From 8c93f7e2a75c8bc8265b2aaf0885bf3b3434a8b4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:10:26 +0000 Subject: [PATCH 1/8] feat(instrument): reload persisted releases after restart After process restart, load the stored locale, digest, item set, and publication state before starting a new session. A Published snapshot may start sessions on that exact form. Missing identity is absent. Duplicate stored item versions fail closed. Exact persist replay stays Duplicate. Publication evidence is not invented on reload. Co-authored-by: Seongho Bae --- CHANGELOG.md | 1 + docs/TRACEABILITY.md | 8 +- docs/architecture/AS_BUILT_SCHEMA.md | 2 + docs/doctoring/standards-and-evidence.md | 3 +- src/instrument.rs | 31 +++ src/postgres_instrument_release.rs | 187 +++++++++++++++++- tests/instrument_release_persisted.rs | 131 ++++++++++++ ...tgres_instrument_release_error_contract.rs | 4 + ...postgres_instrument_release_persistence.rs | 130 +++++++++++- 9 files changed, 490 insertions(+), 7 deletions(-) create mode 100644 tests/instrument_release_persisted.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b2f801dd..28d3771d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable product and architecture changes are recorded here. Releases use imm ## Unreleased ### Added +- After restart, `load_instrument_release` reconstructs the stored locale, digest, item set, and publication state so a worker can decide whether that exact form may start new sessions. Missing identity returns no release. Duplicate stored item versions or other noncanonical rows fail closed. Exact persist replay of the loaded snapshot stays Duplicate. Publication event history and bound evidence are not in this adapter; reactivation still requires rebound approved evidence. - Scoring-job cancel and lease-expiry fallback classification lock the current row until the caller transaction ends, so concurrent workers cannot rewrite terminal or unleased evidence. - PostgreSQL operational-store readiness probe classifies the supported major version and write-readiness, and fails closed when a caller-declared required relation is missing. - PostgreSQL scoring-job cancellation: queued, leased, or retry-scheduled work becomes cancelled without transferring a fence, exact replay is idempotent, and completed or quarantined evidence cannot be rewritten. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 72bc73c2..96733b0f 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -30,7 +30,7 @@ An active PR, architecture document, conversation decision, or scheduler plan is | Immutable result provenance | PRD §3.1, §9.4 | TRD §9 | ADR-0004, ADR-0010 | **Implemented** in `src/result.rs`; result-serving transport is Target | | Deterministic narrative fallback | PRD §3.2, §9.5 | TRD §17; Architecture narrative view | ADR-0009, ADR-0010, ADR-0018 | Target | | Continuous scores remain source of truth; Personality Style is presentation | PRD §3.2 | Measurement Governance; AI Governance | ADR-0018 | Target product narrative mapping; numeric source remains External fast-mlsirm contract | -| Immutable instrument release/version lifecycle | PRD §6, §9 | TRD §7; UML publication state | ADR-0005, ADR-0010 | **Implemented** in `src/instrument.rs` plus `migrations/0006_instrument_release.sql` and `src/postgres_instrument_release.rs`: immutable release manifest, exact version/digest/locale/item set, fail-closed Draft/Review/Published/Suspended/Retired lifecycle, idempotent publication events, and new-session eligibility | +| Immutable instrument release/version lifecycle | PRD §6, §9 | TRD §7; UML publication state | ADR-0005, ADR-0010 | **Implemented** persist and domain lifecycle in `src/instrument.rs` plus `migrations/0006_instrument_release.sql` and `src/postgres_instrument_release.rs`. Reload-after-restart of the stored locale/digest/item set and `accepts_new_sessions` decision is Active PR work; HTTP publication/catalog transport remains Target | | Instrument publication requires intended-use scientific/right/locale evidence | PRD §6, §9, §10 | Measurement Governance; publication evidence gate | ADR-0004, ADR-0013, ADR-0019 | **Implemented** policy gate and immutable evidence provenance in `src/instrument.rs`; each real instrument still requires its own rights/locale/scientific evidence artifacts before publication | | Optional Keyverse account linking | PRD §3.1, §9.7 | TRD §10; UML identity-link lifecycle | ADR-0003, ADR-0020 | **Partially implemented**: issuer-scoped first-link fail-closed domain primitive in `src/participant.rs`; append-only unlink/relink/recovery history, persistence, audit, and transport remain Target | | Cross-cutting tenant/task authorization | PRD §7, §9 | TRD §11; Security/Data | ADR-0001, ADR-0003 | **Implemented** fail-closed domain gate in `src/authorization.rs` binds consent operations to participant-owned `ConsentLedger` / `ManageOwnConsent`; persistence/policy-adapter/public-transport integration remains Target | @@ -62,8 +62,8 @@ An active PR, architecture document, conversation decision, or scheduler plan is | Scientific failure is typed, no invented score | TRD §8; Measurement Governance | scoring contract tests | cross-process failure injection | | Historical result does not mutate | TRD §9 | `src/result.rs` snapshot semantics | persistence and API supersession tests | | Narrative cannot mutate score / deterministic fallback exists | AI Governance; ADR-0018 | architecture policy | mapping implementation + canonical style-assignment key + fallback/no-score-mutation tests | -| Instrument release bytes/version/item order are immutable | TRD §7 | `src/instrument.rs` + publication contract tests; `src/postgres_instrument_release.rs` persists immutable manifest columns | API publication integration | -| Only Published release accepts new sessions | TRD §7 | `PublicationState::accepts_new_sessions` in `src/instrument.rs`; `AssessmentSession` creation copies exact published release/version/locale provenance and fails closed on unpublished eligibility or locale mismatch | session-creation persistence/API integration test | +| Instrument release bytes/version/item order are immutable | TRD §7 | `src/instrument.rs` + publication contract tests; `src/postgres_instrument_release.rs` persists immutable manifest columns; reload-after-restart is Active PR work | API publication integration | +| Only Published release accepts new sessions | TRD §7 | `PublicationState::accepts_new_sessions` in `src/instrument.rs`; `AssessmentSession` creation copies exact published release/version/locale provenance and fails closed on unpublished eligibility or locale mismatch; persisted-state reload is Active PR work | session-creation persistence/API integration test | | Publication event replay is idempotent/conflicting reuse fails closed | TRD §7 | `src/instrument.rs` | durable DB uniqueness/concurrency test | | Published instrument requires exact-version scientific evidence | Measurement Governance; ADR-0019 | `src/instrument.rs` binds approved evidence status, provenance/scope, mandatory evidence references, validity window, and immutable release identity before publication/reactivation | persistence/API publication integration and real instrument-specific evidence artifacts | | Optional account linking does not rewrite historical participant/result identity | ADR-0003, ADR-0020 | `src/participant.rs` issuer-scoped first-link primitive preserves stable participant ID | append-only identity-link persistence + unlink/relink/recovery audit tests | @@ -132,6 +132,8 @@ Still-Target logical modules/adapters include remaining product aggregate persis ### Active implementation work that is not protected-main truth +**Active PR** instrument-release reload is not protected-main truth until an unchanged reviewed/check-clean head is integrated. `load_instrument_release` reconstructs the stored locale, digest, item set, and publication state so a restarted worker can decide whether that exact form may start new sessions. Exact persist replay stays Duplicate. Missing identity returns `None`. Duplicate stored item versions and other noncanonical rows fail closed. Publication event history and bound evidence are not stored by this adapter. HTTP publication/catalog transport remains Target. `src/postgres_instrument_release.rs` and `migrations/0006_instrument_release.sql` are the adapter/migration for this slice. + **Active PR** #76 data-rights processing-start persistence is not protected-main truth until an unchanged reviewed/check-clean head is integrated. Identity-verified requests persist an immutable operation identity and processing-start time under `FOR UPDATE` so later lifecycle composition cannot race the classified row. Dependent-system execution remains outside this slice. ## 5. ADR traceability by concern diff --git a/docs/architecture/AS_BUILT_SCHEMA.md b/docs/architecture/AS_BUILT_SCHEMA.md index 8c2c3510..d333dfd6 100644 --- a/docs/architecture/AS_BUILT_SCHEMA.md +++ b/docs/architecture/AS_BUILT_SCHEMA.md @@ -56,6 +56,8 @@ The protected-main slice persists: - reachable publication-state advance without rewriting immutable manifest columns; - fail-closed digest/identity rebinding and unreachable lifecycle rewind. +Reload of the stored locale, digest, item set, and publication state after process restart is Active PR work and is not protected-main truth until an unchanged reviewed/check-clean head is integrated. After that lands, call `load_instrument_release` before starting a new session on a previously persisted form. + The slice does **not** persist publication-event history, bound scientific evidence records, HTTP publication transport, or session-creation integration. Those remain Target unless separately evidenced on protected main. ## Logical-to-physical mapping rule diff --git a/docs/doctoring/standards-and-evidence.md b/docs/doctoring/standards-and-evidence.md index 7879474c..ba35de18 100644 --- a/docs/doctoring/standards-and-evidence.md +++ b/docs/doctoring/standards-and-evidence.md @@ -1,7 +1,7 @@ # Standards and Evidence Baseline - Status: Living doctoring record -- Last reviewed: 2026-08-11 +- Last reviewed: 2026-08-16 - Scope: Psychometrics Commons product, hosted runtime, reference clients, optional AI, identity integration, and assessment governance This record identifies authoritative standards and primary guidance that materially constrain product design. It is not a certification claim. Each implementation PR that relies on one of these sources must translate the source into a concrete requirement, test, control, or ADR rather than citing it decoratively. @@ -13,6 +13,7 @@ The product's core scientific governance follows the *Standards for Educational Product consequences: - an instrument release states intended score interpretations and prohibited/unsupported uses; +- a published form remains reconstructable after process restart from its stored locale, digest, item set, and publication state so administration uses the same versioned instrument the participant was assigned; - scoring and norms are versioned and reproducible; - precision/uncertainty is not hidden behind a point estimate; - translated forms and group comparisons require evidence appropriate to the intended comparison; diff --git a/src/instrument.rs b/src/instrument.rs index bb01b238..3fc23fc1 100644 --- a/src/instrument.rs +++ b/src/instrument.rs @@ -724,6 +724,37 @@ impl InstrumentRelease { }) } + /// Rebuild one persisted instrument-release snapshot after process restart. + /// + /// Call this with the stored manifest, publication state, and creation time + /// before starting a new session. If the reconstructed state is + /// [`PublicationState::Published`], start sessions on that exact locale, + /// digest, and item set. Event history and bound publication evidence are + /// not part of the persist snapshot, so reactivation still requires the + /// caller to bind approved evidence again. + /// + /// # Errors + /// + /// Returns [`InstrumentReleaseError::InvalidTimestamp`] when the stored + /// creation time is zero. + pub fn from_persisted_snapshot( + manifest: InstrumentReleaseManifest, + state: PublicationState, + created_at_unix_ms: u64, + ) -> Result { + if created_at_unix_ms == 0 { + return Err(InstrumentReleaseError::InvalidTimestamp); + } + Ok(Self { + manifest, + publication_evidence: None, + state, + created_at_unix_ms, + latest_event_at_unix_ms: created_at_unix_ms, + events: Vec::new(), + }) + } + /// Return the immutable release manifest. #[must_use] pub const fn manifest(&self) -> &InstrumentReleaseManifest { diff --git a/src/postgres_instrument_release.rs b/src/postgres_instrument_release.rs index 868e52b2..d6968a27 100644 --- a/src/postgres_instrument_release.rs +++ b/src/postgres_instrument_release.rs @@ -6,7 +6,9 @@ //! `READ COMMITTED` so a concurrent insert that wins a unique-key race is visible //! to the exact-replay classifier. -use crate::instrument::{InstrumentRelease, InstrumentReleaseManifest, PublicationState}; +use crate::instrument::{ + InstrumentRelease, InstrumentReleaseError, InstrumentReleaseManifest, PublicationState, +}; use crate::reference::normalized_reference; use postgres::Transaction; use std::error::Error; @@ -39,6 +41,8 @@ pub enum InstrumentReleasePersistenceError { InvalidTimestamp, /// Instrument-release persistence requires `PostgreSQL` `READ COMMITTED` isolation. UnsupportedIsolationLevel, + /// Durable rows cannot reconstruct the published instrument release. + InconsistentEvidence, /// `PostgreSQL` rejected or could not execute the persistence operation. Database(postgres::Error), } @@ -61,6 +65,9 @@ impl Display for InstrumentReleasePersistenceError { Self::UnsupportedIsolationLevel => { "instrument release persistence requires read committed isolation" } + Self::InconsistentEvidence => { + "durable instrument-release evidence cannot reconstruct the published snapshot" + } Self::Database(_) => "PostgreSQL instrument-release persistence failed", }) } @@ -152,6 +159,84 @@ pub fn persist_instrument_release( classify_existing_release(transaction, manifest, publication_state, created_at) } +/// Load one persisted instrument release after process restart. +/// +/// Call this with the release reference a session or catalog already holds. +/// A Published reconstruction may start new sessions on that exact locale, +/// digest, and item set. Missing identity returns `None`. Duplicate stored +/// item versions or other noncanonical rows fail closed so a worker cannot +/// treat corruption as an eligible form. Exact persist replay of the loaded +/// snapshot stays [`InstrumentReleasePersistenceDisposition::Duplicate`]. +/// Event history and bound publication evidence are not stored by this +/// adapter; reactivation still requires rebound approved evidence. +/// +/// # Errors +/// +/// Returns [`InstrumentReleasePersistenceError`] for unsupported isolation, +/// an invalid release reference, inconsistent durable evidence, or a +/// database failure. +pub fn load_instrument_release( + transaction: &mut Transaction<'_>, + release_ref: &str, +) -> Result, InstrumentReleasePersistenceError> { + require_read_committed(transaction)?; + let release_ref = required_reference(release_ref)?; + let header = transaction.query_opt( + "SELECT instrument_ref, instrument_version_ref, construct_ref, item_version_refs, \ + locale, assessment_spec_ref, scoring_version_ref, calibration_reference, \ + norm_version_ref, narrative_version_ref, consent_requirement_refs, \ + intended_use_ref, limitations_ref, content_digest, publication_state, \ + created_at_unix_ms \ + FROM instrument_release WHERE release_ref = $1", + &[&release_ref], + )?; + let Some(header) = header else { + return Ok(None); + }; + let instrument_ref: String = header.get(0); + let instrument_version_ref: String = header.get(1); + let construct_ref: String = header.get(2); + let item_version_refs: Vec = header.get(3); + let locale: String = header.get(4); + let assessment_spec_ref: String = header.get(5); + let scoring_version_ref: String = header.get(6); + let calibration_reference: String = header.get(7); + let norm_version_ref: Option = header.get(8); + let narrative_version_ref: String = header.get(9); + let consent_requirement_refs: Vec = header.get(10); + let intended_use_ref: String = header.get(11); + let limitations_ref: String = header.get(12); + let content_digest: String = header.get(13); + let publication_state = publication_state_from_stored(&header.get::<_, String>(14))?; + let created_at_unix_ms = stored_timestamp(header.get(15))?; + let item_refs: Vec<&str> = item_version_refs.iter().map(String::as_str).collect(); + let consent_refs: Vec<&str> = consent_requirement_refs + .iter() + .map(String::as_str) + .collect(); + let manifest = InstrumentReleaseManifest::new( + release_ref, + &instrument_ref, + &instrument_version_ref, + &construct_ref, + &item_refs, + &locale, + &assessment_spec_ref, + &scoring_version_ref, + &calibration_reference, + norm_version_ref.as_deref(), + &narrative_version_ref, + &consent_refs, + &intended_use_ref, + &limitations_ref, + &content_digest, + ) + .map_err(durable_evidence_error)?; + InstrumentRelease::from_persisted_snapshot(manifest, publication_state, created_at_unix_ms) + .map(Some) + .map_err(durable_evidence_error) +} + fn classify_existing_release( transaction: &mut Transaction<'_>, manifest: &InstrumentReleaseManifest, @@ -262,6 +347,46 @@ fn publication_state_name(state: PublicationState) -> &'static str { } } +fn publication_state_from_stored( + stored: &str, +) -> Result { + match stored { + "draft" => Ok(PublicationState::Draft), + "review" => Ok(PublicationState::Review), + "published" => Ok(PublicationState::Published), + "suspended" => Ok(PublicationState::Suspended), + "retired" => Ok(PublicationState::Retired), + _ => Err(InstrumentReleasePersistenceError::InconsistentEvidence), + } +} + +fn stored_timestamp(timestamp: i64) -> Result { + u64::try_from(timestamp).map_err(|_| InstrumentReleasePersistenceError::InconsistentEvidence) +} + +fn durable_evidence_error(error: InstrumentReleaseError) -> InstrumentReleasePersistenceError { + match error { + InstrumentReleaseError::InvalidReference + | InstrumentReleaseError::EmptyItemSet + | InstrumentReleaseError::DuplicateItemReference + | InstrumentReleaseError::InvalidLocale + | InstrumentReleaseError::InvalidDigest + | InstrumentReleaseError::InvalidEvidenceDigest + | InstrumentReleaseError::InvalidEvidenceWindow + | InstrumentReleaseError::IncompletePublicationEvidence + | InstrumentReleaseError::PublicationEvidenceMismatch + | InstrumentReleaseError::MissingPublicationEvidence + | InstrumentReleaseError::PublicationEvidenceNotApproved + | InstrumentReleaseError::PublicationEvidenceNotEffective + | InstrumentReleaseError::InvalidTimestamp + | InstrumentReleaseError::NonMonotonicTimestamp + | InstrumentReleaseError::ConflictingReplay + | InstrumentReleaseError::InvalidTransition => { + InstrumentReleasePersistenceError::InconsistentEvidence + } + } +} + fn required_reference(reference: &str) -> Result<&str, InstrumentReleasePersistenceError> { normalized_reference(reference).ok_or(InstrumentReleasePersistenceError::InvalidReference) } @@ -285,9 +410,11 @@ fn require_read_committed( #[cfg(test)] mod reference_guard_tests { use super::{ - postgres_timestamp, publication_state_may_replace, required_reference, + durable_evidence_error, postgres_timestamp, publication_state_from_stored, + publication_state_may_replace, required_reference, stored_timestamp, InstrumentReleasePersistenceError, }; + use crate::instrument::{InstrumentReleaseError, PublicationState}; #[test] fn blank_numeric_and_overflow_inputs_fail_closed() { @@ -347,4 +474,60 @@ mod reference_guard_tests { ); } } + + #[test] + fn stored_publication_states_rebuild_or_fail_closed() { + assert_eq!( + publication_state_from_stored("draft").unwrap(), + PublicationState::Draft + ); + assert_eq!( + publication_state_from_stored("review").unwrap(), + PublicationState::Review + ); + assert_eq!( + publication_state_from_stored("published").unwrap(), + PublicationState::Published + ); + assert_eq!( + publication_state_from_stored("suspended").unwrap(), + PublicationState::Suspended + ); + assert_eq!( + publication_state_from_stored("retired").unwrap(), + PublicationState::Retired + ); + assert!(matches!( + publication_state_from_stored("unknown"), + Err(InstrumentReleasePersistenceError::InconsistentEvidence) + )); + assert_eq!(stored_timestamp(40_000).unwrap(), 40_000); + assert!(matches!( + stored_timestamp(-1), + Err(InstrumentReleasePersistenceError::InconsistentEvidence) + )); + for error in [ + InstrumentReleaseError::InvalidReference, + InstrumentReleaseError::EmptyItemSet, + InstrumentReleaseError::DuplicateItemReference, + InstrumentReleaseError::InvalidLocale, + InstrumentReleaseError::InvalidDigest, + InstrumentReleaseError::InvalidEvidenceDigest, + InstrumentReleaseError::InvalidEvidenceWindow, + InstrumentReleaseError::IncompletePublicationEvidence, + InstrumentReleaseError::PublicationEvidenceMismatch, + InstrumentReleaseError::MissingPublicationEvidence, + InstrumentReleaseError::PublicationEvidenceNotApproved, + InstrumentReleaseError::PublicationEvidenceNotEffective, + InstrumentReleaseError::InvalidTimestamp, + InstrumentReleaseError::NonMonotonicTimestamp, + InstrumentReleaseError::ConflictingReplay, + InstrumentReleaseError::InvalidTransition, + ] { + assert!(matches!( + durable_evidence_error(error), + InstrumentReleasePersistenceError::InconsistentEvidence + )); + } + } } diff --git a/tests/instrument_release_persisted.rs b/tests/instrument_release_persisted.rs new file mode 100644 index 00000000..47ccf585 --- /dev/null +++ b/tests/instrument_release_persisted.rs @@ -0,0 +1,131 @@ +//! Domain contract for rebuilding a persisted instrument release after restart. +//! +//! After process restart, call [`InstrumentRelease::from_persisted_snapshot`] +//! with the stored manifest, publication state, and creation time. If the +//! reconstructed release is Published, start new sessions on that exact +//! locale, digest, and item set. Do not use this reconstruction to continue +//! a publication-evidence workflow: stored persist rows do not carry event +//! history or bound evidence. + +use psychometrics_commons_runtime::instrument::{ + InstrumentRelease, InstrumentReleaseError, InstrumentReleaseManifest, PublicationCommand, + PublicationState, +}; + +const VALID_DIGEST: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn published_manifest() -> InstrumentReleaseManifest { + InstrumentReleaseManifest::new( + "release_big_five_ko_v1", + "instrument_big_five", + "instrument_version_big_five_ko_v1", + "construct_big_five", + &["item_version_001", "item_version_002"], + "ko-KR", + "assessment_spec_big_five_v1", + "scoring_version_big_five_v1", + "calibration_big_five_ko_v1", + Some("norm_version_big_five_ko_v1"), + "narrative_version_big_five_v1", + &["consent_service_v1"], + "intended_use_self_reflection_v1", + "limitations_nonclinical_v1", + VALID_DIGEST, + ) + .unwrap() +} + +#[test] +fn persisted_published_release_may_start_sessions_on_the_exact_form() { + let release = InstrumentRelease::from_persisted_snapshot( + published_manifest(), + PublicationState::Published, + 40_000, + ) + .unwrap(); + + assert!(release.accepts_new_sessions()); + assert_eq!(release.state(), PublicationState::Published); + assert_eq!(release.created_at_unix_ms(), 40_000); + assert_eq!(release.manifest().locale(), "ko-KR"); + assert_eq!(release.manifest().content_digest(), VALID_DIGEST); + assert_eq!( + release.manifest().item_version_refs(), + ["item_version_001", "item_version_002"] + ); + assert!(release.events().is_empty()); + assert!(release.publication_evidence().is_none()); +} + +#[test] +fn persisted_non_published_states_block_new_sessions() { + for state in [ + PublicationState::Draft, + PublicationState::Review, + PublicationState::Suspended, + PublicationState::Retired, + ] { + let release = + InstrumentRelease::from_persisted_snapshot(published_manifest(), state, 40_000) + .unwrap(); + assert!( + !release.accepts_new_sessions(), + "{state:?} must not start new sessions after reload" + ); + assert_eq!(release.state(), state); + } +} + +#[test] +fn persisted_snapshot_rejects_zero_creation_time() { + assert_eq!( + InstrumentRelease::from_persisted_snapshot( + published_manifest(), + PublicationState::Published, + 0 + ) + .unwrap_err(), + InstrumentReleaseError::InvalidTimestamp + ); +} + +#[test] +fn persisted_published_release_can_suspend_without_inventing_evidence() { + let mut release = InstrumentRelease::from_persisted_snapshot( + published_manifest(), + PublicationState::Published, + 40_000, + ) + .unwrap(); + + assert_eq!( + release + .apply_command("suspend_after_reload", PublicationCommand::Suspend, 40_300) + .unwrap(), + PublicationState::Suspended + ); + assert!(!release.accepts_new_sessions()); +} + +#[test] +fn persisted_suspended_release_cannot_reactivate_without_rebound_evidence() { + let mut release = InstrumentRelease::from_persisted_snapshot( + published_manifest(), + PublicationState::Suspended, + 40_000, + ) + .unwrap(); + + assert_eq!( + release + .apply_command( + "reactivate_after_reload", + PublicationCommand::Reactivate, + 40_400 + ) + .unwrap_err(), + InstrumentReleaseError::MissingPublicationEvidence + ); + assert_eq!(release.state(), PublicationState::Suspended); +} diff --git a/tests/postgres_instrument_release_error_contract.rs b/tests/postgres_instrument_release_error_contract.rs index 5cbe4cad..e1ad0d6f 100644 --- a/tests/postgres_instrument_release_error_contract.rs +++ b/tests/postgres_instrument_release_error_contract.rs @@ -32,6 +32,10 @@ fn persistence_errors_expose_stable_messages_and_database_sources() { InstrumentReleasePersistenceError::UnsupportedIsolationLevel, "instrument release persistence requires read committed isolation", ), + ( + InstrumentReleasePersistenceError::InconsistentEvidence, + "durable instrument-release evidence cannot reconstruct the published snapshot", + ), ] { assert_eq!(error.to_string(), expected_message); assert!(std::error::Error::source(&error).is_none()); diff --git a/tests/postgres_instrument_release_persistence.rs b/tests/postgres_instrument_release_persistence.rs index cfa4bf17..5a37dd4f 100644 --- a/tests/postgres_instrument_release_persistence.rs +++ b/tests/postgres_instrument_release_persistence.rs @@ -7,7 +7,7 @@ use psychometrics_commons_runtime::instrument::{ PublicationState, }; use psychometrics_commons_runtime::postgres_instrument_release::{ - apply_instrument_release_migration, persist_instrument_release, + apply_instrument_release_migration, load_instrument_release, persist_instrument_release, InstrumentReleasePersistenceDisposition, InstrumentReleasePersistenceError, }; use std::sync::{Mutex, MutexGuard}; @@ -474,3 +474,131 @@ fn unreachable_publication_state_rewind_fails_closed() { "published" ); } + +#[test] +fn published_release_reloads_after_restart_and_stays_scoreable_for_new_sessions() { + let _guard = instrument_release_test_guard(); + let mut client = test_client(); + reset_instrument_release_tables(&mut client); + apply_instrument_release_migration(&mut client).unwrap(); + + let published = published_release(); + persist_ok(&mut client, &published); + + let mut transaction = client.transaction().unwrap(); + let loaded = load_instrument_release(&mut transaction, "release_big_five_ko_v1") + .unwrap() + .expect("stored published release must reload after restart"); + assert!(loaded.accepts_new_sessions()); + assert_eq!(loaded.state(), PublicationState::Published); + assert_eq!(loaded.manifest().locale(), "ko-KR"); + assert_eq!(loaded.manifest().content_digest(), RELEASE_DIGEST); + assert_eq!( + loaded.manifest().item_version_refs(), + ["item_version_001", "item_version_002"] + ); + assert_eq!( + persist_instrument_release(&mut transaction, &loaded).unwrap(), + InstrumentReleasePersistenceDisposition::Duplicate + ); + transaction.commit().unwrap(); +} + +#[test] +fn draft_release_reloads_but_does_not_start_new_sessions() { + let _guard = instrument_release_test_guard(); + let mut client = test_client(); + reset_instrument_release_tables(&mut client); + apply_instrument_release_migration(&mut client).unwrap(); + + let draft = + InstrumentRelease::new(manifest("release_big_five_ko_v1", RELEASE_DIGEST), 40_000).unwrap(); + persist_ok(&mut client, &draft); + + let mut transaction = client.transaction().unwrap(); + let loaded = load_instrument_release(&mut transaction, "release_big_five_ko_v1") + .unwrap() + .expect("stored draft release must reload after restart"); + assert!(!loaded.accepts_new_sessions()); + assert_eq!(loaded.state(), PublicationState::Draft); + transaction.commit().unwrap(); +} + +#[test] +fn missing_instrument_release_is_absent_after_restart() { + let _guard = instrument_release_test_guard(); + let mut client = test_client(); + reset_instrument_release_tables(&mut client); + apply_instrument_release_migration(&mut client).unwrap(); + + let mut transaction = client.transaction().unwrap(); + assert!( + load_instrument_release(&mut transaction, "release_big_five_missing") + .unwrap() + .is_none() + ); + transaction.commit().unwrap(); +} + +#[test] +fn instrument_release_load_rejects_blank_or_numeric_identity() { + let _guard = instrument_release_test_guard(); + let mut client = test_client(); + reset_instrument_release_tables(&mut client); + apply_instrument_release_migration(&mut client).unwrap(); + + let mut transaction = client.transaction().unwrap(); + assert!(matches!( + load_instrument_release(&mut transaction, " "), + Err(InstrumentReleasePersistenceError::InvalidReference) + )); + assert!(matches!( + load_instrument_release(&mut transaction, "12"), + Err(InstrumentReleasePersistenceError::InvalidReference) + )); + transaction.rollback().unwrap(); +} + +#[test] +fn instrument_release_load_requires_read_committed_isolation() { + let _guard = instrument_release_test_guard(); + let mut client = test_client(); + reset_instrument_release_tables(&mut client); + apply_instrument_release_migration(&mut client).unwrap(); + + let mut transaction = client + .build_transaction() + .isolation_level(IsolationLevel::Serializable) + .start() + .unwrap(); + assert!(matches!( + load_instrument_release(&mut transaction, "release_big_five_ko_v1"), + Err(InstrumentReleasePersistenceError::UnsupportedIsolationLevel) + )); + transaction.rollback().unwrap(); +} + +#[test] +fn duplicate_stored_item_versions_fail_closed_instead_of_starting_sessions() { + let _guard = instrument_release_test_guard(); + let mut client = test_client(); + reset_instrument_release_tables(&mut client); + apply_instrument_release_migration(&mut client).unwrap(); + + persist_ok(&mut client, &published_release()); + client + .execute( + "UPDATE instrument_release SET item_version_refs = ARRAY[\ + 'item_version_001', 'item_version_001'\ + ] WHERE release_ref = 'release_big_five_ko_v1'", + &[], + ) + .unwrap(); + + let mut transaction = client.transaction().unwrap(); + assert!(matches!( + load_instrument_release(&mut transaction, "release_big_five_ko_v1"), + Err(InstrumentReleasePersistenceError::InconsistentEvidence) + )); + transaction.rollback().unwrap(); +} From db56dd28fe7cbea28fc04835a042d55322825d3d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:11:08 +0000 Subject: [PATCH 2/8] docs(traceability): name instrument-release reload as Active PR #171 Co-authored-by: Seongho Bae --- docs/TRACEABILITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 96733b0f..e1573211 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -132,7 +132,7 @@ Still-Target logical modules/adapters include remaining product aggregate persis ### Active implementation work that is not protected-main truth -**Active PR** instrument-release reload is not protected-main truth until an unchanged reviewed/check-clean head is integrated. `load_instrument_release` reconstructs the stored locale, digest, item set, and publication state so a restarted worker can decide whether that exact form may start new sessions. Exact persist replay stays Duplicate. Missing identity returns `None`. Duplicate stored item versions and other noncanonical rows fail closed. Publication event history and bound evidence are not stored by this adapter. HTTP publication/catalog transport remains Target. `src/postgres_instrument_release.rs` and `migrations/0006_instrument_release.sql` are the adapter/migration for this slice. +**Active PR** #171 instrument-release reload is not protected-main truth until an unchanged reviewed/check-clean head is integrated. `load_instrument_release` reconstructs the stored locale, digest, item set, and publication state so a restarted worker can decide whether that exact form may start new sessions. Exact persist replay stays Duplicate. Missing identity returns `None`. Duplicate stored item versions and other noncanonical rows fail closed. Publication event history and bound evidence are not stored by this adapter. HTTP publication/catalog transport remains Target. `src/postgres_instrument_release.rs` and `migrations/0006_instrument_release.sql` are the adapter/migration for this slice. **Active PR** #76 data-rights processing-start persistence is not protected-main truth until an unchanged reviewed/check-clean head is integrated. Identity-verified requests persist an immutable operation identity and processing-start time under `FOR UPDATE` so later lifecycle composition cannot race the classified row. Dependent-system execution remains outside this slice. From e6785a2f78e26f9edc1c8b9d4a958da0dd1f9ba1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:11:54 +0000 Subject: [PATCH 3/8] docs(instrument): distinguish #171 load-any-state from #164 start Co-authored-by: Seongho Bae --- CHANGELOG.md | 2 +- docs/TRACEABILITY.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28d3771d..a3fc306b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable product and architecture changes are recorded here. Releases use imm ## Unreleased ### Added -- After restart, `load_instrument_release` reconstructs the stored locale, digest, item set, and publication state so a worker can decide whether that exact form may start new sessions. Missing identity returns no release. Duplicate stored item versions or other noncanonical rows fail closed. Exact persist replay of the loaded snapshot stays Duplicate. Publication event history and bound evidence are not in this adapter; reactivation still requires rebound approved evidence. +- After restart, `load_instrument_release` reconstructs any stored publication state by release reference so a worker can decide whether that exact form may start new sessions. Missing identity returns no release. Duplicate stored item versions or other noncanonical rows fail closed. Exact persist replay of the loaded snapshot stays Duplicate. Publication event history and bound evidence are not in this adapter; reactivation still requires rebound approved evidence. Session start from a stored Published locale-matched release remains #164. - Scoring-job cancel and lease-expiry fallback classification lock the current row until the caller transaction ends, so concurrent workers cannot rewrite terminal or unleased evidence. - PostgreSQL operational-store readiness probe classifies the supported major version and write-readiness, and fails closed when a caller-declared required relation is missing. - PostgreSQL scoring-job cancellation: queued, leased, or retry-scheduled work becomes cancelled without transferring a fence, exact replay is idempotent, and completed or quarantined evidence cannot be rewritten. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index e1573211..0ccd2c7c 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -132,7 +132,7 @@ Still-Target logical modules/adapters include remaining product aggregate persis ### Active implementation work that is not protected-main truth -**Active PR** #171 instrument-release reload is not protected-main truth until an unchanged reviewed/check-clean head is integrated. `load_instrument_release` reconstructs the stored locale, digest, item set, and publication state so a restarted worker can decide whether that exact form may start new sessions. Exact persist replay stays Duplicate. Missing identity returns `None`. Duplicate stored item versions and other noncanonical rows fail closed. Publication event history and bound evidence are not stored by this adapter. HTTP publication/catalog transport remains Target. `src/postgres_instrument_release.rs` and `migrations/0006_instrument_release.sql` are the adapter/migration for this slice. +**Active PR** #171 instrument-release reload is not protected-main truth until an unchanged reviewed/check-clean head is integrated. `load_instrument_release` reconstructs any stored publication state by `release_ref` so a restarted worker can see whether that exact form may start new sessions. Exact persist replay stays Duplicate. Missing identity returns `None`. Duplicate stored item versions and other noncanonical rows fail closed. Publication event history and bound evidence are not stored by this adapter. This is complementary to #164 `load_published_instrument_release`, which is the session-start landing vehicle and returns only a Published locale-matched snapshot. Do not merge #171 in parallel with #164; rebase this slice after #164. HTTP publication/catalog transport remains Target and is tracked on #165. `src/postgres_instrument_release.rs` and `migrations/0006_instrument_release.sql` are the adapter/migration for this slice. **Active PR** #76 data-rights processing-start persistence is not protected-main truth until an unchanged reviewed/check-clean head is integrated. Identity-verified requests persist an immutable operation identity and processing-start time under `FOR UPDATE` so later lifecycle composition cannot race the classified row. Dependent-system execution remains outside this slice. From 25b5516a8989abab0faf3fe0205fe06b053775d2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:16:05 +0000 Subject: [PATCH 4/8] fix(instrument): name stored-snapshot reconstruction errors honestly load_instrument_release rebuilds Draft, Review, Published, Suspended, and Retired rows. A corrupt row is a stored-snapshot failure, not a published-only failure. Review reconstruction still cannot publish without rebound evidence. Co-authored-by: Seongho Bae --- CHANGELOG.md | 2 +- docs/TRACEABILITY.md | 4 ++-- src/postgres_instrument_release.rs | 8 ++++++-- tests/instrument_release_persisted.rs | 19 +++++++++++++++++++ ...tgres_instrument_release_error_contract.rs | 2 +- 5 files changed, 29 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3fc306b..4d66de3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable product and architecture changes are recorded here. Releases use imm ## Unreleased ### Added -- After restart, `load_instrument_release` reconstructs any stored publication state by release reference so a worker can decide whether that exact form may start new sessions. Missing identity returns no release. Duplicate stored item versions or other noncanonical rows fail closed. Exact persist replay of the loaded snapshot stays Duplicate. Publication event history and bound evidence are not in this adapter; reactivation still requires rebound approved evidence. Session start from a stored Published locale-matched release remains #164. +- After restart, `load_instrument_release` reconstructs any stored publication state by release reference so a worker can decide whether that exact form may start new sessions. Missing identity returns no release. Duplicate stored item versions or other noncanonical rows fail closed as a stored-snapshot reconstruction error, not a published-only error. Exact persist replay of the loaded snapshot stays Duplicate. Publication event history and bound evidence are not in this adapter; reactivation still requires rebound approved evidence. Session start from a stored Published locale-matched release remains #164. - Scoring-job cancel and lease-expiry fallback classification lock the current row until the caller transaction ends, so concurrent workers cannot rewrite terminal or unleased evidence. - PostgreSQL operational-store readiness probe classifies the supported major version and write-readiness, and fails closed when a caller-declared required relation is missing. - PostgreSQL scoring-job cancellation: queued, leased, or retry-scheduled work becomes cancelled without transferring a fence, exact replay is idempotent, and completed or quarantined evidence cannot be rewritten. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 0ccd2c7c..964cec92 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -132,9 +132,9 @@ Still-Target logical modules/adapters include remaining product aggregate persis ### Active implementation work that is not protected-main truth -**Active PR** #171 instrument-release reload is not protected-main truth until an unchanged reviewed/check-clean head is integrated. `load_instrument_release` reconstructs any stored publication state by `release_ref` so a restarted worker can see whether that exact form may start new sessions. Exact persist replay stays Duplicate. Missing identity returns `None`. Duplicate stored item versions and other noncanonical rows fail closed. Publication event history and bound evidence are not stored by this adapter. This is complementary to #164 `load_published_instrument_release`, which is the session-start landing vehicle and returns only a Published locale-matched snapshot. Do not merge #171 in parallel with #164; rebase this slice after #164. HTTP publication/catalog transport remains Target and is tracked on #165. `src/postgres_instrument_release.rs` and `migrations/0006_instrument_release.sql` are the adapter/migration for this slice. +**Active PR** #171 instrument-release reload is not protected-main truth until an unchanged reviewed/check-clean head is integrated. `load_instrument_release` reconstructs any stored publication state by `release_ref` so a restarted worker can see whether that exact form may start new sessions. Exact persist replay stays Duplicate. Missing identity returns `None`. Duplicate stored item versions and other noncanonical rows fail closed as a stored-snapshot reconstruction error, not a published-only error. Publication event history and bound evidence are not stored by this adapter. This is complementary to #164 `load_published_instrument_release`, which is the session-start landing vehicle and returns only a Published locale-matched snapshot. Do not merge #171 in parallel with #164; rebase this slice after #164. HTTP publication/catalog transport remains Target and is tracked on #165. `src/postgres_instrument_release.rs` and `migrations/0006_instrument_release.sql` are the adapter/migration for this slice. -**Active PR** #76 data-rights processing-start persistence is not protected-main truth until an unchanged reviewed/check-clean head is integrated. Identity-verified requests persist an immutable operation identity and processing-start time under `FOR UPDATE` so later lifecycle composition cannot race the classified row. Dependent-system execution remains outside this slice. +Data-rights processing-start persistence from #76 is on this branch ancestry and is no longer Active PR work. Dependent-system execution remains Target. ## 5. ADR traceability by concern diff --git a/src/postgres_instrument_release.rs b/src/postgres_instrument_release.rs index d6968a27..1d50d679 100644 --- a/src/postgres_instrument_release.rs +++ b/src/postgres_instrument_release.rs @@ -41,7 +41,7 @@ pub enum InstrumentReleasePersistenceError { InvalidTimestamp, /// Instrument-release persistence requires `PostgreSQL` `READ COMMITTED` isolation. UnsupportedIsolationLevel, - /// Durable rows cannot reconstruct the published instrument release. + /// Durable rows cannot reconstruct the stored instrument-release snapshot. InconsistentEvidence, /// `PostgreSQL` rejected or could not execute the persistence operation. Database(postgres::Error), @@ -66,7 +66,7 @@ impl Display for InstrumentReleasePersistenceError { "instrument release persistence requires read committed isolation" } Self::InconsistentEvidence => { - "durable instrument-release evidence cannot reconstruct the published snapshot" + "durable instrument-release evidence cannot reconstruct the stored snapshot" } Self::Database(_) => "PostgreSQL instrument-release persistence failed", }) @@ -529,5 +529,9 @@ mod reference_guard_tests { InstrumentReleasePersistenceError::InconsistentEvidence )); } + assert_eq!( + InstrumentReleasePersistenceError::InconsistentEvidence.to_string(), + "durable instrument-release evidence cannot reconstruct the stored snapshot" + ); } } diff --git a/tests/instrument_release_persisted.rs b/tests/instrument_release_persisted.rs index 47ccf585..56ef59c9 100644 --- a/tests/instrument_release_persisted.rs +++ b/tests/instrument_release_persisted.rs @@ -129,3 +129,22 @@ fn persisted_suspended_release_cannot_reactivate_without_rebound_evidence() { ); assert_eq!(release.state(), PublicationState::Suspended); } + +#[test] +fn persisted_review_release_cannot_publish_without_rebound_evidence() { + let mut release = InstrumentRelease::from_persisted_snapshot( + published_manifest(), + PublicationState::Review, + 40_000, + ) + .unwrap(); + + assert_eq!( + release + .apply_command("publish_after_reload", PublicationCommand::Publish, 40_500) + .unwrap_err(), + InstrumentReleaseError::MissingPublicationEvidence + ); + assert!(!release.accepts_new_sessions()); + assert_eq!(release.state(), PublicationState::Review); +} diff --git a/tests/postgres_instrument_release_error_contract.rs b/tests/postgres_instrument_release_error_contract.rs index e1ad0d6f..1d337d49 100644 --- a/tests/postgres_instrument_release_error_contract.rs +++ b/tests/postgres_instrument_release_error_contract.rs @@ -34,7 +34,7 @@ fn persistence_errors_expose_stable_messages_and_database_sources() { ), ( InstrumentReleasePersistenceError::InconsistentEvidence, - "durable instrument-release evidence cannot reconstruct the published snapshot", + "durable instrument-release evidence cannot reconstruct the stored snapshot", ), ] { assert_eq!(error.to_string(), expected_message); From 87123079430065b88cd8c34e8850c83d07de0e46 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:16:36 +0000 Subject: [PATCH 5/8] docs(traceability): name instrument-release reload landing as #179 Prefer #179 over #171 for stored-snapshot reconstruction after restart. Co-authored-by: Seongho Bae --- docs/TRACEABILITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 964cec92..42f018b2 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -132,7 +132,7 @@ Still-Target logical modules/adapters include remaining product aggregate persis ### Active implementation work that is not protected-main truth -**Active PR** #171 instrument-release reload is not protected-main truth until an unchanged reviewed/check-clean head is integrated. `load_instrument_release` reconstructs any stored publication state by `release_ref` so a restarted worker can see whether that exact form may start new sessions. Exact persist replay stays Duplicate. Missing identity returns `None`. Duplicate stored item versions and other noncanonical rows fail closed as a stored-snapshot reconstruction error, not a published-only error. Publication event history and bound evidence are not stored by this adapter. This is complementary to #164 `load_published_instrument_release`, which is the session-start landing vehicle and returns only a Published locale-matched snapshot. Do not merge #171 in parallel with #164; rebase this slice after #164. HTTP publication/catalog transport remains Target and is tracked on #165. `src/postgres_instrument_release.rs` and `migrations/0006_instrument_release.sql` are the adapter/migration for this slice. +**Active PR** #179 instrument-release reload is not protected-main truth until an unchanged reviewed/check-clean head is integrated. Prefer this head over #171. `load_instrument_release` reconstructs any stored publication state by `release_ref` so a restarted worker can see whether that exact form may start new sessions. Exact persist replay stays Duplicate. Missing identity returns `None`. Duplicate stored item versions and other noncanonical rows fail closed as a stored-snapshot reconstruction error, not a published-only error. Publication event history and bound evidence are not stored by this adapter. This is complementary to #164 `load_published_instrument_release`, which is the session-start landing vehicle and returns only a Published locale-matched snapshot. Do not merge #179 in parallel with #164; rebase this slice after #164. HTTP publication/catalog transport remains Target and is tracked on #165. `src/postgres_instrument_release.rs` and `migrations/0006_instrument_release.sql` are the adapter/migration for this slice. Data-rights processing-start persistence from #76 is on this branch ancestry and is no longer Active PR work. Dependent-system execution remains Target. From 2a7b19ee2517800e61d46577a6a6c9fd9b726696 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:23:17 +0000 Subject: [PATCH 6/8] feat(instrument): list startable published releases after restart Offer only stored Published forms after a worker restart so a catalog can copy release_ref and locale into session start. Draft, Review, Suspended, and Retired rows stay hidden. A corrupt Published row fails closed as stored-snapshot reconstruction, not as a startable form. Co-authored-by: Seongho Bae --- CHANGELOG.md | 2 +- README.md | 4 + docs/TRACEABILITY.md | 6 +- docs/architecture/AS_BUILT_SCHEMA.md | 2 +- docs/doctoring/standards-and-evidence.md | 1 + src/postgres_instrument_release.rs | 102 ++++++--- tests/instrument_release_startable_catalog.rs | 61 ++++++ ...postgres_instrument_release_persistence.rs | 207 +++++++++++++++++- 8 files changed, 349 insertions(+), 36 deletions(-) create mode 100644 tests/instrument_release_startable_catalog.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d66de3b..28d35df3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable product and architecture changes are recorded here. Releases use imm ## Unreleased ### Added -- After restart, `load_instrument_release` reconstructs any stored publication state by release reference so a worker can decide whether that exact form may start new sessions. Missing identity returns no release. Duplicate stored item versions or other noncanonical rows fail closed as a stored-snapshot reconstruction error, not a published-only error. Exact persist replay of the loaded snapshot stays Duplicate. Publication event history and bound evidence are not in this adapter; reactivation still requires rebound approved evidence. Session start from a stored Published locale-matched release remains #164. +- After restart, `list_startable_instrument_releases` returns stored Published forms ordered by instrument, locale, then release reference so a worker can offer only currently startable catalogs. Draft, Review, Suspended, and Retired rows are omitted. A corrupt Published row fails closed as a stored-snapshot reconstruction error instead of appearing startable. `load_instrument_release` still reconstructs any stored publication state by release reference. Exact persist replay of a listed snapshot stays Duplicate. Publication event history and bound evidence are not in this adapter. Session start from one stored Published locale-matched release remains #164. HTTP catalog transport remains #165. - Scoring-job cancel and lease-expiry fallback classification lock the current row until the caller transaction ends, so concurrent workers cannot rewrite terminal or unleased evidence. - PostgreSQL operational-store readiness probe classifies the supported major version and write-readiness, and fails closed when a caller-declared required relation is missing. - PostgreSQL scoring-job cancellation: queued, leased, or retry-scheduled work becomes cancelled without transferring a fence, exact replay is idempotent, and completed or quarantined evidence cannot be rewritten. diff --git a/README.md b/README.md index 46b10021..866c1331 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,10 @@ Psychometrics Commons owns product APIs, instrument publication, participant/ses It does **not** duplicate psychometric numerical kernels, identity credentials, temporal model kernels, public research catalog internals, or generic LLM orchestration. `g7` is an optional replaceable reference client rather than a platform dependency. +## Instrument catalog after restart + +After a worker restart, open a `READ COMMITTED` transaction and call `list_startable_instrument_releases`. Copy a returned `release_ref` and exact `locale` into session start. Draft, Review, Suspended, and Retired forms are omitted. If listing fails, repair the corrupt stored snapshot before offering any form. Do not start sessions from a partial untrusted catalog. + ## Documentation ### Product, technical, and governance baseline diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 42f018b2..25ede0a3 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -30,7 +30,7 @@ An active PR, architecture document, conversation decision, or scheduler plan is | Immutable result provenance | PRD §3.1, §9.4 | TRD §9 | ADR-0004, ADR-0010 | **Implemented** in `src/result.rs`; result-serving transport is Target | | Deterministic narrative fallback | PRD §3.2, §9.5 | TRD §17; Architecture narrative view | ADR-0009, ADR-0010, ADR-0018 | Target | | Continuous scores remain source of truth; Personality Style is presentation | PRD §3.2 | Measurement Governance; AI Governance | ADR-0018 | Target product narrative mapping; numeric source remains External fast-mlsirm contract | -| Immutable instrument release/version lifecycle | PRD §6, §9 | TRD §7; UML publication state | ADR-0005, ADR-0010 | **Implemented** persist and domain lifecycle in `src/instrument.rs` plus `migrations/0006_instrument_release.sql` and `src/postgres_instrument_release.rs`. Reload-after-restart of the stored locale/digest/item set and `accepts_new_sessions` decision is Active PR work; HTTP publication/catalog transport remains Target | +| Immutable instrument release/version lifecycle | PRD §6, §9 | TRD §7; UML publication state | ADR-0005, ADR-0010 | **Implemented** persist and domain lifecycle in `src/instrument.rs` plus `migrations/0006_instrument_release.sql` and `src/postgres_instrument_release.rs`. Reload-after-restart and startable-catalog listing of stored Published forms are Active PR work; HTTP publication/catalog transport remains Target | | Instrument publication requires intended-use scientific/right/locale evidence | PRD §6, §9, §10 | Measurement Governance; publication evidence gate | ADR-0004, ADR-0013, ADR-0019 | **Implemented** policy gate and immutable evidence provenance in `src/instrument.rs`; each real instrument still requires its own rights/locale/scientific evidence artifacts before publication | | Optional Keyverse account linking | PRD §3.1, §9.7 | TRD §10; UML identity-link lifecycle | ADR-0003, ADR-0020 | **Partially implemented**: issuer-scoped first-link fail-closed domain primitive in `src/participant.rs`; append-only unlink/relink/recovery history, persistence, audit, and transport remain Target | | Cross-cutting tenant/task authorization | PRD §7, §9 | TRD §11; Security/Data | ADR-0001, ADR-0003 | **Implemented** fail-closed domain gate in `src/authorization.rs` binds consent operations to participant-owned `ConsentLedger` / `ManageOwnConsent`; persistence/policy-adapter/public-transport integration remains Target | @@ -63,7 +63,7 @@ An active PR, architecture document, conversation decision, or scheduler plan is | Historical result does not mutate | TRD §9 | `src/result.rs` snapshot semantics | persistence and API supersession tests | | Narrative cannot mutate score / deterministic fallback exists | AI Governance; ADR-0018 | architecture policy | mapping implementation + canonical style-assignment key + fallback/no-score-mutation tests | | Instrument release bytes/version/item order are immutable | TRD §7 | `src/instrument.rs` + publication contract tests; `src/postgres_instrument_release.rs` persists immutable manifest columns; reload-after-restart is Active PR work | API publication integration | -| Only Published release accepts new sessions | TRD §7 | `PublicationState::accepts_new_sessions` in `src/instrument.rs`; `AssessmentSession` creation copies exact published release/version/locale provenance and fails closed on unpublished eligibility or locale mismatch; persisted-state reload is Active PR work | session-creation persistence/API integration test | +| Only Published release accepts new sessions | TRD §7 | `PublicationState::accepts_new_sessions` in `src/instrument.rs`; `AssessmentSession` creation copies exact published release/version/locale provenance and fails closed on unpublished eligibility or locale mismatch; persisted-state reload and startable-catalog listing are Active PR work | session-creation persistence/API integration test | | Publication event replay is idempotent/conflicting reuse fails closed | TRD §7 | `src/instrument.rs` | durable DB uniqueness/concurrency test | | Published instrument requires exact-version scientific evidence | Measurement Governance; ADR-0019 | `src/instrument.rs` binds approved evidence status, provenance/scope, mandatory evidence references, validity window, and immutable release identity before publication/reactivation | persistence/API publication integration and real instrument-specific evidence artifacts | | Optional account linking does not rewrite historical participant/result identity | ADR-0003, ADR-0020 | `src/participant.rs` issuer-scoped first-link primitive preserves stable participant ID | append-only identity-link persistence + unlink/relink/recovery audit tests | @@ -132,7 +132,7 @@ Still-Target logical modules/adapters include remaining product aggregate persis ### Active implementation work that is not protected-main truth -**Active PR** #179 instrument-release reload is not protected-main truth until an unchanged reviewed/check-clean head is integrated. Prefer this head over #171. `load_instrument_release` reconstructs any stored publication state by `release_ref` so a restarted worker can see whether that exact form may start new sessions. Exact persist replay stays Duplicate. Missing identity returns `None`. Duplicate stored item versions and other noncanonical rows fail closed as a stored-snapshot reconstruction error, not a published-only error. Publication event history and bound evidence are not stored by this adapter. This is complementary to #164 `load_published_instrument_release`, which is the session-start landing vehicle and returns only a Published locale-matched snapshot. Do not merge #179 in parallel with #164; rebase this slice after #164. HTTP publication/catalog transport remains Target and is tracked on #165. `src/postgres_instrument_release.rs` and `migrations/0006_instrument_release.sql` are the adapter/migration for this slice. +**Active PR** instrument-release startable-catalog listing is not protected-main truth until an unchanged reviewed/check-clean head is integrated. Prefer this head over #179 and #171 for catalog-from-store. After restart, `list_startable_instrument_releases` returns stored Published forms ordered by `instrument_ref`, `locale`, then `release_ref`. Draft, Review, Suspended, and Retired rows are omitted. A corrupt Published row fails closed as a stored-snapshot reconstruction error so the catalog cannot treat damaged evidence as startable. `load_instrument_release` still reconstructs any stored publication state by `release_ref`. Exact persist replay of a listed snapshot stays Duplicate. Publication event history and bound evidence are not stored by this adapter. This is complementary to #164 `load_published_instrument_release`, which is the session-start landing vehicle, and to #165, which is in-process HTTP catalog only. Do not merge this slice in parallel with #164; rebase after #164. Do not fold HTTP or session start into this adapter list. `src/postgres_instrument_release.rs` and `migrations/0006_instrument_release.sql` are the adapter/migration for this slice. Data-rights processing-start persistence from #76 is on this branch ancestry and is no longer Active PR work. Dependent-system execution remains Target. diff --git a/docs/architecture/AS_BUILT_SCHEMA.md b/docs/architecture/AS_BUILT_SCHEMA.md index d333dfd6..561fa4a4 100644 --- a/docs/architecture/AS_BUILT_SCHEMA.md +++ b/docs/architecture/AS_BUILT_SCHEMA.md @@ -56,7 +56,7 @@ The protected-main slice persists: - reachable publication-state advance without rewriting immutable manifest columns; - fail-closed digest/identity rebinding and unreachable lifecycle rewind. -Reload of the stored locale, digest, item set, and publication state after process restart is Active PR work and is not protected-main truth until an unchanged reviewed/check-clean head is integrated. After that lands, call `load_instrument_release` before starting a new session on a previously persisted form. +Reload of the stored locale, digest, item set, and publication state after process restart is Active PR work and is not protected-main truth until an unchanged reviewed/check-clean head is integrated. After that lands, call `list_startable_instrument_releases` to offer only currently Published forms, then call `load_instrument_release` with the chosen release reference before starting a new session. The slice does **not** persist publication-event history, bound scientific evidence records, HTTP publication transport, or session-creation integration. Those remain Target unless separately evidenced on protected main. diff --git a/docs/doctoring/standards-and-evidence.md b/docs/doctoring/standards-and-evidence.md index ba35de18..8f66b021 100644 --- a/docs/doctoring/standards-and-evidence.md +++ b/docs/doctoring/standards-and-evidence.md @@ -14,6 +14,7 @@ Product consequences: - an instrument release states intended score interpretations and prohibited/unsupported uses; - a published form remains reconstructable after process restart from its stored locale, digest, item set, and publication state so administration uses the same versioned instrument the participant was assigned; +- after restart, only currently Published stored forms are offered as startable catalog entries so withdrawn, draft, or damaged work cannot be administered as if it were the current form; - scoring and norms are versioned and reproducible; - precision/uncertainty is not hidden behind a point estimate; - translated forms and group comparisons require evidence appropriate to the intended comparison; diff --git a/src/postgres_instrument_release.rs b/src/postgres_instrument_release.rs index 1d50d679..e9d760ea 100644 --- a/src/postgres_instrument_release.rs +++ b/src/postgres_instrument_release.rs @@ -182,40 +182,89 @@ pub fn load_instrument_release( require_read_committed(transaction)?; let release_ref = required_reference(release_ref)?; let header = transaction.query_opt( - "SELECT instrument_ref, instrument_version_ref, construct_ref, item_version_refs, \ - locale, assessment_spec_ref, scoring_version_ref, calibration_reference, \ - norm_version_ref, narrative_version_ref, consent_requirement_refs, \ - intended_use_ref, limitations_ref, content_digest, publication_state, \ - created_at_unix_ms \ + "SELECT release_ref, instrument_ref, instrument_version_ref, construct_ref, \ + item_version_refs, locale, assessment_spec_ref, scoring_version_ref, \ + calibration_reference, norm_version_ref, narrative_version_ref, \ + consent_requirement_refs, intended_use_ref, limitations_ref, \ + content_digest, publication_state, created_at_unix_ms \ FROM instrument_release WHERE release_ref = $1", &[&release_ref], )?; let Some(header) = header else { return Ok(None); }; - let instrument_ref: String = header.get(0); - let instrument_version_ref: String = header.get(1); - let construct_ref: String = header.get(2); - let item_version_refs: Vec = header.get(3); - let locale: String = header.get(4); - let assessment_spec_ref: String = header.get(5); - let scoring_version_ref: String = header.get(6); - let calibration_reference: String = header.get(7); - let norm_version_ref: Option = header.get(8); - let narrative_version_ref: String = header.get(9); - let consent_requirement_refs: Vec = header.get(10); - let intended_use_ref: String = header.get(11); - let limitations_ref: String = header.get(12); - let content_digest: String = header.get(13); - let publication_state = publication_state_from_stored(&header.get::<_, String>(14))?; - let created_at_unix_ms = stored_timestamp(header.get(15))?; + reconstruct_stored_release(&header).map(Some) +} + +/// List stored Published instrument releases that may start new sessions. +/// +/// After process restart, call this before presenting a catalog or choosing a +/// `release_ref` for session start. Draft, Review, Suspended, and Retired rows +/// are omitted so unpublished work stays hidden. Each returned release +/// reconstructs the stored locale, digest, and item set, ordered by +/// `instrument_ref`, `locale`, then `release_ref`. A corrupt Published row +/// fails closed as [`InstrumentReleasePersistenceError::InconsistentEvidence`] +/// so the catalog cannot treat damaged evidence as startable. Copy a returned +/// `release_ref` and exact `locale` into session start. HTTP catalog transport +/// remains a separate slice. +/// +/// # Errors +/// +/// Returns [`InstrumentReleasePersistenceError`] for unsupported isolation, +/// inconsistent durable evidence, or a database failure. +pub fn list_startable_instrument_releases( + transaction: &mut Transaction<'_>, +) -> Result, InstrumentReleasePersistenceError> { + require_read_committed(transaction)?; + let rows = transaction.query( + "SELECT release_ref, instrument_ref, instrument_version_ref, construct_ref, \ + item_version_refs, locale, assessment_spec_ref, scoring_version_ref, \ + calibration_reference, norm_version_ref, narrative_version_ref, \ + consent_requirement_refs, intended_use_ref, limitations_ref, \ + content_digest, publication_state, created_at_unix_ms \ + FROM instrument_release \ + WHERE publication_state = $1 \ + ORDER BY instrument_ref, locale, release_ref", + &[&publication_state_name(PublicationState::Published)], + )?; + let mut releases = Vec::with_capacity(rows.len()); + for row in rows { + let release = reconstruct_stored_release(&row)?; + if !release.accepts_new_sessions() { + return Err(InstrumentReleasePersistenceError::InconsistentEvidence); + } + releases.push(release); + } + Ok(releases) +} + +fn reconstruct_stored_release( + header: &postgres::Row, +) -> Result { + let release_ref: String = header.get(0); + let instrument_ref: String = header.get(1); + let instrument_version_ref: String = header.get(2); + let construct_ref: String = header.get(3); + let item_version_refs: Vec = header.get(4); + let locale: String = header.get(5); + let assessment_spec_ref: String = header.get(6); + let scoring_version_ref: String = header.get(7); + let calibration_reference: String = header.get(8); + let norm_version_ref: Option = header.get(9); + let narrative_version_ref: String = header.get(10); + let consent_requirement_refs: Vec = header.get(11); + let intended_use_ref: String = header.get(12); + let limitations_ref: String = header.get(13); + let content_digest: String = header.get(14); + let publication_state = publication_state_from_stored(&header.get::<_, String>(15))?; + let created_at_unix_ms = stored_timestamp(header.get(16))?; let item_refs: Vec<&str> = item_version_refs.iter().map(String::as_str).collect(); let consent_refs: Vec<&str> = consent_requirement_refs .iter() .map(String::as_str) .collect(); let manifest = InstrumentReleaseManifest::new( - release_ref, + &release_ref, &instrument_ref, &instrument_version_ref, &construct_ref, @@ -233,7 +282,6 @@ pub fn load_instrument_release( ) .map_err(durable_evidence_error)?; InstrumentRelease::from_persisted_snapshot(manifest, publication_state, created_at_unix_ms) - .map(Some) .map_err(durable_evidence_error) } @@ -411,8 +459,8 @@ fn require_read_committed( mod reference_guard_tests { use super::{ durable_evidence_error, postgres_timestamp, publication_state_from_stored, - publication_state_may_replace, required_reference, stored_timestamp, - InstrumentReleasePersistenceError, + publication_state_may_replace, publication_state_name, required_reference, + stored_timestamp, InstrumentReleasePersistenceError, }; use crate::instrument::{InstrumentReleaseError, PublicationState}; @@ -533,5 +581,9 @@ mod reference_guard_tests { InstrumentReleasePersistenceError::InconsistentEvidence.to_string(), "durable instrument-release evidence cannot reconstruct the stored snapshot" ); + assert_eq!( + publication_state_name(PublicationState::Published), + "published" + ); } } diff --git a/tests/instrument_release_startable_catalog.rs b/tests/instrument_release_startable_catalog.rs new file mode 100644 index 00000000..146fa79f --- /dev/null +++ b/tests/instrument_release_startable_catalog.rs @@ -0,0 +1,61 @@ +//! Catalog next action after reconstructing stored instrument releases. +//! +//! After process restart, offer only Published reconstructions. Copy the +//! `release_ref` and exact `locale` into session start. Do not offer Draft, +//! Review, Suspended, or Retired forms, and do not invent a fallback locale. + +use psychometrics_commons_runtime::instrument::{ + InstrumentRelease, InstrumentReleaseManifest, PublicationState, +}; + +const VALID_DIGEST: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn published_manifest() -> InstrumentReleaseManifest { + InstrumentReleaseManifest::new( + "release_big_five_ko_v1", + "instrument_big_five", + "instrument_version_big_five_ko_v1", + "construct_big_five", + &["item_version_001", "item_version_002"], + "ko-KR", + "assessment_spec_big_five_v1", + "scoring_version_big_five_v1", + "calibration_big_five_ko_v1", + Some("norm_version_big_five_ko_v1"), + "narrative_version_big_five_v1", + &["consent_service_v1"], + "intended_use_self_reflection_v1", + "limitations_nonclinical_v1", + VALID_DIGEST, + ) + .unwrap() +} + +#[test] +fn only_published_reconstructions_are_catalog_startable() { + let published = InstrumentRelease::from_persisted_snapshot( + published_manifest(), + PublicationState::Published, + 40_000, + ) + .unwrap(); + assert!(published.accepts_new_sessions()); + assert_eq!(published.manifest().release_ref(), "release_big_five_ko_v1"); + assert_eq!(published.manifest().locale(), "ko-KR"); + + for state in [ + PublicationState::Draft, + PublicationState::Review, + PublicationState::Suspended, + PublicationState::Retired, + ] { + let release = + InstrumentRelease::from_persisted_snapshot(published_manifest(), state, 40_000) + .unwrap(); + assert!( + !release.accepts_new_sessions(), + "{state:?} must stay hidden from the startable catalog" + ); + } +} diff --git a/tests/postgres_instrument_release_persistence.rs b/tests/postgres_instrument_release_persistence.rs index 5a37dd4f..4510c968 100644 --- a/tests/postgres_instrument_release_persistence.rs +++ b/tests/postgres_instrument_release_persistence.rs @@ -7,8 +7,9 @@ use psychometrics_commons_runtime::instrument::{ PublicationState, }; use psychometrics_commons_runtime::postgres_instrument_release::{ - apply_instrument_release_migration, load_instrument_release, persist_instrument_release, - InstrumentReleasePersistenceDisposition, InstrumentReleasePersistenceError, + apply_instrument_release_migration, list_startable_instrument_releases, + load_instrument_release, persist_instrument_release, InstrumentReleasePersistenceDisposition, + InstrumentReleasePersistenceError, }; use std::sync::{Mutex, MutexGuard}; @@ -79,6 +80,13 @@ fn manifest_with_norm( } fn approved_publication_evidence_for(release_ref: &str) -> PublicationEvidenceRecord { + approved_publication_evidence_for_locale(release_ref, "ko-KR") +} + +fn approved_publication_evidence_for_locale( + release_ref: &str, + locale: &str, +) -> PublicationEvidenceRecord { PublicationEvidenceRecord::new( "publication_evidence_big_five_ko_v1", "evidence_policy_self_reflection_v1", @@ -86,7 +94,7 @@ fn approved_publication_evidence_for(release_ref: &str) -> PublicationEvidenceRe "instrument_version_big_five_ko_v1", &["item_version_001", "item_version_002"], RELEASE_DIGEST, - "ko-KR", + locale, "intended_use_self_reflection_v1", "assessment_spec_big_five_v1", "scoring_version_big_five_v1", @@ -115,8 +123,36 @@ fn published_release() -> InstrumentRelease { } fn published_release_named(release_ref: &str) -> InstrumentRelease { - let mut release = - InstrumentRelease::new(manifest(release_ref, RELEASE_DIGEST), 40_000).unwrap(); + published_release_for(release_ref, "instrument_big_five", "ko-KR") +} + +fn published_release_for( + release_ref: &str, + instrument_ref: &str, + locale: &str, +) -> InstrumentRelease { + let mut release = InstrumentRelease::new( + InstrumentReleaseManifest::new( + release_ref, + instrument_ref, + "instrument_version_big_five_ko_v1", + "construct_big_five", + &["item_version_001", "item_version_002"], + locale, + "assessment_spec_big_five_v1", + "scoring_version_big_five_v1", + "calibration_big_five_ko_v1", + Some("norm_version_big_five_ko_v1"), + "narrative_version_big_five_v1", + &["consent_service_v1"], + "intended_use_self_reflection_v1", + "limitations_nonclinical_v1", + RELEASE_DIGEST, + ) + .unwrap(), + 40_000, + ) + .unwrap(); release .apply_command( "submit_review_event", @@ -125,7 +161,10 @@ fn published_release_named(release_ref: &str) -> InstrumentRelease { ) .unwrap(); release - .bind_publication_evidence(approved_publication_evidence_for(release_ref)) + .bind_publication_evidence(approved_publication_evidence_for_locale( + release_ref, + locale, + )) .unwrap(); release .apply_command("publish_event", PublicationCommand::Publish, 40_200) @@ -135,6 +174,35 @@ fn published_release_named(release_ref: &str) -> InstrumentRelease { release } +fn review_release_named(release_ref: &str) -> InstrumentRelease { + let mut release = + InstrumentRelease::new(manifest(release_ref, RELEASE_DIGEST), 40_000).unwrap(); + release + .apply_command( + "submit_review_event", + PublicationCommand::SubmitReview, + 40_100, + ) + .unwrap(); + release +} + +fn suspended_release_named(release_ref: &str) -> InstrumentRelease { + let mut release = published_release_named(release_ref); + release + .apply_command("suspend_event", PublicationCommand::Suspend, 40_300) + .unwrap(); + release +} + +fn retired_release_named(release_ref: &str) -> InstrumentRelease { + let mut release = published_release_named(release_ref); + release + .apply_command("retire_event", PublicationCommand::Retire, 40_300) + .unwrap(); + release +} + fn persist_ok( client: &mut Client, release: &InstrumentRelease, @@ -602,3 +670,130 @@ fn duplicate_stored_item_versions_fail_closed_instead_of_starting_sessions() { )); transaction.rollback().unwrap(); } + +#[test] +fn startable_catalog_lists_only_published_forms_in_stable_order() { + let _guard = instrument_release_test_guard(); + let mut client = test_client(); + reset_instrument_release_tables(&mut client); + apply_instrument_release_migration(&mut client).unwrap(); + + persist_ok( + &mut client, + &InstrumentRelease::new(manifest("release_draft_ko_v1", RELEASE_DIGEST), 40_000).unwrap(), + ); + persist_ok(&mut client, &review_release_named("release_review_ko_v1")); + persist_ok( + &mut client, + &published_release_for("release_big_five_ko_v1", "instrument_big_five", "ko-KR"), + ); + persist_ok( + &mut client, + &published_release_for("release_big_five_en_v1", "instrument_big_five", "en-US"), + ); + persist_ok( + &mut client, + &published_release_for("release_alpha_en_v1", "instrument_alpha", "en-US"), + ); + persist_ok( + &mut client, + &suspended_release_named("release_suspended_ko_v1"), + ); + persist_ok(&mut client, &retired_release_named("release_retired_ko_v1")); + + let mut transaction = client.transaction().unwrap(); + let listed = list_startable_instrument_releases(&mut transaction).unwrap(); + let identities: Vec<(&str, &str, &str)> = listed + .iter() + .map(|release| { + ( + release.manifest().instrument_ref(), + release.manifest().locale(), + release.manifest().release_ref(), + ) + }) + .collect(); + assert_eq!( + identities, + [ + ("instrument_alpha", "en-US", "release_alpha_en_v1"), + ("instrument_big_five", "en-US", "release_big_five_en_v1"), + ("instrument_big_five", "ko-KR", "release_big_five_ko_v1"), + ] + ); + for release in &listed { + assert!(release.accepts_new_sessions()); + assert_eq!( + persist_instrument_release(&mut transaction, release).unwrap(), + InstrumentReleasePersistenceDisposition::Duplicate + ); + } + transaction.commit().unwrap(); +} + +#[test] +fn startable_catalog_is_empty_when_no_published_form_is_stored() { + let _guard = instrument_release_test_guard(); + let mut client = test_client(); + reset_instrument_release_tables(&mut client); + apply_instrument_release_migration(&mut client).unwrap(); + + persist_ok( + &mut client, + &InstrumentRelease::new(manifest("release_draft_ko_v1", RELEASE_DIGEST), 40_000).unwrap(), + ); + + let mut transaction = client.transaction().unwrap(); + assert!(list_startable_instrument_releases(&mut transaction) + .unwrap() + .is_empty()); + transaction.commit().unwrap(); +} + +#[test] +fn startable_catalog_fails_closed_on_corrupt_published_row() { + let _guard = instrument_release_test_guard(); + let mut client = test_client(); + reset_instrument_release_tables(&mut client); + apply_instrument_release_migration(&mut client).unwrap(); + + persist_ok(&mut client, &published_release()); + persist_ok( + &mut client, + &published_release_for("release_alpha_en_v1", "instrument_alpha", "en-US"), + ); + client + .execute( + "UPDATE instrument_release SET item_version_refs = ARRAY[\ + 'item_version_001', 'item_version_001'\ + ] WHERE release_ref = 'release_big_five_ko_v1'", + &[], + ) + .unwrap(); + + let mut transaction = client.transaction().unwrap(); + assert!(matches!( + list_startable_instrument_releases(&mut transaction), + Err(InstrumentReleasePersistenceError::InconsistentEvidence) + )); + transaction.rollback().unwrap(); +} + +#[test] +fn startable_catalog_requires_read_committed_isolation() { + let _guard = instrument_release_test_guard(); + let mut client = test_client(); + reset_instrument_release_tables(&mut client); + apply_instrument_release_migration(&mut client).unwrap(); + + let mut transaction = client + .build_transaction() + .isolation_level(IsolationLevel::Serializable) + .start() + .unwrap(); + assert!(matches!( + list_startable_instrument_releases(&mut transaction), + Err(InstrumentReleasePersistenceError::UnsupportedIsolationLevel) + )); + transaction.rollback().unwrap(); +} From 8b32d53ae0475b891051b80b81065b74843a7836 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:23:39 +0000 Subject: [PATCH 7/8] docs(traceability): name startable-catalog landing as #193 Co-authored-by: Seongho Bae --- docs/TRACEABILITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 25ede0a3..b4f9ccb4 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -132,7 +132,7 @@ Still-Target logical modules/adapters include remaining product aggregate persis ### Active implementation work that is not protected-main truth -**Active PR** instrument-release startable-catalog listing is not protected-main truth until an unchanged reviewed/check-clean head is integrated. Prefer this head over #179 and #171 for catalog-from-store. After restart, `list_startable_instrument_releases` returns stored Published forms ordered by `instrument_ref`, `locale`, then `release_ref`. Draft, Review, Suspended, and Retired rows are omitted. A corrupt Published row fails closed as a stored-snapshot reconstruction error so the catalog cannot treat damaged evidence as startable. `load_instrument_release` still reconstructs any stored publication state by `release_ref`. Exact persist replay of a listed snapshot stays Duplicate. Publication event history and bound evidence are not stored by this adapter. This is complementary to #164 `load_published_instrument_release`, which is the session-start landing vehicle, and to #165, which is in-process HTTP catalog only. Do not merge this slice in parallel with #164; rebase after #164. Do not fold HTTP or session start into this adapter list. `src/postgres_instrument_release.rs` and `migrations/0006_instrument_release.sql` are the adapter/migration for this slice. +**Active PR** #193 instrument-release startable-catalog listing is not protected-main truth until an unchanged reviewed/check-clean head is integrated. Prefer this head over #179 and #171 for catalog-from-store. After restart, `list_startable_instrument_releases` returns stored Published forms ordered by `instrument_ref`, `locale`, then `release_ref`. Draft, Review, Suspended, and Retired rows are omitted. A corrupt Published row fails closed as a stored-snapshot reconstruction error so the catalog cannot treat damaged evidence as startable. `load_instrument_release` still reconstructs any stored publication state by `release_ref`. Exact persist replay of a listed snapshot stays Duplicate. Publication event history and bound evidence are not stored by this adapter. This is complementary to #164 `load_published_instrument_release`, which is the session-start landing vehicle, and to #165, which is in-process HTTP catalog only. Do not merge this slice in parallel with #164; rebase after #164. Do not fold HTTP or session start into this adapter list. `src/postgres_instrument_release.rs` and `migrations/0006_instrument_release.sql` are the adapter/migration for this slice. Data-rights processing-start persistence from #76 is on this branch ancestry and is no longer Active PR work. Dependent-system execution remains Target. From 4ee1c01e0918030befcf3b13c37b7b0079cf6587 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:24:10 +0000 Subject: [PATCH 8/8] docs(instrument): name session-start landing as #180 Co-authored-by: Seongho Bae --- CHANGELOG.md | 2 +- docs/TRACEABILITY.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28d35df3..fbeca848 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable product and architecture changes are recorded here. Releases use imm ## Unreleased ### Added -- After restart, `list_startable_instrument_releases` returns stored Published forms ordered by instrument, locale, then release reference so a worker can offer only currently startable catalogs. Draft, Review, Suspended, and Retired rows are omitted. A corrupt Published row fails closed as a stored-snapshot reconstruction error instead of appearing startable. `load_instrument_release` still reconstructs any stored publication state by release reference. Exact persist replay of a listed snapshot stays Duplicate. Publication event history and bound evidence are not in this adapter. Session start from one stored Published locale-matched release remains #164. HTTP catalog transport remains #165. +- After restart, `list_startable_instrument_releases` returns stored Published forms ordered by instrument, locale, then release reference so a worker can offer only currently startable catalogs. Draft, Review, Suspended, and Retired rows are omitted. A corrupt Published row fails closed as a stored-snapshot reconstruction error instead of appearing startable. `load_instrument_release` still reconstructs any stored publication state by release reference. Exact persist replay of a listed snapshot stays Duplicate. Publication event history and bound evidence are not in this adapter. Session start from one stored Published locale-matched release remains #180. HTTP catalog transport remains #165. - Scoring-job cancel and lease-expiry fallback classification lock the current row until the caller transaction ends, so concurrent workers cannot rewrite terminal or unleased evidence. - PostgreSQL operational-store readiness probe classifies the supported major version and write-readiness, and fails closed when a caller-declared required relation is missing. - PostgreSQL scoring-job cancellation: queued, leased, or retry-scheduled work becomes cancelled without transferring a fence, exact replay is idempotent, and completed or quarantined evidence cannot be rewritten. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index b4f9ccb4..1d01e48c 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -132,7 +132,7 @@ Still-Target logical modules/adapters include remaining product aggregate persis ### Active implementation work that is not protected-main truth -**Active PR** #193 instrument-release startable-catalog listing is not protected-main truth until an unchanged reviewed/check-clean head is integrated. Prefer this head over #179 and #171 for catalog-from-store. After restart, `list_startable_instrument_releases` returns stored Published forms ordered by `instrument_ref`, `locale`, then `release_ref`. Draft, Review, Suspended, and Retired rows are omitted. A corrupt Published row fails closed as a stored-snapshot reconstruction error so the catalog cannot treat damaged evidence as startable. `load_instrument_release` still reconstructs any stored publication state by `release_ref`. Exact persist replay of a listed snapshot stays Duplicate. Publication event history and bound evidence are not stored by this adapter. This is complementary to #164 `load_published_instrument_release`, which is the session-start landing vehicle, and to #165, which is in-process HTTP catalog only. Do not merge this slice in parallel with #164; rebase after #164. Do not fold HTTP or session start into this adapter list. `src/postgres_instrument_release.rs` and `migrations/0006_instrument_release.sql` are the adapter/migration for this slice. +**Active PR** #193 instrument-release startable-catalog listing is not protected-main truth until an unchanged reviewed/check-clean head is integrated. Prefer this head over #179 and #171 for catalog-from-store. After restart, `list_startable_instrument_releases` returns stored Published forms ordered by `instrument_ref`, `locale`, then `release_ref`. Draft, Review, Suspended, and Retired rows are omitted. A corrupt Published row fails closed as a stored-snapshot reconstruction error so the catalog cannot treat damaged evidence as startable. `load_instrument_release` still reconstructs any stored publication state by `release_ref`. Exact persist replay of a listed snapshot stays Duplicate. Publication event history and bound evidence are not stored by this adapter. This is complementary to #180 `load_published_instrument_release`, which is the session-start landing vehicle, and to #165, which is in-process HTTP catalog only. Do not merge this slice in parallel with #180; rebase after #180. Do not fold HTTP or session start into this adapter list. Do not merge #164 or #179 in parallel. `src/postgres_instrument_release.rs` and `migrations/0006_instrument_release.sql` are the adapter/migration for this slice. Data-rights processing-start persistence from #76 is on this branch ancestry and is no longer Active PR work. Dependent-system execution remains Target.