diff --git a/CHANGELOG.md b/CHANGELOG.md index b2f801dd..fbeca848 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, `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/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 72bc73c2..1d01e48c 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 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 | @@ -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 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,9 @@ Still-Target logical modules/adapters include remaining product aggregate persis ### Active implementation work that is not protected-main truth -**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. +**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. ## 5. ADR traceability by concern diff --git a/docs/architecture/AS_BUILT_SCHEMA.md b/docs/architecture/AS_BUILT_SCHEMA.md index 8c2c3510..561fa4a4 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 `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. ## Logical-to-physical mapping rule diff --git a/docs/doctoring/standards-and-evidence.md b/docs/doctoring/standards-and-evidence.md index 7879474c..8f66b021 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,8 @@ 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; +- 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/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..e9d760ea 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 stored instrument-release snapshot. + 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 stored snapshot" + } Self::Database(_) => "PostgreSQL instrument-release persistence failed", }) } @@ -152,6 +159,132 @@ 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 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); + }; + 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, + &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_err(durable_evidence_error) +} + fn classify_existing_release( transaction: &mut Transaction<'_>, manifest: &InstrumentReleaseManifest, @@ -262,6 +395,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 +458,11 @@ fn require_read_committed( #[cfg(test)] mod reference_guard_tests { use super::{ - postgres_timestamp, publication_state_may_replace, required_reference, - InstrumentReleasePersistenceError, + durable_evidence_error, postgres_timestamp, publication_state_from_stored, + publication_state_may_replace, publication_state_name, required_reference, + stored_timestamp, InstrumentReleasePersistenceError, }; + use crate::instrument::{InstrumentReleaseError, PublicationState}; #[test] fn blank_numeric_and_overflow_inputs_fail_closed() { @@ -347,4 +522,68 @@ 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 + )); + } + assert_eq!( + 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_persisted.rs b/tests/instrument_release_persisted.rs new file mode 100644 index 00000000..56ef59c9 --- /dev/null +++ b/tests/instrument_release_persisted.rs @@ -0,0 +1,150 @@ +//! 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); +} + +#[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/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_error_contract.rs b/tests/postgres_instrument_release_error_contract.rs index 5cbe4cad..1d337d49 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 stored 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..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, 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, @@ -474,3 +542,258 @@ 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(); +} + +#[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(); +}