From 33636b4b4e4e5c76c1999c9ffef61231d947c208 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:17:31 +0000 Subject: [PATCH 01/33] feat(response): persist accepted events so restart keeps the ledger A mid-session crash currently drops answers that exist only in memory. Store each accepted response_event, reload the same Korean IPIP Quick prefix after restart, and fail closed on conflicting replay. Co-authored-by: Seongho Bae --- CHANGELOG.md | 1 + docs/TRACEABILITY.md | 4 +- ...-persistence-and-transaction-boundaries.md | 4 +- docs/architecture/AS_BUILT_SCHEMA.md | 4 + docs/architecture/ERD.md | 2 + docs/architecture/UML.md | 1 + docs/doctoring/standards-and-evidence.md | 2 + migrations/0020_response_event.sql | 49 ++ src/lib.rs | 1 + src/postgres_response_event.rs | 325 +++++++++++++ src/response.rs | 103 ++++ tests/postgres_recovery_invariants.rs | 27 ++ .../postgres_response_event_error_contract.rs | 53 +++ tests/postgres_response_event_persistence.rs | 443 ++++++++++++++++++ tests/response_event_persisted.rs | 235 ++++++++++ tests/response_ledger.rs | 4 + 16 files changed, 1256 insertions(+), 2 deletions(-) create mode 100644 migrations/0020_response_event.sql create mode 100644 src/postgres_response_event.rs create mode 100644 tests/postgres_response_event_error_contract.rs create mode 100644 tests/postgres_response_event_persistence.rs create mode 100644 tests/response_event_persisted.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b2f801dd..fa0ecb10 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 +- PostgreSQL 18 persistence for accepted `response_event` rows so a mid-session restart can rebuild the same response ledger and freeze the same scoring prefix. Exact replay is idempotent; conflicting client-event, sequence, or evidence rebinding fails closed under `READ COMMITTED`. - 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..eb64888a 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -23,7 +23,7 @@ An active PR, architecture document, conversation decision, or scheduler plan is | Anonymous core assessment | PRD §3.1, §9.1 | TRD §5, §10; UML anonymous sequence | ADR-0002, ADR-0003, ADR-0005 | Session lifecycle primitives implemented, including creation bound to one published locale-specific release; anonymous credential/HTTP flow is Target | | Pause/resume | PRD §3.1, §9.1 | TRD §5 | ADR-0005 | **Implemented** in `src/session.rs` with fail-closed transitions | | Sequence-aware item delivery evidence | PRD §3.1, §9 | TRD §5–7 | ADR-0005, ADR-0010 | **Implemented** domain primitive in `src/item_delivery.rs`; persistence/API delivery orchestration is Target | -| Idempotent response events | PRD §9.2 | TRD §6 | ADR-0005, ADR-0010 | **Implemented** in `src/response.rs` with canonical SHA-256 payload-digest identity; persistence adapter is Target | +| Idempotent response events | PRD §9.2 | TRD §6 | ADR-0005, ADR-0010 | **Implemented** in `src/response.rs` with canonical SHA-256 payload-digest identity; mid-session `response_event` persist/reload is Active PR work in `src/postgres_response_event.rs` / `migrations/0020_response_event.sql` and is not protected-main truth until integrated | | Immutable response snapshot before scoring | PRD §9.3 | TRD §5–8 | ADR-0005, ADR-0010 | **Implemented** domain semantics in `src/response.rs` | | Version-pinned scoring | PRD §9.4, §10 | TRD §8 | ADR-0004, ADR-0010 | **Implemented** reusable product-side scoring dispatch contract in `src/scoring.rs` with canonical SHA-256 engine-artifact digest provenance plus `migrations/0011_scoring_request.sql` / `src/postgres_scoring_request.rs` request-identity persistence; live fast-mlsirm integration is Target | | Bounded asynchronous scoring retry/quarantine with stale-worker fencing | PRD §9.4, §10 | TRD §8; ADR-0015 transaction boundary | ADR-0004, ADR-0010, ADR-0015 | **Implemented** product lifecycle plus PostgreSQL enqueue, claim, retry, completion, expiry recovery, and cancellation without transferring a fence; live fast-mlsirm execution remains Target | @@ -134,6 +134,8 @@ Still-Target logical modules/adapters include remaining product aggregate persis **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** response-event persist/reload stores each accepted answer under `response_event` so a mid-session restart can rebuild the same Korean IPIP Quick prefix before snapshot freeze. Exact replay is idempotent; client-identity, sequence, and evidence rebinding fail closed under `READ COMMITTED`. HTTP transport and live scoring remain outside this slice. This is not protected-main truth until an unchanged reviewed/check-clean head is integrated. + ## 5. ADR traceability by concern | Concern | Governing ADR(s) | diff --git a/docs/adr/0015-persistence-and-transaction-boundaries.md b/docs/adr/0015-persistence-and-transaction-boundaries.md index c6643000..a4875d65 100644 --- a/docs/adr/0015-persistence-and-transaction-boundaries.md +++ b/docs/adr/0015-persistence-and-transaction-boundaries.md @@ -6,7 +6,7 @@ - Scope: Psychometrics Commons-owned durable state, local transactions, migration boundaries, outbox/inbox integration - Supersedes: none - Superseded by: none -- Current/as-built status: protected main contains in-memory/domain lifecycle primitives only; active PR #24 carries the first PostgreSQL integration-evidence migration/adapter but is not protected-main truth until merged +- Current/as-built status: protected main contains domain lifecycle primitives plus bounded PostgreSQL slices including completed `response_snapshot` persist; mid-session `response_event` persist/reload is Active PR work in `migrations/0020_response_event.sql` and is not protected-main truth until merged - Target status: upstream PostgreSQL 18.x operational persistence with real-database concurrency/crash/recovery evidence and transactional outbox/inbox semantics - Migration status: active PR #24 introduces only the bounded integration-evidence slice; the remaining product schema still must be established from the logical ERD and this ADR without synthetic provenance backfills @@ -305,4 +305,6 @@ The physical database technology or decomposition may change if scale, residency PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation*. +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Transaction isolation*. https://www.postgresql.org/docs/18/transaction-iso.html + PostgreSQL Global Development Group. (2026). *PostgreSQL versioning policy*. diff --git a/docs/architecture/AS_BUILT_SCHEMA.md b/docs/architecture/AS_BUILT_SCHEMA.md index 8c2c3510..9ac304c1 100644 --- a/docs/architecture/AS_BUILT_SCHEMA.md +++ b/docs/architecture/AS_BUILT_SCHEMA.md @@ -58,6 +58,10 @@ The protected-main slice persists: 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. +## Active PR response-event physical schema + +This open PR adds `migrations/0020_response_event.sql` and `src/postgres_response_event.rs` so each accepted answer is durable before snapshot freeze. The slice is **Active PR**, not protected-main truth. It stores opaque `response_event_ref` identity, session binding, client idempotency identity, item version, canonical SHA-256 payload digest, and positive `server_sequence`. Exact replay is idempotent. Client-identity, sequence, and evidence rebinding fail closed. Reload reconstructs `ResponseLedger` in `server_sequence` order under `READ COMMITTED`. HTTP response transport remains outside this slice. + ## Logical-to-physical mapping rule A logical entity is classified as physical only when all of the following exist on the named protected-main baseline: diff --git a/docs/architecture/ERD.md b/docs/architecture/ERD.md index 8f21954a..6e710169 100644 --- a/docs/architecture/ERD.md +++ b/docs/architecture/ERD.md @@ -425,6 +425,7 @@ The target ERD deliberately includes several logical entities that are not yet p - `instrument_release` is the locale-specific publication identity already owned by `src/instrument.rs`. Physical `migrations/0006_instrument_release.sql` persists that one-row aggregate (immutable manifest columns plus `publication_state`); HTTP publication transport remains Target. - `data_rights_request` and `data_rights_propagation_state` are the first durable export/deletion slice. Physical `migrations/0003_data_rights_propagation.sql` stores requested-state identity plus one local outbox event per dependent system; verification, processing, completion, and dependent-system execution remain Target. - `item_delivery_event` reflects the already-merged `src/item_delivery.rs` domain primitive; durable persistence/API orchestration is still Target. +- `response_event` is the accepted mid-session ledger. Physical `migrations/0020_response_event.sql` and `src/postgres_response_event.rs` persist/reload that prefix on this Active PR; HTTP response transport remains Target. - `consent_ledger` and `consent_event` persist the already-merged `src/consent.rs` append-only ledger. Physical persistence is carried by Active PR #49 (`migrations/0005_consent_lifecycle.sql`); HTTP consent transport and derived snapshot tables remain Target. - `participant_identity_link` is the persistence target accepted by ADR-0020. The current `src/participant.rs` `keyverse_subject_ref` field is an application-domain first-link projection, not the future mutable persistence source of truth. - `longitudinal_enrollment`, `longitudinal_observation_record`, and `temporal_analysis_submission` make the ADR-0008 Commons-owned Gyeot/TEPP orchestration boundary explicit. No TEPP analytical kernel is duplicated here. @@ -453,6 +454,7 @@ Once semantically published/frozen, the following are append-only or superseded - `instrument_release` manifest columns after first persist (only `publication_state` may advance); - `item_version` after publication; - `item_delivery_event`; +- accepted `response_event` rows; - `response_snapshot` and `response_snapshot_entry`; - `result_snapshot`; - `consent_snapshot`; diff --git a/docs/architecture/UML.md b/docs/architecture/UML.md index 13988939..0e877391 100644 --- a/docs/architecture/UML.md +++ b/docs/architecture/UML.md @@ -336,6 +336,7 @@ sequenceDiagram participant F as fast-mlsirm scoring path P->>A: complete assessment + Note over DB: Active PR persist/reload keeps accepted response_event rows so a mid-session restart can rebuild the same prefix before this freeze A->>DB: commit Completed + immutable response snapshot + outbox A-->>P: completion durable; scoring pending diff --git a/docs/doctoring/standards-and-evidence.md b/docs/doctoring/standards-and-evidence.md index 7879474c..4de2ba2c 100644 --- a/docs/doctoring/standards-and-evidence.md +++ b/docs/doctoring/standards-and-evidence.md @@ -124,3 +124,5 @@ Temoshok, D., Proud-Madruga, D., Choong, Y.-Y., Galluzzo, R., Gupta, S., LaSalle World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines (WCAG) 2.2* (W3C Recommendation, 12 December 2024). https://www.w3.org/TR/WCAG22/ World Wide Web Consortium. (2013). *PROV-DM: The PROV data model* (W3C Recommendation, 30 April 2013). https://www.w3.org/TR/prov-dm/ + +The PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Transaction isolation*. https://www.postgresql.org/docs/18/transaction-iso.html diff --git a/migrations/0020_response_event.sql b/migrations/0020_response_event.sql new file mode 100644 index 00000000..08daab4f --- /dev/null +++ b/migrations/0020_response_event.sql @@ -0,0 +1,49 @@ +CREATE TABLE IF NOT EXISTS response_event ( + response_event_ref TEXT CONSTRAINT response_event_response_event_ref_not_null NOT NULL + CONSTRAINT response_event_response_event_ref_format_check CHECK ( + response_event_ref = btrim(response_event_ref) + AND response_event_ref <> '' + AND NOT ( + response_event_ref ~ '[[:digit:]]' + AND response_event_ref ~ '^[[:digit:]+,.eE-]+$' + ) + ), + session_ref TEXT CONSTRAINT response_event_session_ref_not_null NOT NULL + CONSTRAINT response_event_session_ref_format_check CHECK ( + session_ref = btrim(session_ref) + AND session_ref <> '' + AND NOT ( + session_ref ~ '[[:digit:]]' + AND session_ref ~ '^[[:digit:]+,.eE-]+$' + ) + ), + client_event_ref TEXT CONSTRAINT response_event_client_event_ref_not_null NOT NULL + CONSTRAINT response_event_client_event_ref_format_check CHECK ( + client_event_ref = btrim(client_event_ref) + AND client_event_ref <> '' + AND NOT ( + client_event_ref ~ '[[:digit:]]' + AND client_event_ref ~ '^[[:digit:]+,.eE-]+$' + ) + ), + item_version_ref TEXT CONSTRAINT response_event_item_version_ref_not_null NOT NULL + CONSTRAINT response_event_item_version_ref_format_check CHECK ( + item_version_ref = btrim(item_version_ref) + AND item_version_ref <> '' + AND NOT ( + item_version_ref ~ '[[:digit:]]' + AND item_version_ref ~ '^[[:digit:]+,.eE-]+$' + ) + ), + payload_digest TEXT CONSTRAINT response_event_payload_digest_not_null NOT NULL + CONSTRAINT response_event_payload_digest_format_check CHECK ( + payload_digest ~ '^sha256:[0-9a-f]{64}$' + ), + server_sequence BIGINT CONSTRAINT response_event_server_sequence_not_null NOT NULL + CONSTRAINT response_event_server_sequence_positive_check CHECK (server_sequence > 0), + received_at TIMESTAMPTZ CONSTRAINT response_event_received_at_not_null NOT NULL + DEFAULT clock_timestamp(), + CONSTRAINT response_event_pkey PRIMARY KEY (response_event_ref), + CONSTRAINT response_event_session_client_unique UNIQUE (session_ref, client_event_ref), + CONSTRAINT response_event_session_sequence_unique UNIQUE (session_ref, server_sequence) +); diff --git a/src/lib.rs b/src/lib.rs index 8b586a68..d9fe1d10 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,6 +27,7 @@ pub mod postgres_inbox_consumption; pub mod postgres_instrument_release; pub mod postgres_integration; pub mod postgres_item_delivery; +pub mod postgres_response_event; pub mod postgres_response_snapshot; pub mod postgres_result_snapshot; pub mod postgres_scoring_job; diff --git a/src/postgres_response_event.rs b/src/postgres_response_event.rs new file mode 100644 index 00000000..0f44cc71 --- /dev/null +++ b/src/postgres_response_event.rs @@ -0,0 +1,325 @@ +//! `PostgreSQL` 18 persistence for accepted response events. +//! +//! This adapter stores the mid-session ledger so a process restart can rebuild +//! the same accepted prefix before snapshot freeze. It does not store response +//! bodies and does not compute scores. The caller owns the connection, +//! credentials, and transaction boundary. Replay requires `READ COMMITTED` so a +//! concurrent insert that wins a unique-key race is visible to the exact-replay +//! classifier. + +use crate::reference::normalized_reference; +use crate::response::{ResponseEvent, ResponseLedger, WriteError}; +use postgres::Transaction; +use std::error::Error; +use std::fmt::{Display, Formatter}; + +const RESPONSE_EVENT_MIGRATION: &str = include_str!("../migrations/0020_response_event.sql"); + +/// Outcome of persisting one accepted response event. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ResponseEventPersistenceDisposition { + /// A new accepted event row was inserted. + Inserted, + /// The same immutable event identity and evidence already existed. + Duplicate, +} + +/// Fail-closed error for durable response-event persistence. +#[derive(Debug)] +#[non_exhaustive] +pub enum ResponseEventPersistenceError { + /// A session or event identity was blank, numeric-like, or unbound. + InvalidReference, + /// Event identity was replayed with different immutable evidence. + ConflictingReplay, + /// A server sequence was reused by another event identity in the session. + SequenceConflict, + /// A sequence cannot be represented by the bounded database column. + InvalidSequence, + /// Response-event persistence requires `PostgreSQL` `READ COMMITTED` isolation. + UnsupportedIsolationLevel, + /// `PostgreSQL` rejected or could not execute the persistence operation. + Database(postgres::Error), +} + +impl Display for ResponseEventPersistenceError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::InvalidReference => { + "response event persistence references must be opaque durable values" + } + Self::ConflictingReplay => { + "response event identity was replayed with conflicting evidence" + } + Self::SequenceConflict => { + "response event sequence was reused by a different event identity" + } + Self::InvalidSequence => { + "response event sequence is missing, gapped, or outside the PostgreSQL bigint range" + } + Self::UnsupportedIsolationLevel => { + "response event persistence requires read committed isolation" + } + Self::Database(_) => "PostgreSQL response-event persistence failed", + }) + } +} + +impl Error for ResponseEventPersistenceError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Database(error) => Some(error), + _ => None, + } + } +} + +impl From for ResponseEventPersistenceError { + fn from(error: postgres::Error) -> Self { + Self::Database(error) + } +} + +/// Apply the idempotent response-event migration to a `PostgreSQL` connection. +/// +/// # Errors +/// +/// Returns the `PostgreSQL` error if the migration cannot be applied. +pub fn apply_response_event_migration( + client: &mut impl postgres::GenericClient, +) -> Result<(), postgres::Error> { + client.batch_execute(RESPONSE_EVENT_MIGRATION) +} + +/// Persist one accepted response event for a session. +/// +/// Exact replay of the same event identity, session binding, item version, +/// payload digest, and server sequence is idempotent. Rebinding any of those +/// values fails closed. Historical accepted answers are never updated. +/// +/// # Errors +/// +/// Returns [`ResponseEventPersistenceError`] for an unbound identity, +/// unsupported isolation, conflicting replay, a sequence conflict, an invalid +/// sequence, or a database failure. +pub fn persist_response_event( + transaction: &mut Transaction<'_>, + session_ref: &str, + event: &ResponseEvent, +) -> Result { + require_read_committed(transaction)?; + let session_ref = required_reference(session_ref)?; + let server_event_ref = required_reference(event.server_event_ref())?; + let client_event_ref = required_reference(event.client_event_ref())?; + let item_version_ref = required_reference(event.item_version_ref())?; + let server_sequence = postgres_sequence(event.sequence())?; + + let inserted = match transaction.execute( + "INSERT INTO response_event (\ + response_event_ref, session_ref, client_event_ref, item_version_ref, \ + payload_digest, server_sequence\ + ) VALUES ($1, $2, $3, $4, $5, $6) \ + ON CONFLICT (response_event_ref) DO NOTHING", + &[ + &server_event_ref, + &session_ref, + &client_event_ref, + &item_version_ref, + &event.payload_digest(), + &server_sequence, + ], + ) { + Ok(count) => count, + Err(error) => return Err(classify_unique_violation(error)), + }; + if inserted == 1 { + return Ok(ResponseEventPersistenceDisposition::Inserted); + } + classify_existing_event( + transaction, + session_ref, + event, + server_event_ref, + server_sequence, + ) +} + +/// Rebuild the accepted response ledger for one session after restart. +/// +/// Rows are read in `server_sequence` order. A missing session returns an empty +/// ledger. Gapped, reordered, or conflicting stored identities fail closed. +/// +/// # Errors +/// +/// Returns [`ResponseEventPersistenceError`] for an unbound session, unsupported +/// isolation, corrupt stored history, or a database failure. +pub fn load_response_ledger( + transaction: &mut Transaction<'_>, + session_ref: &str, +) -> Result { + require_read_committed(transaction)?; + let session_ref = required_reference(session_ref)?; + let rows = transaction.query( + "SELECT response_event_ref, client_event_ref, item_version_ref, \ + payload_digest, server_sequence \ + FROM response_event \ + WHERE session_ref = $1 \ + ORDER BY server_sequence", + &[&session_ref], + )?; + let mut events = Vec::with_capacity(rows.len()); + for row in rows { + let server_event_ref: String = row.get(0); + let client_event_ref: String = row.get(1); + let item_version_ref: String = row.get(2); + let payload_digest: String = row.get(3); + let server_sequence: i64 = row.get(4); + let sequence = usize::try_from(server_sequence) + .map_err(|_| ResponseEventPersistenceError::InvalidSequence)?; + let event = ResponseEvent::from_persisted( + server_event_ref, + client_event_ref, + item_version_ref, + payload_digest, + sequence, + ) + .map_err(map_rebuild_error)?; + events.push(event); + } + ResponseLedger::from_persisted(session_ref, events).map_err(map_rebuild_error) +} + +fn classify_existing_event( + transaction: &mut Transaction<'_>, + session_ref: &str, + event: &ResponseEvent, + server_event_ref: &str, + server_sequence: i64, +) -> Result { + let row = transaction.query_one( + "SELECT session_ref, client_event_ref, item_version_ref, payload_digest, server_sequence \ + FROM response_event WHERE response_event_ref = $1", + &[&server_event_ref], + )?; + let stored_session: String = row.get(0); + let stored_client: String = row.get(1); + let stored_item: String = row.get(2); + let stored_digest: String = row.get(3); + let stored_sequence: i64 = row.get(4); + if stored_session == session_ref + && stored_client == event.client_event_ref() + && stored_item == event.item_version_ref() + && stored_digest == event.payload_digest() + && stored_sequence == server_sequence + { + Ok(ResponseEventPersistenceDisposition::Duplicate) + } else { + Err(ResponseEventPersistenceError::ConflictingReplay) + } +} + +fn classify_unique_violation(error: postgres::Error) -> ResponseEventPersistenceError { + match error + .as_db_error() + .and_then(postgres::error::DbError::constraint) + { + Some("response_event_session_client_unique") => { + ResponseEventPersistenceError::ConflictingReplay + } + Some("response_event_session_sequence_unique") => { + ResponseEventPersistenceError::SequenceConflict + } + _ => ResponseEventPersistenceError::Database(error), + } +} + +fn map_rebuild_error(error: WriteError) -> ResponseEventPersistenceError { + match error { + WriteError::InvalidReference => ResponseEventPersistenceError::InvalidReference, + WriteError::InvalidSequence => ResponseEventPersistenceError::InvalidSequence, + WriteError::EmptyReference + | WriteError::InvalidPayloadDigest + | WriteError::IdempotencyConflict + | WriteError::ServerReferenceConflict + | WriteError::SessionNotActive(_) + | WriteError::SnapshotRequiresCompleted(_) => { + ResponseEventPersistenceError::ConflictingReplay + } + } +} + +fn required_reference(reference: &str) -> Result<&str, ResponseEventPersistenceError> { + normalized_reference(reference).ok_or(ResponseEventPersistenceError::InvalidReference) +} + +fn postgres_sequence(value: usize) -> Result { + i64::try_from(value).map_err(|_| ResponseEventPersistenceError::InvalidSequence) +} + +fn require_read_committed( + transaction: &mut Transaction<'_>, +) -> Result<(), ResponseEventPersistenceError> { + let row = transaction.query_one("SHOW transaction_isolation", &[])?; + let isolation: String = row.get(0); + if isolation == "read committed" { + Ok(()) + } else { + Err(ResponseEventPersistenceError::UnsupportedIsolationLevel) + } +} + +#[cfg(test)] +mod reference_guard_tests { + use super::{ + map_rebuild_error, postgres_sequence, required_reference, ResponseEventPersistenceError, + }; + use crate::response::WriteError; + use crate::session::SessionState; + + #[test] + fn blank_numeric_and_overflow_sequences_fail_closed() { + assert!(matches!( + required_reference(" "), + Err(ResponseEventPersistenceError::InvalidReference) + )); + assert!(matches!( + required_reference("12"), + Err(ResponseEventPersistenceError::InvalidReference) + )); + assert_eq!( + required_reference("session_ipip_ko_quick").unwrap(), + "session_ipip_ko_quick" + ); + assert_eq!(postgres_sequence(1).unwrap(), 1); + assert!(matches!( + postgres_sequence(usize::MAX), + Err(ResponseEventPersistenceError::InvalidSequence) + )); + } + + #[test] + fn rebuild_errors_map_to_typed_persistence_failures() { + assert!(matches!( + map_rebuild_error(WriteError::InvalidReference), + ResponseEventPersistenceError::InvalidReference + )); + assert!(matches!( + map_rebuild_error(WriteError::InvalidSequence), + ResponseEventPersistenceError::InvalidSequence + )); + for error in [ + WriteError::EmptyReference, + WriteError::InvalidPayloadDigest, + WriteError::IdempotencyConflict, + WriteError::ServerReferenceConflict, + WriteError::SessionNotActive(SessionState::Paused), + WriteError::SnapshotRequiresCompleted(SessionState::Active), + ] { + assert!(matches!( + map_rebuild_error(error), + ResponseEventPersistenceError::ConflictingReplay + )); + } + } +} diff --git a/src/response.rs b/src/response.rs index b5ff00cf..d4749201 100644 --- a/src/response.rs +++ b/src/response.rs @@ -65,6 +65,50 @@ impl ResponseEvent { pub const fn sequence(&self) -> usize { self.sequence } + + /// Rebuild one accepted event from durable store columns. + /// + /// Use this after a process restart. The values must already be the exact + /// accepted identity, item version, payload digest, and server sequence. + /// This constructor does not invent an answer or a score. + /// + /// # Errors + /// + /// Returns [`WriteError::InvalidReference`] for a blank or numeric-like + /// identity, [`WriteError::EmptyReference`] for a blank digest, + /// [`WriteError::InvalidPayloadDigest`] for a noncanonical digest, or + /// [`WriteError::InvalidSequence`] when the server sequence is zero. + pub fn from_persisted( + server_event_ref: impl AsRef, + client_event_ref: impl AsRef, + item_version_ref: impl AsRef, + payload_digest: impl AsRef, + sequence: usize, + ) -> Result { + let server_event_ref = + normalized_reference(server_event_ref.as_ref()).ok_or(WriteError::InvalidReference)?; + let client_event_ref = + normalized_reference(client_event_ref.as_ref()).ok_or(WriteError::InvalidReference)?; + let item_version_ref = + normalized_reference(item_version_ref.as_ref()).ok_or(WriteError::InvalidReference)?; + let payload_digest = payload_digest.as_ref(); + if payload_digest.trim().is_empty() { + return Err(WriteError::EmptyReference); + } + if !is_canonical_sha256(payload_digest) { + return Err(WriteError::InvalidPayloadDigest); + } + if sequence == 0 { + return Err(WriteError::InvalidSequence); + } + Ok(Self { + server_event_ref: server_event_ref.to_owned(), + client_event_ref: client_event_ref.to_owned(), + item_version_ref: item_version_ref.to_owned(), + payload_digest: payload_digest.to_owned(), + sequence, + }) + } } /// Immutable response snapshot frozen when collection completes. @@ -140,6 +184,8 @@ pub enum WriteError { ServerReferenceConflict, /// A response snapshot was requested before the session reached completion. SnapshotRequiresCompleted(SessionState), + /// Persisted events were missing, gapped, zero, or out of server-sequence order. + InvalidSequence, } impl Display for WriteError { @@ -164,6 +210,9 @@ impl Display for WriteError { formatter, "response snapshot requires Completed session state, found {state:?}" ), + Self::InvalidSequence => formatter.write_str( + "persisted response events must use contiguous positive server sequences", + ), } } } @@ -212,6 +261,60 @@ impl ResponseLedger { self.events.is_empty() } + /// Return the opaque assessment-session reference. + #[must_use] + pub fn session_ref(&self) -> &str { + &self.session_ref + } + + /// Return accepted events in server-authoritative sequence order. + #[must_use] + pub fn events(&self) -> &[ResponseEvent] { + &self.events + } + + /// Rebuild the accepted prefix from durable store rows after restart. + /// + /// Events must already be in server-sequence order `1..=n` with unique + /// server and client identities. The reconstructed ledger can freeze the + /// same scoring prefix as the process that accepted the answers. + /// + /// # Errors + /// + /// Returns [`WriteError::InvalidReference`] for a blank or numeric-like + /// session identity, [`WriteError::InvalidSequence`] when sequences are + /// gapped or reordered, [`WriteError::ServerReferenceConflict`] when a + /// server event identity repeats, or [`WriteError::IdempotencyConflict`] + /// when a client event identity repeats. + pub fn from_persisted( + session_ref: impl AsRef, + events: Vec, + ) -> Result { + let session_ref = + normalized_reference(session_ref.as_ref()).ok_or(WriteError::InvalidReference)?; + for (index, event) in events.iter().enumerate() { + if event.sequence != index + 1 { + return Err(WriteError::InvalidSequence); + } + if events[..index] + .iter() + .any(|prior| prior.server_event_ref == event.server_event_ref) + { + return Err(WriteError::ServerReferenceConflict); + } + if events[..index] + .iter() + .any(|prior| prior.client_event_ref == event.client_event_ref) + { + return Err(WriteError::IdempotencyConflict); + } + } + Ok(Self { + session_ref: session_ref.to_owned(), + events, + }) + } + /// Record one response event or replay an identical prior event. /// /// Exact replay of an already accepted `client_event_ref` remains idempotent diff --git a/tests/postgres_recovery_invariants.rs b/tests/postgres_recovery_invariants.rs index e8af1d61..64608eee 100644 --- a/tests/postgres_recovery_invariants.rs +++ b/tests/postgres_recovery_invariants.rs @@ -98,6 +98,14 @@ fn seed_recovery_critical_state(client: &mut Client) { ) VALUES ( 'snapshot_recovery_alpha', 1, 'response_recovery_alpha', 'item_version_recovery_alpha', '{DIGEST_A}' + ); + INSERT INTO {SOURCE_SCHEMA}.response_event ( + response_event_ref, session_ref, client_event_ref, item_version_ref, + payload_digest, server_sequence + ) VALUES ( + 'response_event_recovery_alpha', 'session_recovery_alpha', + 'client_event_recovery_alpha', 'item_version_recovery_alpha', + '{DIGEST_A}', 1 );" )) .expect("recovery fixture should satisfy all protected-main persistence constraints"); @@ -183,6 +191,24 @@ fn assert_restored_evidence(client: &mut Client) { "response_recovery_alpha" ); assert_eq!(restored_snapshot.get::<_, String>(3), DIGEST_A); + + let restored_event = client + .query_one( + &format!( + "SELECT session_ref, client_event_ref, payload_digest, server_sequence + FROM {RESTORED_SCHEMA}.response_event + WHERE response_event_ref = 'response_event_recovery_alpha'" + ), + &[], + ) + .expect("accepted mid-session response events should survive restore"); + assert_eq!(restored_event.get::<_, String>(0), "session_recovery_alpha"); + assert_eq!( + restored_event.get::<_, String>(1), + "client_event_recovery_alpha" + ); + assert_eq!(restored_event.get::<_, String>(2), DIGEST_A); + assert_eq!(restored_event.get::<_, i64>(3), 1); } fn assert_restored_tenant_scoped_deduplication(client: &mut Client) { @@ -246,6 +272,7 @@ fn clean_restore_preserves_provenance_deduplication_and_fencing_state() { "integration_consumption", "response_snapshot", "response_snapshot_entry", + "response_event", ]; let backups: Vec<(&str, Vec)> = tables .iter() diff --git a/tests/postgres_response_event_error_contract.rs b/tests/postgres_response_event_error_contract.rs new file mode 100644 index 00000000..b256344d --- /dev/null +++ b/tests/postgres_response_event_error_contract.rs @@ -0,0 +1,53 @@ +//! Stable operator-facing error contracts for response-event persistence. + +use postgres::{Client, NoTls}; +use psychometrics_commons_runtime::postgres_response_event::ResponseEventPersistenceError; + +fn test_client() -> Client { + let connection = std::env::var("TEST_DATABASE_URL") + .expect("TEST_DATABASE_URL must identify the isolated CI PostgreSQL database"); + Client::connect(&connection, NoTls).expect("isolated CI PostgreSQL database must be reachable") +} + +#[test] +fn persistence_errors_expose_stable_messages_and_database_sources() { + for (error, expected_message) in [ + ( + ResponseEventPersistenceError::InvalidReference, + "response event persistence references must be opaque durable values", + ), + ( + ResponseEventPersistenceError::ConflictingReplay, + "response event identity was replayed with conflicting evidence", + ), + ( + ResponseEventPersistenceError::SequenceConflict, + "response event sequence was reused by a different event identity", + ), + ( + ResponseEventPersistenceError::InvalidSequence, + "response event sequence is missing, gapped, or outside the PostgreSQL bigint range", + ), + ( + ResponseEventPersistenceError::UnsupportedIsolationLevel, + "response event persistence requires read committed isolation", + ), + ] { + assert_eq!(error.to_string(), expected_message); + assert!(std::error::Error::source(&error).is_none()); + } + + let mut client = test_client(); + let database_error = client + .query_one( + "SELECT * FROM response_event_error_contract_missing_relation", + &[], + ) + .unwrap_err(); + let error = ResponseEventPersistenceError::from(database_error); + assert_eq!( + error.to_string(), + "PostgreSQL response-event persistence failed" + ); + assert!(std::error::Error::source(&error).is_some()); +} diff --git a/tests/postgres_response_event_persistence.rs b/tests/postgres_response_event_persistence.rs new file mode 100644 index 00000000..303ad4e6 --- /dev/null +++ b/tests/postgres_response_event_persistence.rs @@ -0,0 +1,443 @@ +//! Real `PostgreSQL` contract for durable mid-session response events. + +use postgres::{Client, IsolationLevel, NoTls}; +use psychometrics_commons_runtime::postgres_response_event::{ + apply_response_event_migration, load_response_ledger, persist_response_event, + ResponseEventPersistenceDisposition, ResponseEventPersistenceError, +}; +use psychometrics_commons_runtime::response::{ResponseEvent, ResponseLedger, ResponseWrite}; +use psychometrics_commons_runtime::session::SessionState; +use std::sync::{Mutex, MutexGuard}; + +const DIGEST_N1: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const DIGEST_N2: &str = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +static RESPONSE_EVENT_TEST_LOCK: Mutex<()> = Mutex::new(()); + +fn response_event_test_guard() -> MutexGuard<'static, ()> { + RESPONSE_EVENT_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn test_client() -> Client { + let connection = std::env::var("TEST_DATABASE_URL") + .expect("TEST_DATABASE_URL must identify the isolated CI PostgreSQL database"); + let mut client = Client::connect(&connection, NoTls) + .expect("isolated CI PostgreSQL database must be reachable"); + client + .batch_execute( + "CREATE SCHEMA IF NOT EXISTS response_event_persistence_test;\ + SET search_path TO response_event_persistence_test;", + ) + .unwrap(); + client +} + +fn reset_response_event_table(client: &mut Client) { + client + .batch_execute("DROP TABLE IF EXISTS response_event_persistence_test.response_event;") + .unwrap(); +} + +fn write<'a>( + server_event_ref: &'a str, + client_event_ref: &'a str, + item_version_ref: &'a str, + payload_digest: &'a str, +) -> ResponseWrite<'a> { + ResponseWrite { + server_event_ref, + client_event_ref, + item_version_ref, + payload_digest, + } +} + +fn recorded_event( + session_ref: &str, + request: ResponseWrite<'_>, +) -> (ResponseLedger, ResponseEvent) { + let mut ledger = ResponseLedger::new(session_ref).unwrap(); + let event = ledger.record(SessionState::Active, request).unwrap(); + (ledger, event) +} + +fn persist_ok( + client: &mut Client, + session_ref: &str, + event: &ResponseEvent, +) -> ResponseEventPersistenceDisposition { + let mut transaction = client.transaction().unwrap(); + let disposition = persist_response_event(&mut transaction, session_ref, event).unwrap(); + transaction.commit().unwrap(); + disposition +} + +fn persist_err( + client: &mut Client, + session_ref: &str, + event: &ResponseEvent, +) -> ResponseEventPersistenceError { + let mut transaction = client.transaction().unwrap(); + let error = persist_response_event(&mut transaction, session_ref, event).unwrap_err(); + transaction.rollback().unwrap(); + error +} + +fn load_ok(client: &mut Client, session_ref: &str) -> ResponseLedger { + let mut transaction = client.transaction().unwrap(); + let ledger = load_response_ledger(&mut transaction, session_ref).unwrap(); + transaction.commit().unwrap(); + ledger +} + +fn load_err(client: &mut Client, session_ref: &str) -> ResponseEventPersistenceError { + let mut transaction = client.transaction().unwrap(); + let error = load_response_ledger(&mut transaction, session_ref).unwrap_err(); + transaction.rollback().unwrap(); + error +} + +fn rebound_event( + client_event_ref: &str, + item_version_ref: &str, + payload_digest: &str, + sequence: usize, +) -> ResponseEvent { + ResponseEvent::from_persisted( + "server_event_item_01", + client_event_ref, + item_version_ref, + payload_digest, + sequence, + ) + .unwrap() +} + +#[test] +fn two_item_korean_path_survives_restart_and_exact_replay() { + let _guard = response_event_test_guard(); + let mut client = test_client(); + reset_response_event_table(&mut client); + apply_response_event_migration(&mut client).unwrap(); + + let mut live = ResponseLedger::new("session_ipip_ko_quick").unwrap(); + let first = live + .record( + SessionState::Active, + write( + "server_event_item_01", + "client_event_item_01", + "item_version_n1_ko", + DIGEST_N1, + ), + ) + .unwrap(); + assert_eq!( + persist_ok(&mut client, "session_ipip_ko_quick", &first), + ResponseEventPersistenceDisposition::Inserted + ); + assert_eq!( + persist_ok(&mut client, "session_ipip_ko_quick", &first), + ResponseEventPersistenceDisposition::Duplicate + ); + + let after_first = load_ok(&mut client, "session_ipip_ko_quick"); + assert_eq!(after_first.events(), std::slice::from_ref(&first)); + + let second = live + .record( + SessionState::Active, + write( + "server_event_item_02", + "client_event_item_02", + "item_version_n2_ko", + DIGEST_N2, + ), + ) + .unwrap(); + assert_eq!( + persist_ok(&mut client, "session_ipip_ko_quick", &second), + ResponseEventPersistenceDisposition::Inserted + ); + + let rebuilt = load_ok(&mut client, "session_ipip_ko_quick"); + assert_eq!(rebuilt, live); + let snapshot = rebuilt + .freeze_as(SessionState::Completed, "response_snapshot_ipip_ko_quick") + .unwrap(); + assert_eq!(snapshot.event_count(), 2); + assert_eq!(snapshot.last_sequence(), Some(2)); +} + +#[test] +fn empty_session_reload_is_an_empty_ledger() { + let _guard = response_event_test_guard(); + let mut client = test_client(); + reset_response_event_table(&mut client); + apply_response_event_migration(&mut client).unwrap(); + + let rebuilt = load_ok(&mut client, "session_ipip_ko_empty"); + assert!(rebuilt.is_empty()); + assert_eq!(rebuilt.session_ref(), "session_ipip_ko_empty"); +} + +#[test] +fn event_identity_rebinding_and_sequence_reuse_fail_closed() { + let _guard = response_event_test_guard(); + let mut client = test_client(); + reset_response_event_table(&mut client); + apply_response_event_migration(&mut client).unwrap(); + + let (_, first) = recorded_event( + "session_ipip_ko_conflict", + write( + "server_event_item_01", + "client_event_item_01", + "item_version_n1_ko", + DIGEST_N1, + ), + ); + persist_ok(&mut client, "session_ipip_ko_conflict", &first); + + assert!(matches!( + persist_err(&mut client, "session_ipip_ko_other", &first), + ResponseEventPersistenceError::ConflictingReplay + )); + for rebound in [ + rebound_event("client_event_item_99", "item_version_n1_ko", DIGEST_N1, 1), + rebound_event("client_event_item_01", "item_version_n9_ko", DIGEST_N1, 1), + rebound_event("client_event_item_01", "item_version_n1_ko", DIGEST_N2, 1), + rebound_event("client_event_item_01", "item_version_n1_ko", DIGEST_N1, 2), + ] { + assert!(matches!( + persist_err(&mut client, "session_ipip_ko_conflict", &rebound), + ResponseEventPersistenceError::ConflictingReplay + )); + } + + let (_, other_server) = recorded_event( + "session_ipip_ko_conflict", + write( + "server_event_item_99", + "client_event_item_01", + "item_version_n1_ko", + DIGEST_N1, + ), + ); + assert!(matches!( + persist_err(&mut client, "session_ipip_ko_conflict", &other_server), + ResponseEventPersistenceError::ConflictingReplay + )); +} + +#[test] +fn reused_server_sequence_by_another_event_fails_closed() { + let _guard = response_event_test_guard(); + let mut client = test_client(); + reset_response_event_table(&mut client); + apply_response_event_migration(&mut client).unwrap(); + + let (_, first) = recorded_event( + "session_ipip_ko_sequence", + write( + "server_event_item_01", + "client_event_item_01", + "item_version_n1_ko", + DIGEST_N1, + ), + ); + persist_ok(&mut client, "session_ipip_ko_sequence", &first); + client + .execute( + "UPDATE response_event SET server_sequence = 2 \ + WHERE response_event_ref = 'server_event_item_01'", + &[], + ) + .unwrap(); + let (_, other_sequence) = recorded_event( + "session_ipip_ko_sequence", + write( + "server_event_item_02", + "client_event_item_02", + "item_version_n2_ko", + DIGEST_N2, + ), + ); + assert!(matches!( + persist_err(&mut client, "session_ipip_ko_sequence", &other_sequence), + ResponseEventPersistenceError::SequenceConflict + )); +} + +#[test] +fn persist_and_load_require_read_committed_and_opaque_session() { + let _guard = response_event_test_guard(); + let mut client = test_client(); + reset_response_event_table(&mut client); + apply_response_event_migration(&mut client).unwrap(); + + let (_, event) = recorded_event( + "session_ipip_ko_isolation", + write( + "server_event_item_01", + "client_event_item_01", + "item_version_n1_ko", + DIGEST_N1, + ), + ); + let mut serializable = client + .build_transaction() + .isolation_level(IsolationLevel::Serializable) + .start() + .unwrap(); + assert!(matches!( + persist_response_event(&mut serializable, "session_ipip_ko_isolation", &event), + Err(ResponseEventPersistenceError::UnsupportedIsolationLevel) + )); + assert!(matches!( + load_response_ledger(&mut serializable, "session_ipip_ko_isolation"), + Err(ResponseEventPersistenceError::UnsupportedIsolationLevel) + )); + serializable.rollback().unwrap(); + + assert!(matches!( + persist_err(&mut client, " ", &event), + ResponseEventPersistenceError::InvalidReference + )); + assert!(matches!( + load_err(&mut client, "12"), + ResponseEventPersistenceError::InvalidReference + )); +} + +#[test] +fn missing_relation_and_gapped_history_fail_closed() { + let _guard = response_event_test_guard(); + let mut client = test_client(); + reset_response_event_table(&mut client); + + let (_, event) = recorded_event( + "session_ipip_ko_missing", + write( + "server_event_item_01", + "client_event_item_01", + "item_version_n1_ko", + DIGEST_N1, + ), + ); + assert!(matches!( + persist_err(&mut client, "session_ipip_ko_missing", &event), + ResponseEventPersistenceError::Database(_) + )); + + apply_response_event_migration(&mut client).unwrap(); + persist_ok(&mut client, "session_ipip_ko_gap", &event); + client + .execute( + "INSERT INTO response_event (\ + response_event_ref, session_ref, client_event_ref, item_version_ref, \ + payload_digest, server_sequence\ + ) VALUES (\ + 'server_event_item_03', 'session_ipip_ko_gap', 'client_event_item_03', \ + 'item_version_n3_ko', $1, 3\ + )", + &[&DIGEST_N2], + ) + .unwrap(); + assert!(matches!( + load_err(&mut client, "session_ipip_ko_gap"), + ResponseEventPersistenceError::InvalidSequence + )); +} + +#[test] +fn stored_noncanonical_digest_fails_closed_on_reload() { + let _guard = response_event_test_guard(); + let mut client = test_client(); + reset_response_event_table(&mut client); + apply_response_event_migration(&mut client).unwrap(); + + client + .batch_execute( + "ALTER TABLE response_event DROP CONSTRAINT response_event_payload_digest_format_check;", + ) + .unwrap(); + client + .execute( + "INSERT INTO response_event (\ + response_event_ref, session_ref, client_event_ref, item_version_ref, \ + payload_digest, server_sequence\ + ) VALUES (\ + 'server_event_item_01', 'session_ipip_ko_digest', 'client_event_item_01', \ + 'item_version_n1_ko', 'not-a-digest', 1\ + )", + &[], + ) + .unwrap(); + assert!(matches!( + load_err(&mut client, "session_ipip_ko_digest"), + ResponseEventPersistenceError::ConflictingReplay + )); +} + +#[test] +fn unexpected_unique_constraint_and_negative_sequence_fail_closed() { + let _guard = response_event_test_guard(); + let mut client = test_client(); + reset_response_event_table(&mut client); + apply_response_event_migration(&mut client).unwrap(); + + let (_, first) = recorded_event( + "session_ipip_ko_extra", + write( + "server_event_item_01", + "client_event_item_01", + "item_version_n1_ko", + DIGEST_N1, + ), + ); + persist_ok(&mut client, "session_ipip_ko_extra", &first); + client + .batch_execute( + "CREATE UNIQUE INDEX response_event_session_only_unique ON response_event (session_ref);", + ) + .unwrap(); + let (_, second) = recorded_event( + "session_ipip_ko_extra", + write( + "server_event_item_02", + "client_event_item_02", + "item_version_n2_ko", + DIGEST_N2, + ), + ); + assert!(matches!( + persist_err(&mut client, "session_ipip_ko_extra", &second), + ResponseEventPersistenceError::Database(_) + )); + + client + .batch_execute( + "DROP INDEX response_event_session_only_unique;\ + ALTER TABLE response_event DROP CONSTRAINT response_event_server_sequence_positive_check;", + ) + .unwrap(); + client + .execute( + "INSERT INTO response_event (\ + response_event_ref, session_ref, client_event_ref, item_version_ref, \ + payload_digest, server_sequence\ + ) VALUES (\ + 'server_event_item_neg', 'session_ipip_ko_negative', 'client_event_item_neg', \ + 'item_version_n_neg', $1, -1\ + )", + &[&DIGEST_N1], + ) + .unwrap(); + assert!(matches!( + load_err(&mut client, "session_ipip_ko_negative"), + ResponseEventPersistenceError::InvalidSequence + )); +} diff --git a/tests/response_event_persisted.rs b/tests/response_event_persisted.rs new file mode 100644 index 00000000..42eac469 --- /dev/null +++ b/tests/response_event_persisted.rs @@ -0,0 +1,235 @@ +//! Reconstruct a mid-session response ledger after process restart. +//! +//! A buyer on the Korean IPIP Quick path can answer item 1, lose the process, +//! and still freeze the same scoring prefix after item 2. Persistence adapters +//! must rebuild that ledger without inventing answers or scores. + +use psychometrics_commons_runtime::response::{ResponseEvent, ResponseLedger, ResponseWrite}; +use psychometrics_commons_runtime::scoring::{ScoringRequest, ScoringRequestInput}; +use psychometrics_commons_runtime::session::SessionState; + +const DIGEST_N1: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const DIGEST_N2: &str = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +fn write<'a>( + server_event_ref: &'a str, + client_event_ref: &'a str, + item_version_ref: &'a str, + payload_digest: &'a str, +) -> ResponseWrite<'a> { + ResponseWrite { + server_event_ref, + client_event_ref, + item_version_ref, + payload_digest, + } +} + +fn scoring_input<'a>() -> ScoringRequestInput<'a> { + ScoringRequestInput { + scoring_request_ref: "scoring_request_ipip_ko_quick", + response_snapshot_ref: "response_snapshot_ipip_ko_quick", + assessment_spec_ref: "assessment_spec_ipip_bf_ko_quick", + instrument_version_ref: "instrument_version_ipip_bf_ko_quick", + scoring_version_ref: "scoring_version_ipip_mlsirm_v1", + calibration_reference: "calibration_ipip_bf_ko_quick", + norm_version_ref: Some("norm_ipip_bf_ko_quick"), + requested_output_schema_version: 1, + } +} + +#[test] +fn reconstructed_two_item_korean_path_pins_the_same_scoring_request() { + let mut live = ResponseLedger::new("session_ipip_ko_quick").unwrap(); + live.record( + SessionState::Active, + write( + "server_event_item_01", + "client_event_item_01", + "item_version_n1_ko", + DIGEST_N1, + ), + ) + .unwrap(); + live.record( + SessionState::Active, + write( + "server_event_item_02", + "client_event_item_02", + "item_version_n2_ko", + DIGEST_N2, + ), + ) + .unwrap(); + let expected_snapshot = live + .freeze_as(SessionState::Completed, "response_snapshot_ipip_ko_quick") + .unwrap(); + let expected_request = + ScoringRequest::from_snapshot(&expected_snapshot, scoring_input()).unwrap(); + + let rebuilt = ResponseLedger::from_persisted( + "session_ipip_ko_quick", + vec![ + ResponseEvent::from_persisted( + "server_event_item_01", + "client_event_item_01", + "item_version_n1_ko", + DIGEST_N1, + 1, + ) + .unwrap(), + ResponseEvent::from_persisted( + "server_event_item_02", + "client_event_item_02", + "item_version_n2_ko", + DIGEST_N2, + 2, + ) + .unwrap(), + ], + ) + .unwrap(); + let rebuilt_snapshot = rebuilt + .freeze_as(SessionState::Completed, "response_snapshot_ipip_ko_quick") + .unwrap(); + let rebuilt_request = + ScoringRequest::from_snapshot(&rebuilt_snapshot, scoring_input()).unwrap(); + + assert_eq!(rebuilt.session_ref(), "session_ipip_ko_quick"); + assert_eq!(rebuilt.events(), live.events()); + assert_eq!(rebuilt_snapshot, expected_snapshot); + assert_eq!(rebuilt_request, expected_request); + assert_eq!( + rebuilt_request.response_snapshot_ref(), + "response_snapshot_ipip_ko_quick" + ); +} + +#[test] +fn persisted_event_reconstruction_fails_closed_on_identity_and_sequence() { + assert!(matches!( + ResponseEvent::from_persisted( + " ", + "client_event_item_01", + "item_version_n1_ko", + DIGEST_N1, + 1 + ), + Err(psychometrics_commons_runtime::response::WriteError::InvalidReference) + )); + assert!(matches!( + ResponseEvent::from_persisted( + "server_event_item_01", + "12", + "item_version_n1_ko", + DIGEST_N1, + 1 + ), + Err(psychometrics_commons_runtime::response::WriteError::InvalidReference) + )); + assert!(matches!( + ResponseEvent::from_persisted( + "server_event_item_01", + "client_event_item_01", + " ", + DIGEST_N1, + 1 + ), + Err(psychometrics_commons_runtime::response::WriteError::InvalidReference) + )); + assert!(matches!( + ResponseEvent::from_persisted( + "server_event_item_01", + "client_event_item_01", + "item_version_n1_ko", + " ", + 1 + ), + Err(psychometrics_commons_runtime::response::WriteError::EmptyReference) + )); + assert!(matches!( + ResponseEvent::from_persisted( + "server_event_item_01", + "client_event_item_01", + "item_version_n1_ko", + "sha256:not-a-digest", + 1 + ), + Err(psychometrics_commons_runtime::response::WriteError::InvalidPayloadDigest) + )); + assert!(matches!( + ResponseEvent::from_persisted( + "server_event_item_01", + "client_event_item_01", + "item_version_n1_ko", + DIGEST_N1, + 0 + ), + Err(psychometrics_commons_runtime::response::WriteError::InvalidSequence) + )); +} + +#[test] +fn persisted_ledger_reconstruction_fails_closed_on_gaps_and_duplicate_identities() { + let first = ResponseEvent::from_persisted( + "server_event_item_01", + "client_event_item_01", + "item_version_n1_ko", + DIGEST_N1, + 1, + ) + .unwrap(); + let gapped = ResponseEvent::from_persisted( + "server_event_item_03", + "client_event_item_03", + "item_version_n3_ko", + DIGEST_N2, + 3, + ) + .unwrap(); + assert!(matches!( + ResponseLedger::from_persisted(" ", vec![]), + Err(psychometrics_commons_runtime::response::WriteError::InvalidReference) + )); + assert!(matches!( + ResponseLedger::from_persisted("session_ipip_ko_quick", vec![gapped.clone()]), + Err(psychometrics_commons_runtime::response::WriteError::InvalidSequence) + )); + assert!(matches!( + ResponseLedger::from_persisted("session_ipip_ko_quick", vec![first.clone(), gapped]), + Err(psychometrics_commons_runtime::response::WriteError::InvalidSequence) + )); + + let duplicate_server = ResponseEvent::from_persisted( + "server_event_item_01", + "client_event_item_02", + "item_version_n2_ko", + DIGEST_N2, + 2, + ) + .unwrap(); + assert!(matches!( + ResponseLedger::from_persisted( + "session_ipip_ko_quick", + vec![first.clone(), duplicate_server] + ), + Err(psychometrics_commons_runtime::response::WriteError::ServerReferenceConflict) + )); + + let duplicate_client = ResponseEvent::from_persisted( + "server_event_item_02", + "client_event_item_01", + "item_version_n2_ko", + DIGEST_N2, + 2, + ) + .unwrap(); + assert!(matches!( + ResponseLedger::from_persisted("session_ipip_ko_quick", vec![first, duplicate_client]), + Err(psychometrics_commons_runtime::response::WriteError::IdempotencyConflict) + )); + + let empty = ResponseLedger::from_persisted("session_ipip_ko_quick", vec![]).unwrap(); + assert_eq!(empty.session_ref(), "session_ipip_ko_quick"); + assert!(empty.events().is_empty()); +} diff --git a/tests/response_ledger.rs b/tests/response_ledger.rs index 1aec771c..4da486dc 100644 --- a/tests/response_ledger.rs +++ b/tests/response_ledger.rs @@ -335,4 +335,8 @@ fn write_errors_have_stable_human_readable_context() { WriteError::SnapshotRequiresCompleted(SessionState::Active).to_string(), "response snapshot requires Completed session state, found Active" ); + assert_eq!( + WriteError::InvalidSequence.to_string(), + "persisted response events must use contiguous positive server sequences" + ); } From a901fc643acd53c3d573a9f9ff342ef22f3e06b2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:20:39 +0000 Subject: [PATCH 02/33] fix(response): keep observed and received event times distinct Match the ERD and #174 temporal contract so source-valid time cannot replace platform receipt time, and fail closed on inverted or rebound timestamps. Co-authored-by: Seongho Bae --- CHANGELOG.md | 2 +- docs/TRACEABILITY.md | 4 +- docs/architecture/AS_BUILT_SCHEMA.md | 2 +- docs/architecture/ERD.md | 2 +- migrations/0020_response_event.sql | 7 +- src/postgres_response_event.rs | 59 +++++++-- tests/postgres_recovery_invariants.rs | 6 +- .../postgres_response_event_error_contract.rs | 4 + tests/postgres_response_event_persistence.rs | 114 ++++++++++++++++-- 9 files changed, 174 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa0ecb10..04796351 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 -- PostgreSQL 18 persistence for accepted `response_event` rows so a mid-session restart can rebuild the same response ledger and freeze the same scoring prefix. Exact replay is idempotent; conflicting client-event, sequence, or evidence rebinding fails closed under `READ COMMITTED`. +- PostgreSQL 18 persistence for accepted `response_event` rows so a mid-session restart can rebuild the same response ledger and freeze the same scoring prefix. Observed time and platform receipt time stay distinct. Exact replay is idempotent; conflicting client-event, sequence, evidence, or time rebinding fails closed under `READ COMMITTED`. - 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 eb64888a..f9901ea3 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -23,7 +23,7 @@ An active PR, architecture document, conversation decision, or scheduler plan is | Anonymous core assessment | PRD §3.1, §9.1 | TRD §5, §10; UML anonymous sequence | ADR-0002, ADR-0003, ADR-0005 | Session lifecycle primitives implemented, including creation bound to one published locale-specific release; anonymous credential/HTTP flow is Target | | Pause/resume | PRD §3.1, §9.1 | TRD §5 | ADR-0005 | **Implemented** in `src/session.rs` with fail-closed transitions | | Sequence-aware item delivery evidence | PRD §3.1, §9 | TRD §5–7 | ADR-0005, ADR-0010 | **Implemented** domain primitive in `src/item_delivery.rs`; persistence/API delivery orchestration is Target | -| Idempotent response events | PRD §9.2 | TRD §6 | ADR-0005, ADR-0010 | **Implemented** in `src/response.rs` with canonical SHA-256 payload-digest identity; mid-session `response_event` persist/reload is Active PR work in `src/postgres_response_event.rs` / `migrations/0020_response_event.sql` and is not protected-main truth until integrated | +| Idempotent response events | PRD §9.2 | TRD §6 | ADR-0005, ADR-0010 | **Implemented** in `src/response.rs` with canonical SHA-256 payload-digest identity; mid-session `response_event` persist/reload is Active PR #182 in `src/postgres_response_event.rs` / `migrations/0020_response_event.sql` and is not protected-main truth until integrated | | Immutable response snapshot before scoring | PRD §9.3 | TRD §5–8 | ADR-0005, ADR-0010 | **Implemented** domain semantics in `src/response.rs` | | Version-pinned scoring | PRD §9.4, §10 | TRD §8 | ADR-0004, ADR-0010 | **Implemented** reusable product-side scoring dispatch contract in `src/scoring.rs` with canonical SHA-256 engine-artifact digest provenance plus `migrations/0011_scoring_request.sql` / `src/postgres_scoring_request.rs` request-identity persistence; live fast-mlsirm integration is Target | | Bounded asynchronous scoring retry/quarantine with stale-worker fencing | PRD §9.4, §10 | TRD §8; ADR-0015 transaction boundary | ADR-0004, ADR-0010, ADR-0015 | **Implemented** product lifecycle plus PostgreSQL enqueue, claim, retry, completion, expiry recovery, and cancellation without transferring a fence; live fast-mlsirm execution remains Target | @@ -134,7 +134,7 @@ Still-Target logical modules/adapters include remaining product aggregate persis **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** response-event persist/reload stores each accepted answer under `response_event` so a mid-session restart can rebuild the same Korean IPIP Quick prefix before snapshot freeze. Exact replay is idempotent; client-identity, sequence, and evidence rebinding fail closed under `READ COMMITTED`. HTTP transport and live scoring remain outside this slice. This is not protected-main truth until an unchanged reviewed/check-clean head is integrated. +**Active PR** #182 response-event persist/reload stores each accepted answer under `response_event` so a mid-session restart can rebuild the same Korean IPIP Quick prefix before snapshot freeze. Observed time and platform receipt time stay distinct. Exact replay is idempotent; client-identity, sequence, evidence, and time rebinding fail closed under `READ COMMITTED`. Prefer this head over #174, #53, and snapshot-only #151. HTTP transport and live scoring remain outside this slice. This is not protected-main truth until an unchanged reviewed/check-clean head is integrated. ## 5. ADR traceability by concern diff --git a/docs/architecture/AS_BUILT_SCHEMA.md b/docs/architecture/AS_BUILT_SCHEMA.md index 9ac304c1..ddc8949f 100644 --- a/docs/architecture/AS_BUILT_SCHEMA.md +++ b/docs/architecture/AS_BUILT_SCHEMA.md @@ -60,7 +60,7 @@ The slice does **not** persist publication-event history, bound scientific evide ## Active PR response-event physical schema -This open PR adds `migrations/0020_response_event.sql` and `src/postgres_response_event.rs` so each accepted answer is durable before snapshot freeze. The slice is **Active PR**, not protected-main truth. It stores opaque `response_event_ref` identity, session binding, client idempotency identity, item version, canonical SHA-256 payload digest, and positive `server_sequence`. Exact replay is idempotent. Client-identity, sequence, and evidence rebinding fail closed. Reload reconstructs `ResponseLedger` in `server_sequence` order under `READ COMMITTED`. HTTP response transport remains outside this slice. +PR #182 adds `migrations/0020_response_event.sql` and `src/postgres_response_event.rs` so each accepted answer is durable before snapshot freeze. The slice is **Active PR**, not protected-main truth. It stores opaque `response_event_ref` identity, session binding, client idempotency identity, item version, canonical SHA-256 payload digest, and positive `server_sequence`. Exact replay is idempotent. Client-identity, sequence, and evidence rebinding fail closed. Reload reconstructs `ResponseLedger` in `server_sequence` order under `READ COMMITTED`. HTTP response transport remains outside this slice. ## Logical-to-physical mapping rule diff --git a/docs/architecture/ERD.md b/docs/architecture/ERD.md index 6e710169..8f298ec9 100644 --- a/docs/architecture/ERD.md +++ b/docs/architecture/ERD.md @@ -425,7 +425,7 @@ The target ERD deliberately includes several logical entities that are not yet p - `instrument_release` is the locale-specific publication identity already owned by `src/instrument.rs`. Physical `migrations/0006_instrument_release.sql` persists that one-row aggregate (immutable manifest columns plus `publication_state`); HTTP publication transport remains Target. - `data_rights_request` and `data_rights_propagation_state` are the first durable export/deletion slice. Physical `migrations/0003_data_rights_propagation.sql` stores requested-state identity plus one local outbox event per dependent system; verification, processing, completion, and dependent-system execution remain Target. - `item_delivery_event` reflects the already-merged `src/item_delivery.rs` domain primitive; durable persistence/API orchestration is still Target. -- `response_event` is the accepted mid-session ledger. Physical `migrations/0020_response_event.sql` and `src/postgres_response_event.rs` persist/reload that prefix on this Active PR; HTTP response transport remains Target. +- `response_event` is the accepted mid-session ledger. Physical `migrations/0020_response_event.sql` and `src/postgres_response_event.rs` persist/reload that prefix on Active PR #182; HTTP response transport remains Target. - `consent_ledger` and `consent_event` persist the already-merged `src/consent.rs` append-only ledger. Physical persistence is carried by Active PR #49 (`migrations/0005_consent_lifecycle.sql`); HTTP consent transport and derived snapshot tables remain Target. - `participant_identity_link` is the persistence target accepted by ADR-0020. The current `src/participant.rs` `keyverse_subject_ref` field is an application-domain first-link projection, not the future mutable persistence source of truth. - `longitudinal_enrollment`, `longitudinal_observation_record`, and `temporal_analysis_submission` make the ADR-0008 Commons-owned Gyeot/TEPP orchestration boundary explicit. No TEPP analytical kernel is duplicated here. diff --git a/migrations/0020_response_event.sql b/migrations/0020_response_event.sql index 08daab4f..655df1b1 100644 --- a/migrations/0020_response_event.sql +++ b/migrations/0020_response_event.sql @@ -41,9 +41,10 @@ CREATE TABLE IF NOT EXISTS response_event ( ), server_sequence BIGINT CONSTRAINT response_event_server_sequence_not_null NOT NULL CONSTRAINT response_event_server_sequence_positive_check CHECK (server_sequence > 0), - received_at TIMESTAMPTZ CONSTRAINT response_event_received_at_not_null NOT NULL - DEFAULT clock_timestamp(), + observed_at TIMESTAMPTZ CONSTRAINT response_event_observed_at_not_null NOT NULL, + received_at TIMESTAMPTZ CONSTRAINT response_event_received_at_not_null NOT NULL, CONSTRAINT response_event_pkey PRIMARY KEY (response_event_ref), CONSTRAINT response_event_session_client_unique UNIQUE (session_ref, client_event_ref), - CONSTRAINT response_event_session_sequence_unique UNIQUE (session_ref, server_sequence) + CONSTRAINT response_event_session_sequence_unique UNIQUE (session_ref, server_sequence), + CONSTRAINT response_event_observed_not_after_received_check CHECK (observed_at <= received_at) ); diff --git a/src/postgres_response_event.rs b/src/postgres_response_event.rs index 0f44cc71..1e257bb9 100644 --- a/src/postgres_response_event.rs +++ b/src/postgres_response_event.rs @@ -12,6 +12,7 @@ use crate::response::{ResponseEvent, ResponseLedger, WriteError}; use postgres::Transaction; use std::error::Error; use std::fmt::{Display, Formatter}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; const RESPONSE_EVENT_MIGRATION: &str = include_str!("../migrations/0020_response_event.sql"); @@ -37,6 +38,8 @@ pub enum ResponseEventPersistenceError { SequenceConflict, /// A sequence cannot be represented by the bounded database column. InvalidSequence, + /// Observed time was zero, inverted after received time, or out of range. + InvalidTimestamp, /// Response-event persistence requires `PostgreSQL` `READ COMMITTED` isolation. UnsupportedIsolationLevel, /// `PostgreSQL` rejected or could not execute the persistence operation. @@ -58,6 +61,9 @@ impl Display for ResponseEventPersistenceError { Self::InvalidSequence => { "response event sequence is missing, gapped, or outside the PostgreSQL bigint range" } + Self::InvalidTimestamp => { + "response event observed time must be positive and not after received time" + } Self::UnsupportedIsolationLevel => { "response event persistence requires read committed isolation" } @@ -95,18 +101,22 @@ pub fn apply_response_event_migration( /// Persist one accepted response event for a session. /// /// Exact replay of the same event identity, session binding, item version, -/// payload digest, and server sequence is idempotent. Rebinding any of those -/// values fails closed. Historical accepted answers are never updated. +/// payload digest, server sequence, and observed/received times is idempotent. +/// Rebinding any of those values fails closed. Historical accepted answers are +/// never updated. `observed_at_unix_ms` is source-valid time; `received_at_unix_ms` +/// is platform receipt time. /// /// # Errors /// /// Returns [`ResponseEventPersistenceError`] for an unbound identity, /// unsupported isolation, conflicting replay, a sequence conflict, an invalid -/// sequence, or a database failure. +/// sequence or timestamp, or a database failure. pub fn persist_response_event( transaction: &mut Transaction<'_>, session_ref: &str, event: &ResponseEvent, + observed_at_unix_ms: u64, + received_at_unix_ms: u64, ) -> Result { require_read_committed(transaction)?; let session_ref = required_reference(session_ref)?; @@ -114,12 +124,17 @@ pub fn persist_response_event( let client_event_ref = required_reference(event.client_event_ref())?; let item_version_ref = required_reference(event.item_version_ref())?; let server_sequence = postgres_sequence(event.sequence())?; + let observed_at = postgres_timestamptz(observed_at_unix_ms)?; + let received_at = postgres_timestamptz(received_at_unix_ms)?; + if observed_at_unix_ms > received_at_unix_ms { + return Err(ResponseEventPersistenceError::InvalidTimestamp); + } let inserted = match transaction.execute( "INSERT INTO response_event (\ response_event_ref, session_ref, client_event_ref, item_version_ref, \ - payload_digest, server_sequence\ - ) VALUES ($1, $2, $3, $4, $5, $6) \ + payload_digest, server_sequence, observed_at, received_at\ + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \ ON CONFLICT (response_event_ref) DO NOTHING", &[ &server_event_ref, @@ -128,6 +143,8 @@ pub fn persist_response_event( &item_version_ref, &event.payload_digest(), &server_sequence, + &observed_at, + &received_at, ], ) { Ok(count) => count, @@ -142,6 +159,8 @@ pub fn persist_response_event( event, server_event_ref, server_sequence, + observed_at, + received_at, ) } @@ -196,9 +215,12 @@ fn classify_existing_event( event: &ResponseEvent, server_event_ref: &str, server_sequence: i64, + observed_at: SystemTime, + received_at: SystemTime, ) -> Result { let row = transaction.query_one( - "SELECT session_ref, client_event_ref, item_version_ref, payload_digest, server_sequence \ + "SELECT session_ref, client_event_ref, item_version_ref, payload_digest, \ + server_sequence, observed_at, received_at \ FROM response_event WHERE response_event_ref = $1", &[&server_event_ref], )?; @@ -207,11 +229,15 @@ fn classify_existing_event( let stored_item: String = row.get(2); let stored_digest: String = row.get(3); let stored_sequence: i64 = row.get(4); + let stored_observed: SystemTime = row.get(5); + let stored_received: SystemTime = row.get(6); if stored_session == session_ref && stored_client == event.client_event_ref() && stored_item == event.item_version_ref() && stored_digest == event.payload_digest() && stored_sequence == server_sequence + && stored_observed == observed_at + && stored_received == received_at { Ok(ResponseEventPersistenceDisposition::Duplicate) } else { @@ -257,6 +283,15 @@ fn postgres_sequence(value: usize) -> Result i64::try_from(value).map_err(|_| ResponseEventPersistenceError::InvalidSequence) } +fn postgres_timestamptz(unix_ms: u64) -> Result { + if unix_ms == 0 { + return Err(ResponseEventPersistenceError::InvalidTimestamp); + } + UNIX_EPOCH + .checked_add(Duration::from_millis(unix_ms)) + .ok_or(ResponseEventPersistenceError::InvalidTimestamp) +} + fn require_read_committed( transaction: &mut Transaction<'_>, ) -> Result<(), ResponseEventPersistenceError> { @@ -272,7 +307,8 @@ fn require_read_committed( #[cfg(test)] mod reference_guard_tests { use super::{ - map_rebuild_error, postgres_sequence, required_reference, ResponseEventPersistenceError, + map_rebuild_error, postgres_sequence, postgres_timestamptz, required_reference, + ResponseEventPersistenceError, }; use crate::response::WriteError; use crate::session::SessionState; @@ -296,6 +332,15 @@ mod reference_guard_tests { postgres_sequence(usize::MAX), Err(ResponseEventPersistenceError::InvalidSequence) )); + assert!(matches!( + postgres_timestamptz(0), + Err(ResponseEventPersistenceError::InvalidTimestamp) + )); + assert!(postgres_timestamptz(1_700_000_000_000).is_ok()); + assert!(matches!( + postgres_timestamptz(u64::MAX), + Err(ResponseEventPersistenceError::InvalidTimestamp) + )); } #[test] diff --git a/tests/postgres_recovery_invariants.rs b/tests/postgres_recovery_invariants.rs index 64608eee..313f1818 100644 --- a/tests/postgres_recovery_invariants.rs +++ b/tests/postgres_recovery_invariants.rs @@ -101,11 +101,13 @@ fn seed_recovery_critical_state(client: &mut Client) { ); INSERT INTO {SOURCE_SCHEMA}.response_event ( response_event_ref, session_ref, client_event_ref, item_version_ref, - payload_digest, server_sequence + payload_digest, server_sequence, observed_at, received_at ) VALUES ( 'response_event_recovery_alpha', 'session_recovery_alpha', 'client_event_recovery_alpha', 'item_version_recovery_alpha', - '{DIGEST_A}', 1 + '{DIGEST_A}', 1, + TIMESTAMPTZ '2023-11-14 22:13:20+00', + TIMESTAMPTZ '2023-11-14 22:13:20.250+00' );" )) .expect("recovery fixture should satisfy all protected-main persistence constraints"); diff --git a/tests/postgres_response_event_error_contract.rs b/tests/postgres_response_event_error_contract.rs index b256344d..74133e77 100644 --- a/tests/postgres_response_event_error_contract.rs +++ b/tests/postgres_response_event_error_contract.rs @@ -28,6 +28,10 @@ fn persistence_errors_expose_stable_messages_and_database_sources() { ResponseEventPersistenceError::InvalidSequence, "response event sequence is missing, gapped, or outside the PostgreSQL bigint range", ), + ( + ResponseEventPersistenceError::InvalidTimestamp, + "response event observed time must be positive and not after received time", + ), ( ResponseEventPersistenceError::UnsupportedIsolationLevel, "response event persistence requires read committed isolation", diff --git a/tests/postgres_response_event_persistence.rs b/tests/postgres_response_event_persistence.rs index 303ad4e6..2165a24d 100644 --- a/tests/postgres_response_event_persistence.rs +++ b/tests/postgres_response_event_persistence.rs @@ -11,6 +11,8 @@ use std::sync::{Mutex, MutexGuard}; const DIGEST_N1: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const DIGEST_N2: &str = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const OBSERVED_AT_MS: u64 = 1_700_000_000_000; +const RECEIVED_AT_MS: u64 = 1_700_000_000_250; static RESPONSE_EVENT_TEST_LOCK: Mutex<()> = Mutex::new(()); @@ -67,9 +69,26 @@ fn persist_ok( client: &mut Client, session_ref: &str, event: &ResponseEvent, +) -> ResponseEventPersistenceDisposition { + persist_ok_at(client, session_ref, event, OBSERVED_AT_MS, RECEIVED_AT_MS) +} + +fn persist_ok_at( + client: &mut Client, + session_ref: &str, + event: &ResponseEvent, + observed_at_unix_ms: u64, + received_at_unix_ms: u64, ) -> ResponseEventPersistenceDisposition { let mut transaction = client.transaction().unwrap(); - let disposition = persist_response_event(&mut transaction, session_ref, event).unwrap(); + let disposition = persist_response_event( + &mut transaction, + session_ref, + event, + observed_at_unix_ms, + received_at_unix_ms, + ) + .unwrap(); transaction.commit().unwrap(); disposition } @@ -78,9 +97,26 @@ fn persist_err( client: &mut Client, session_ref: &str, event: &ResponseEvent, +) -> ResponseEventPersistenceError { + persist_err_at(client, session_ref, event, OBSERVED_AT_MS, RECEIVED_AT_MS) +} + +fn persist_err_at( + client: &mut Client, + session_ref: &str, + event: &ResponseEvent, + observed_at_unix_ms: u64, + received_at_unix_ms: u64, ) -> ResponseEventPersistenceError { let mut transaction = client.transaction().unwrap(); - let error = persist_response_event(&mut transaction, session_ref, event).unwrap_err(); + let error = persist_response_event( + &mut transaction, + session_ref, + event, + observed_at_unix_ms, + received_at_unix_ms, + ) + .unwrap_err(); transaction.rollback().unwrap(); error } @@ -293,7 +329,13 @@ fn persist_and_load_require_read_committed_and_opaque_session() { .start() .unwrap(); assert!(matches!( - persist_response_event(&mut serializable, "session_ipip_ko_isolation", &event), + persist_response_event( + &mut serializable, + "session_ipip_ko_isolation", + &event, + OBSERVED_AT_MS, + RECEIVED_AT_MS, + ), Err(ResponseEventPersistenceError::UnsupportedIsolationLevel) )); assert!(matches!( @@ -338,10 +380,11 @@ fn missing_relation_and_gapped_history_fail_closed() { .execute( "INSERT INTO response_event (\ response_event_ref, session_ref, client_event_ref, item_version_ref, \ - payload_digest, server_sequence\ + payload_digest, server_sequence, observed_at, received_at\ ) VALUES (\ 'server_event_item_03', 'session_ipip_ko_gap', 'client_event_item_03', \ - 'item_version_n3_ko', $1, 3\ + 'item_version_n3_ko', $1, 3, TIMESTAMPTZ '2023-11-14 22:13:20+00', \ + TIMESTAMPTZ '2023-11-14 22:13:20.250+00'\ )", &[&DIGEST_N2], ) @@ -368,10 +411,12 @@ fn stored_noncanonical_digest_fails_closed_on_reload() { .execute( "INSERT INTO response_event (\ response_event_ref, session_ref, client_event_ref, item_version_ref, \ - payload_digest, server_sequence\ + payload_digest, server_sequence, observed_at, received_at\ ) VALUES (\ 'server_event_item_01', 'session_ipip_ko_digest', 'client_event_item_01', \ - 'item_version_n1_ko', 'not-a-digest', 1\ + 'item_version_n1_ko', 'not-a-digest', 1, \ + TIMESTAMPTZ '2023-11-14 22:13:20+00', \ + TIMESTAMPTZ '2023-11-14 22:13:20.250+00'\ )", &[], ) @@ -428,10 +473,12 @@ fn unexpected_unique_constraint_and_negative_sequence_fail_closed() { .execute( "INSERT INTO response_event (\ response_event_ref, session_ref, client_event_ref, item_version_ref, \ - payload_digest, server_sequence\ + payload_digest, server_sequence, observed_at, received_at\ ) VALUES (\ 'server_event_item_neg', 'session_ipip_ko_negative', 'client_event_item_neg', \ - 'item_version_n_neg', $1, -1\ + 'item_version_n_neg', $1, -1, \ + TIMESTAMPTZ '2023-11-14 22:13:20+00', \ + TIMESTAMPTZ '2023-11-14 22:13:20.250+00'\ )", &[&DIGEST_N1], ) @@ -441,3 +488,52 @@ fn unexpected_unique_constraint_and_negative_sequence_fail_closed() { ResponseEventPersistenceError::InvalidSequence )); } + +#[test] +fn inverted_or_zero_event_times_and_time_rebinding_fail_closed() { + let _guard = response_event_test_guard(); + let mut client = test_client(); + reset_response_event_table(&mut client); + apply_response_event_migration(&mut client).unwrap(); + + let (_, event) = recorded_event( + "session_ipip_ko_time", + write( + "server_event_item_01", + "client_event_item_01", + "item_version_n1_ko", + DIGEST_N1, + ), + ); + assert!(matches!( + persist_err_at( + &mut client, + "session_ipip_ko_time", + &event, + 0, + RECEIVED_AT_MS + ), + ResponseEventPersistenceError::InvalidTimestamp + )); + assert!(matches!( + persist_err_at( + &mut client, + "session_ipip_ko_time", + &event, + RECEIVED_AT_MS + 1, + RECEIVED_AT_MS + ), + ResponseEventPersistenceError::InvalidTimestamp + )); + persist_ok(&mut client, "session_ipip_ko_time", &event); + assert!(matches!( + persist_err_at( + &mut client, + "session_ipip_ko_time", + &event, + OBSERVED_AT_MS, + RECEIVED_AT_MS + 1 + ), + ResponseEventPersistenceError::ConflictingReplay + )); +} From 86e5669514e5866b7a20d4fb6d6dc5792f1758a8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:21:00 +0000 Subject: [PATCH 03/33] test(response): drop overflow time case that does not fail on this host u64::MAX milliseconds is representable as SystemTime here, so the assertion did not prove an invalid timestamp. Co-authored-by: Seongho Bae --- src/postgres_response_event.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/postgres_response_event.rs b/src/postgres_response_event.rs index 1e257bb9..2548887a 100644 --- a/src/postgres_response_event.rs +++ b/src/postgres_response_event.rs @@ -337,10 +337,6 @@ mod reference_guard_tests { Err(ResponseEventPersistenceError::InvalidTimestamp) )); assert!(postgres_timestamptz(1_700_000_000_000).is_ok()); - assert!(matches!( - postgres_timestamptz(u64::MAX), - Err(ResponseEventPersistenceError::InvalidTimestamp) - )); } #[test] From 4dd8e6d906fde57420b84e6c383ac988beef8d68 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:28:42 +0000 Subject: [PATCH 04/33] feat(response): reload observed and received event clocks Keep source-valid time distinct from platform receipt time after restart so a Korean IPIP Quick path can hand the same temporal prefix to later scoring or TEPP composition. Inverted or zero stored times fail closed. Co-authored-by: Seongho Bae --- CHANGELOG.md | 2 +- docs/TRACEABILITY.md | 2 +- ...-persistence-and-transaction-boundaries.md | 2 +- docs/architecture/AS_BUILT_SCHEMA.md | 2 +- docs/architecture/ERD.md | 2 +- docs/architecture/UML.md | 2 +- src/postgres_response_event.rs | 151 ++++++++++++++++-- tests/postgres_response_event_persistence.rs | 59 ++++++- 8 files changed, 205 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04796351..8da78675 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 -- PostgreSQL 18 persistence for accepted `response_event` rows so a mid-session restart can rebuild the same response ledger and freeze the same scoring prefix. Observed time and platform receipt time stay distinct. Exact replay is idempotent; conflicting client-event, sequence, evidence, or time rebinding fails closed under `READ COMMITTED`. +- PostgreSQL 18 persistence for accepted `response_event` rows so a mid-session restart can rebuild the same response ledger and freeze the same scoring prefix. Observed time and platform receipt time stay distinct on write and reload. Exact replay is idempotent; conflicting client-event, sequence, evidence, or time rebinding fails closed under `READ COMMITTED`. - 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 f9901ea3..38f886b6 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -134,7 +134,7 @@ Still-Target logical modules/adapters include remaining product aggregate persis **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** #182 response-event persist/reload stores each accepted answer under `response_event` so a mid-session restart can rebuild the same Korean IPIP Quick prefix before snapshot freeze. Observed time and platform receipt time stay distinct. Exact replay is idempotent; client-identity, sequence, evidence, and time rebinding fail closed under `READ COMMITTED`. Prefer this head over #174, #53, and snapshot-only #151. HTTP transport and live scoring remain outside this slice. This is not protected-main truth until an unchanged reviewed/check-clean head is integrated. +**Active PR** #182 response-event persist/reload stores each accepted answer under `response_event` so a mid-session restart can rebuild the same Korean IPIP Quick prefix before snapshot freeze. Observed time and platform receipt time stay distinct on write and reload (`ResponseEventReceipt`). Exact replay is idempotent; client-identity, sequence, evidence, inverted/zero stored time, and time rebinding fail closed under `READ COMMITTED`. Prefer this head over #174, #53, and snapshot-only #151. HTTP transport and live scoring remain outside this slice. This is not protected-main truth until an unchanged reviewed/check-clean head is integrated. ## 5. ADR traceability by concern diff --git a/docs/adr/0015-persistence-and-transaction-boundaries.md b/docs/adr/0015-persistence-and-transaction-boundaries.md index a4875d65..e9e85cbb 100644 --- a/docs/adr/0015-persistence-and-transaction-boundaries.md +++ b/docs/adr/0015-persistence-and-transaction-boundaries.md @@ -6,7 +6,7 @@ - Scope: Psychometrics Commons-owned durable state, local transactions, migration boundaries, outbox/inbox integration - Supersedes: none - Superseded by: none -- Current/as-built status: protected main contains domain lifecycle primitives plus bounded PostgreSQL slices including completed `response_snapshot` persist; mid-session `response_event` persist/reload is Active PR work in `migrations/0020_response_event.sql` and is not protected-main truth until merged +- Current/as-built status: protected main contains domain lifecycle primitives plus bounded PostgreSQL slices including completed `response_snapshot` persist; mid-session `response_event` persist/reload with distinct observed/received clocks is Active PR #182 work in `migrations/0020_response_event.sql` and is not protected-main truth until merged - Target status: upstream PostgreSQL 18.x operational persistence with real-database concurrency/crash/recovery evidence and transactional outbox/inbox semantics - Migration status: active PR #24 introduces only the bounded integration-evidence slice; the remaining product schema still must be established from the logical ERD and this ADR without synthetic provenance backfills diff --git a/docs/architecture/AS_BUILT_SCHEMA.md b/docs/architecture/AS_BUILT_SCHEMA.md index ddc8949f..b2974960 100644 --- a/docs/architecture/AS_BUILT_SCHEMA.md +++ b/docs/architecture/AS_BUILT_SCHEMA.md @@ -60,7 +60,7 @@ The slice does **not** persist publication-event history, bound scientific evide ## Active PR response-event physical schema -PR #182 adds `migrations/0020_response_event.sql` and `src/postgres_response_event.rs` so each accepted answer is durable before snapshot freeze. The slice is **Active PR**, not protected-main truth. It stores opaque `response_event_ref` identity, session binding, client idempotency identity, item version, canonical SHA-256 payload digest, and positive `server_sequence`. Exact replay is idempotent. Client-identity, sequence, and evidence rebinding fail closed. Reload reconstructs `ResponseLedger` in `server_sequence` order under `READ COMMITTED`. HTTP response transport remains outside this slice. +PR #182 adds `migrations/0020_response_event.sql` and `src/postgres_response_event.rs` so each accepted answer is durable before snapshot freeze. The slice is **Active PR**, not protected-main truth. It stores opaque `response_event_ref` identity, session binding, client idempotency identity, item version, canonical SHA-256 payload digest, positive `server_sequence`, source-valid `observed_at`, and platform `received_at`. Exact replay is idempotent. Client-identity, sequence, evidence, and time rebinding fail closed. Reload reconstructs `ResponseLedger` and `ResponseEventReceipt` clocks in `server_sequence` order under `READ COMMITTED`. Inverted or zero stored times fail closed even if a check constraint was dropped. HTTP response transport remains outside this slice. ## Logical-to-physical mapping rule diff --git a/docs/architecture/ERD.md b/docs/architecture/ERD.md index 8f298ec9..f7d93011 100644 --- a/docs/architecture/ERD.md +++ b/docs/architecture/ERD.md @@ -425,7 +425,7 @@ The target ERD deliberately includes several logical entities that are not yet p - `instrument_release` is the locale-specific publication identity already owned by `src/instrument.rs`. Physical `migrations/0006_instrument_release.sql` persists that one-row aggregate (immutable manifest columns plus `publication_state`); HTTP publication transport remains Target. - `data_rights_request` and `data_rights_propagation_state` are the first durable export/deletion slice. Physical `migrations/0003_data_rights_propagation.sql` stores requested-state identity plus one local outbox event per dependent system; verification, processing, completion, and dependent-system execution remain Target. - `item_delivery_event` reflects the already-merged `src/item_delivery.rs` domain primitive; durable persistence/API orchestration is still Target. -- `response_event` is the accepted mid-session ledger. Physical `migrations/0020_response_event.sql` and `src/postgres_response_event.rs` persist/reload that prefix on Active PR #182; HTTP response transport remains Target. +- `response_event` is the accepted mid-session ledger. Physical `migrations/0020_response_event.sql` and `src/postgres_response_event.rs` persist/reload that prefix plus distinct observed/received clocks on Active PR #182; HTTP response transport remains Target. - `consent_ledger` and `consent_event` persist the already-merged `src/consent.rs` append-only ledger. Physical persistence is carried by Active PR #49 (`migrations/0005_consent_lifecycle.sql`); HTTP consent transport and derived snapshot tables remain Target. - `participant_identity_link` is the persistence target accepted by ADR-0020. The current `src/participant.rs` `keyverse_subject_ref` field is an application-domain first-link projection, not the future mutable persistence source of truth. - `longitudinal_enrollment`, `longitudinal_observation_record`, and `temporal_analysis_submission` make the ADR-0008 Commons-owned Gyeot/TEPP orchestration boundary explicit. No TEPP analytical kernel is duplicated here. diff --git a/docs/architecture/UML.md b/docs/architecture/UML.md index 0e877391..d285c613 100644 --- a/docs/architecture/UML.md +++ b/docs/architecture/UML.md @@ -336,7 +336,7 @@ sequenceDiagram participant F as fast-mlsirm scoring path P->>A: complete assessment - Note over DB: Active PR persist/reload keeps accepted response_event rows so a mid-session restart can rebuild the same prefix before this freeze + Note over DB: Active PR persist/reload keeps accepted response_event rows and distinct observed/received clocks so a mid-session restart can rebuild the same prefix before this freeze A->>DB: commit Completed + immutable response snapshot + outbox A-->>P: completion durable; scoring pending diff --git a/src/postgres_response_event.rs b/src/postgres_response_event.rs index 2548887a..ef6b5262 100644 --- a/src/postgres_response_event.rs +++ b/src/postgres_response_event.rs @@ -16,6 +16,39 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; const RESPONSE_EVENT_MIGRATION: &str = include_str!("../migrations/0020_response_event.sql"); +/// One accepted event plus the distinct observed and received clocks. +/// +/// `observed_at_unix_ms` is source-valid time. `received_at_unix_ms` is +/// platform receipt time. Reload keeps both so a Korean IPIP Quick restart +/// can hand the same temporal prefix to later TEPP or scoring composition +/// without inventing an answer or a score. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ResponseEventReceipt { + event: ResponseEvent, + observed_at_unix_ms: u64, + received_at_unix_ms: u64, +} + +impl ResponseEventReceipt { + /// Return the accepted response-event identity and evidence. + #[must_use] + pub const fn event(&self) -> &ResponseEvent { + &self.event + } + + /// Return source-valid observed time in Unix milliseconds. + #[must_use] + pub const fn observed_at_unix_ms(&self) -> u64 { + self.observed_at_unix_ms + } + + /// Return platform receipt time in Unix milliseconds. + #[must_use] + pub const fn received_at_unix_ms(&self) -> u64 { + self.received_at_unix_ms + } +} + /// Outcome of persisting one accepted response event. #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[non_exhaustive] @@ -164,38 +197,46 @@ pub fn persist_response_event( ) } -/// Rebuild the accepted response ledger for one session after restart. +/// Rebuild accepted events and their observed/received clocks after restart. /// /// Rows are read in `server_sequence` order. A missing session returns an empty -/// ledger. Gapped, reordered, or conflicting stored identities fail closed. +/// list. Gapped, reordered, conflicting, inverted, or zero stored times fail +/// closed. Observed time stays distinct from platform receipt time. /// /// # Errors /// /// Returns [`ResponseEventPersistenceError`] for an unbound session, unsupported -/// isolation, corrupt stored history, or a database failure. -pub fn load_response_ledger( +/// isolation, corrupt stored history, invalid stored time, or a database failure. +pub fn load_response_event_receipts( transaction: &mut Transaction<'_>, session_ref: &str, -) -> Result { +) -> Result, ResponseEventPersistenceError> { require_read_committed(transaction)?; let session_ref = required_reference(session_ref)?; let rows = transaction.query( "SELECT response_event_ref, client_event_ref, item_version_ref, \ - payload_digest, server_sequence \ + payload_digest, server_sequence, observed_at, received_at \ FROM response_event \ WHERE session_ref = $1 \ ORDER BY server_sequence", &[&session_ref], )?; - let mut events = Vec::with_capacity(rows.len()); + let mut receipts = Vec::with_capacity(rows.len()); for row in rows { let server_event_ref: String = row.get(0); let client_event_ref: String = row.get(1); let item_version_ref: String = row.get(2); let payload_digest: String = row.get(3); let server_sequence: i64 = row.get(4); + let observed_at: SystemTime = row.get(5); + let received_at: SystemTime = row.get(6); let sequence = usize::try_from(server_sequence) .map_err(|_| ResponseEventPersistenceError::InvalidSequence)?; + let observed_at_unix_ms = unix_ms_from_system_time(observed_at)?; + let received_at_unix_ms = unix_ms_from_system_time(received_at)?; + if observed_at_unix_ms > received_at_unix_ms { + return Err(ResponseEventPersistenceError::InvalidTimestamp); + } let event = ResponseEvent::from_persisted( server_event_ref, client_event_ref, @@ -204,8 +245,34 @@ pub fn load_response_ledger( sequence, ) .map_err(map_rebuild_error)?; - events.push(event); + receipts.push(ResponseEventReceipt { + event, + observed_at_unix_ms, + received_at_unix_ms, + }); } + Ok(receipts) +} + +/// Rebuild the accepted response ledger for one session after restart. +/// +/// Rows are read in `server_sequence` order. A missing session returns an empty +/// ledger. Gapped, reordered, conflicting identities, or invalid stored times +/// fail closed. +/// +/// # Errors +/// +/// Returns [`ResponseEventPersistenceError`] for an unbound session, unsupported +/// isolation, corrupt stored history, or a database failure. +pub fn load_response_ledger( + transaction: &mut Transaction<'_>, + session_ref: &str, +) -> Result { + let receipts = load_response_event_receipts(transaction, session_ref)?; + let events = receipts + .into_iter() + .map(|receipt| receipt.event) + .collect(); ResponseLedger::from_persisted(session_ref, events).map_err(map_rebuild_error) } @@ -292,6 +359,21 @@ fn postgres_timestamptz(unix_ms: u64) -> Result Result { + let duration = time + .duration_since(UNIX_EPOCH) + .map_err(|_| ResponseEventPersistenceError::InvalidTimestamp)?; + let unix_ms = duration + .as_secs() + .checked_mul(1_000) + .and_then(|value| value.checked_add(u64::from(duration.subsec_millis()))) + .ok_or(ResponseEventPersistenceError::InvalidTimestamp)?; + if unix_ms == 0 { + return Err(ResponseEventPersistenceError::InvalidTimestamp); + } + Ok(unix_ms) +} + fn require_read_committed( transaction: &mut Transaction<'_>, ) -> Result<(), ResponseEventPersistenceError> { @@ -308,8 +390,10 @@ fn require_read_committed( mod reference_guard_tests { use super::{ map_rebuild_error, postgres_sequence, postgres_timestamptz, required_reference, - ResponseEventPersistenceError, + unix_ms_from_system_time, ResponseEventPersistenceError, ResponseEventReceipt, }; + use crate::response::ResponseEvent; + use std::time::{Duration, UNIX_EPOCH}; use crate::response::WriteError; use crate::session::SessionState; @@ -337,6 +421,55 @@ mod reference_guard_tests { Err(ResponseEventPersistenceError::InvalidTimestamp) )); assert!(postgres_timestamptz(1_700_000_000_000).is_ok()); + assert!(matches!( + unix_ms_from_system_time(UNIX_EPOCH), + Err(ResponseEventPersistenceError::InvalidTimestamp) + )); + assert!(matches!( + unix_ms_from_system_time(UNIX_EPOCH - Duration::from_millis(1)), + Err(ResponseEventPersistenceError::InvalidTimestamp) + )); + assert_eq!( + unix_ms_from_system_time(UNIX_EPOCH + Duration::from_secs(1_700_000_000)).unwrap(), + 1_700_000_000_000 + ); + let overflow_secs = u64::MAX / 1_000 + 1; + if let Some(far_future) = UNIX_EPOCH.checked_add(Duration::from_secs(overflow_secs)) { + assert!(matches!( + unix_ms_from_system_time(far_future), + Err(ResponseEventPersistenceError::InvalidTimestamp) + )); + } + let add_overflow_secs = u64::MAX / 1_000; + if let Some(near_max) = UNIX_EPOCH + .checked_add(Duration::from_secs(add_overflow_secs)) + .and_then(|time| time.checked_add(Duration::from_millis(616))) + { + assert!(matches!( + unix_ms_from_system_time(near_max), + Err(ResponseEventPersistenceError::InvalidTimestamp) + )); + } + } + + #[test] + fn receipt_keeps_distinct_observed_and_received_times() { + let event = ResponseEvent::from_persisted( + "server_event_item_01", + "client_event_item_01", + "item_version_n1_ko", + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + 1, + ) + .unwrap(); + let receipt = ResponseEventReceipt { + event: event.clone(), + observed_at_unix_ms: 1_700_000_000_000, + received_at_unix_ms: 1_700_000_000_250, + }; + assert_eq!(receipt.event(), &event); + assert_eq!(receipt.observed_at_unix_ms(), 1_700_000_000_000); + assert_eq!(receipt.received_at_unix_ms(), 1_700_000_000_250); } #[test] diff --git a/tests/postgres_response_event_persistence.rs b/tests/postgres_response_event_persistence.rs index 2165a24d..e182b14c 100644 --- a/tests/postgres_response_event_persistence.rs +++ b/tests/postgres_response_event_persistence.rs @@ -2,8 +2,9 @@ use postgres::{Client, IsolationLevel, NoTls}; use psychometrics_commons_runtime::postgres_response_event::{ - apply_response_event_migration, load_response_ledger, persist_response_event, - ResponseEventPersistenceDisposition, ResponseEventPersistenceError, + apply_response_event_migration, load_response_event_receipts, load_response_ledger, + persist_response_event, ResponseEventPersistenceDisposition, ResponseEventPersistenceError, + ResponseEventReceipt, }; use psychometrics_commons_runtime::response::{ResponseEvent, ResponseLedger, ResponseWrite}; use psychometrics_commons_runtime::session::SessionState; @@ -128,6 +129,13 @@ fn load_ok(client: &mut Client, session_ref: &str) -> ResponseLedger { ledger } +fn load_receipts_ok(client: &mut Client, session_ref: &str) -> Vec { + let mut transaction = client.transaction().unwrap(); + let receipts = load_response_event_receipts(&mut transaction, session_ref).unwrap(); + transaction.commit().unwrap(); + receipts +} + fn load_err(client: &mut Client, session_ref: &str) -> ResponseEventPersistenceError { let mut transaction = client.transaction().unwrap(); let error = load_response_ledger(&mut transaction, session_ref).unwrap_err(); @@ -181,6 +189,11 @@ fn two_item_korean_path_survives_restart_and_exact_replay() { let after_first = load_ok(&mut client, "session_ipip_ko_quick"); assert_eq!(after_first.events(), std::slice::from_ref(&first)); + let first_receipts = load_receipts_ok(&mut client, "session_ipip_ko_quick"); + assert_eq!(first_receipts.len(), 1); + assert_eq!(first_receipts[0].event(), &first); + assert_eq!(first_receipts[0].observed_at_unix_ms(), OBSERVED_AT_MS); + assert_eq!(first_receipts[0].received_at_unix_ms(), RECEIVED_AT_MS); let second = live .record( @@ -536,4 +549,46 @@ fn inverted_or_zero_event_times_and_time_rebinding_fail_closed() { ), ResponseEventPersistenceError::ConflictingReplay )); + + client + .batch_execute( + "ALTER TABLE response_event DROP CONSTRAINT response_event_observed_not_after_received_check;", + ) + .unwrap(); + client + .execute( + "INSERT INTO response_event (\ + response_event_ref, session_ref, client_event_ref, item_version_ref, \ + payload_digest, server_sequence, observed_at, received_at\ + ) VALUES (\ + 'server_event_item_inverted', 'session_ipip_ko_inverted', \ + 'client_event_item_inverted', 'item_version_n_inverted', $1, 1, \ + TIMESTAMPTZ '2023-11-14 22:13:21+00', \ + TIMESTAMPTZ '2023-11-14 22:13:20+00'\ + )", + &[&DIGEST_N1], + ) + .unwrap(); + assert!(matches!( + load_err(&mut client, "session_ipip_ko_inverted"), + ResponseEventPersistenceError::InvalidTimestamp + )); + client + .execute( + "INSERT INTO response_event (\ + response_event_ref, session_ref, client_event_ref, item_version_ref, \ + payload_digest, server_sequence, observed_at, received_at\ + ) VALUES (\ + 'server_event_item_epoch', 'session_ipip_ko_epoch', \ + 'client_event_item_epoch', 'item_version_n_epoch', $1, 1, \ + TIMESTAMPTZ '1970-01-01 00:00:00+00', \ + TIMESTAMPTZ '1970-01-01 00:00:00+00'\ + )", + &[&DIGEST_N1], + ) + .unwrap(); + assert!(matches!( + load_err(&mut client, "session_ipip_ko_epoch"), + ResponseEventPersistenceError::InvalidTimestamp + )); } From 7a72ee1df6621c5487ce86d9559ced05fd98e06a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:29:28 +0000 Subject: [PATCH 05/33] test(response): prove sequence reuse by another event identity The previous case moved the first row to sequence 2, then recorded a new event that still owned sequence 1, so persist succeeded. Persist a second identity on sequence 1 and keep the original Korean prefix unchanged. Co-authored-by: Seongho Bae --- tests/postgres_response_event_persistence.rs | 28 ++++++++------------ 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/tests/postgres_response_event_persistence.rs b/tests/postgres_response_event_persistence.rs index e182b14c..43e987ef 100644 --- a/tests/postgres_response_event_persistence.rs +++ b/tests/postgres_response_event_persistence.rs @@ -298,26 +298,20 @@ fn reused_server_sequence_by_another_event_fails_closed() { ), ); persist_ok(&mut client, "session_ipip_ko_sequence", &first); - client - .execute( - "UPDATE response_event SET server_sequence = 2 \ - WHERE response_event_ref = 'server_event_item_01'", - &[], - ) - .unwrap(); - let (_, other_sequence) = recorded_event( - "session_ipip_ko_sequence", - write( - "server_event_item_02", - "client_event_item_02", - "item_version_n2_ko", - DIGEST_N2, - ), - ); + let colliding = ResponseEvent::from_persisted( + "server_event_item_02", + "client_event_item_02", + "item_version_n2_ko", + DIGEST_N2, + 1, + ) + .unwrap(); assert!(matches!( - persist_err(&mut client, "session_ipip_ko_sequence", &other_sequence), + persist_err(&mut client, "session_ipip_ko_sequence", &colliding), ResponseEventPersistenceError::SequenceConflict )); + let rebuilt = load_ok(&mut client, "session_ipip_ko_sequence"); + assert_eq!(rebuilt.events(), std::slice::from_ref(&first)); } #[test] From 5c662423b5b678396bb63a4ff971ef7a734a82a8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:44:24 +0000 Subject: [PATCH 06/33] fix(response): continue from reload and fail closed on gapped receipts A Korean IPIP Quick restart now records item 2 on the reloaded ledger and freezes the same scoring request as an uninterrupted control. Receipt reload rejects gapped sequences, recovery COPY asserts both clocks, and neighbor sessions stay isolated. Co-authored-by: Seongho Bae --- CHANGELOG.md | 2 +- docs/TRACEABILITY.md | 4 +- docs/architecture/AS_BUILT_SCHEMA.md | 2 +- src/postgres_response_event.rs | 36 ++++-- tests/postgres_recovery_invariants.rs | 12 +- tests/postgres_response_event_persistence.rs | 113 ++++++++++++++++++- tests/response_event_persisted.rs | 65 +++++++++++ 7 files changed, 215 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8da78675..e75fc861 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 -- PostgreSQL 18 persistence for accepted `response_event` rows so a mid-session restart can rebuild the same response ledger and freeze the same scoring prefix. Observed time and platform receipt time stay distinct on write and reload. Exact replay is idempotent; conflicting client-event, sequence, evidence, or time rebinding fails closed under `READ COMMITTED`. +- PostgreSQL 18 persistence for accepted `response_event` rows so a mid-session restart can rebuild the same response ledger, continue from the reloaded prefix, and freeze the same scoring request. Observed time and platform receipt time stay distinct on write, reload, and recovery COPY. Exact replay is idempotent; conflicting client-event, sequence, evidence, time rebinding, or gapped receipt history fails closed under `READ COMMITTED`. - 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 38f886b6..ad6fdf0b 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -23,7 +23,7 @@ An active PR, architecture document, conversation decision, or scheduler plan is | Anonymous core assessment | PRD §3.1, §9.1 | TRD §5, §10; UML anonymous sequence | ADR-0002, ADR-0003, ADR-0005 | Session lifecycle primitives implemented, including creation bound to one published locale-specific release; anonymous credential/HTTP flow is Target | | Pause/resume | PRD §3.1, §9.1 | TRD §5 | ADR-0005 | **Implemented** in `src/session.rs` with fail-closed transitions | | Sequence-aware item delivery evidence | PRD §3.1, §9 | TRD §5–7 | ADR-0005, ADR-0010 | **Implemented** domain primitive in `src/item_delivery.rs`; persistence/API delivery orchestration is Target | -| Idempotent response events | PRD §9.2 | TRD §6 | ADR-0005, ADR-0010 | **Implemented** in `src/response.rs` with canonical SHA-256 payload-digest identity; mid-session `response_event` persist/reload is Active PR #182 in `src/postgres_response_event.rs` / `migrations/0020_response_event.sql` and is not protected-main truth until integrated | +| Idempotent response events | PRD §9.2 | TRD §6 | ADR-0005, ADR-0010 | **Implemented** in `src/response.rs` with canonical SHA-256 payload-digest identity; mid-session `response_event` persist/reload with continue-from-restart and fail-closed gapped receipts is Active PR #182 in `src/postgres_response_event.rs` / `migrations/0020_response_event.sql` and is not protected-main truth until integrated | | Immutable response snapshot before scoring | PRD §9.3 | TRD §5–8 | ADR-0005, ADR-0010 | **Implemented** domain semantics in `src/response.rs` | | Version-pinned scoring | PRD §9.4, §10 | TRD §8 | ADR-0004, ADR-0010 | **Implemented** reusable product-side scoring dispatch contract in `src/scoring.rs` with canonical SHA-256 engine-artifact digest provenance plus `migrations/0011_scoring_request.sql` / `src/postgres_scoring_request.rs` request-identity persistence; live fast-mlsirm integration is Target | | Bounded asynchronous scoring retry/quarantine with stale-worker fencing | PRD §9.4, §10 | TRD §8; ADR-0015 transaction boundary | ADR-0004, ADR-0010, ADR-0015 | **Implemented** product lifecycle plus PostgreSQL enqueue, claim, retry, completion, expiry recovery, and cancellation without transferring a fence; live fast-mlsirm execution remains Target | @@ -134,7 +134,7 @@ Still-Target logical modules/adapters include remaining product aggregate persis **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** #182 response-event persist/reload stores each accepted answer under `response_event` so a mid-session restart can rebuild the same Korean IPIP Quick prefix before snapshot freeze. Observed time and platform receipt time stay distinct on write and reload (`ResponseEventReceipt`). Exact replay is idempotent; client-identity, sequence, evidence, inverted/zero stored time, and time rebinding fail closed under `READ COMMITTED`. Prefer this head over #174, #53, and snapshot-only #151. HTTP transport and live scoring remain outside this slice. This is not protected-main truth until an unchanged reviewed/check-clean head is integrated. +**Active PR** #182 response-event persist/reload stores each accepted answer under `response_event` so a mid-session restart can rebuild the same Korean IPIP Quick prefix, continue from the reloaded ledger, and freeze the same scoring request. Observed time and platform receipt time stay distinct on write, reload, and recovery COPY (`ResponseEventReceipt`). Exact replay is idempotent; client-identity, sequence, evidence, inverted/zero stored time, time rebinding, and gapped receipt history fail closed under `READ COMMITTED`. Neighbor sessions stay isolated by `session_ref`. Prefer this head over #174, #53, snapshot-only #151, and stale overlapping draft #201. HTTP transport and live scoring remain outside this slice. This is not protected-main truth until an unchanged reviewed/check-clean head is integrated. ## 5. ADR traceability by concern diff --git a/docs/architecture/AS_BUILT_SCHEMA.md b/docs/architecture/AS_BUILT_SCHEMA.md index b2974960..7160116b 100644 --- a/docs/architecture/AS_BUILT_SCHEMA.md +++ b/docs/architecture/AS_BUILT_SCHEMA.md @@ -60,7 +60,7 @@ The slice does **not** persist publication-event history, bound scientific evide ## Active PR response-event physical schema -PR #182 adds `migrations/0020_response_event.sql` and `src/postgres_response_event.rs` so each accepted answer is durable before snapshot freeze. The slice is **Active PR**, not protected-main truth. It stores opaque `response_event_ref` identity, session binding, client idempotency identity, item version, canonical SHA-256 payload digest, positive `server_sequence`, source-valid `observed_at`, and platform `received_at`. Exact replay is idempotent. Client-identity, sequence, evidence, and time rebinding fail closed. Reload reconstructs `ResponseLedger` and `ResponseEventReceipt` clocks in `server_sequence` order under `READ COMMITTED`. Inverted or zero stored times fail closed even if a check constraint was dropped. HTTP response transport remains outside this slice. +PR #182 adds `migrations/0020_response_event.sql` and `src/postgres_response_event.rs` so each accepted answer is durable before snapshot freeze. The slice is **Active PR**, not protected-main truth. It stores opaque `response_event_ref` identity, session binding, client idempotency identity, item version, canonical SHA-256 payload digest, positive `server_sequence`, source-valid `observed_at`, and platform `received_at`. Exact replay is idempotent. Client-identity, sequence, evidence, and time rebinding fail closed. Reload reconstructs `ResponseLedger` and `ResponseEventReceipt` clocks in contiguous `server_sequence` order `1..=n` under `READ COMMITTED`. Gapped or reordered receipt history fails closed. A restart continues from the reloaded ledger and freezes the same scoring prefix. Inverted or zero stored times fail closed even if a check constraint was dropped. Recovery COPY preserves distinct observed/received clocks. HTTP response transport remains outside this slice. ## Logical-to-physical mapping rule diff --git a/src/postgres_response_event.rs b/src/postgres_response_event.rs index ef6b5262..7fe71994 100644 --- a/src/postgres_response_event.rs +++ b/src/postgres_response_event.rs @@ -222,7 +222,7 @@ pub fn load_response_event_receipts( &[&session_ref], )?; let mut receipts = Vec::with_capacity(rows.len()); - for row in rows { + for (index, row) in rows.into_iter().enumerate() { let server_event_ref: String = row.get(0); let client_event_ref: String = row.get(1); let item_version_ref: String = row.get(2); @@ -232,6 +232,7 @@ pub fn load_response_event_receipts( let received_at: SystemTime = row.get(6); let sequence = usize::try_from(server_sequence) .map_err(|_| ResponseEventPersistenceError::InvalidSequence)?; + require_contiguous_server_sequence(index, sequence)?; let observed_at_unix_ms = unix_ms_from_system_time(observed_at)?; let received_at_unix_ms = unix_ms_from_system_time(received_at)?; if observed_at_unix_ms > received_at_unix_ms { @@ -269,10 +270,7 @@ pub fn load_response_ledger( session_ref: &str, ) -> Result { let receipts = load_response_event_receipts(transaction, session_ref)?; - let events = receipts - .into_iter() - .map(|receipt| receipt.event) - .collect(); + let events = receipts.into_iter().map(|receipt| receipt.event).collect(); ResponseLedger::from_persisted(session_ref, events).map_err(map_rebuild_error) } @@ -374,6 +372,17 @@ fn unix_ms_from_system_time(time: SystemTime) -> Result Result<(), ResponseEventPersistenceError> { + if sequence == index + 1 { + Ok(()) + } else { + Err(ResponseEventPersistenceError::InvalidSequence) + } +} + fn require_read_committed( transaction: &mut Transaction<'_>, ) -> Result<(), ResponseEventPersistenceError> { @@ -389,13 +398,14 @@ fn require_read_committed( #[cfg(test)] mod reference_guard_tests { use super::{ - map_rebuild_error, postgres_sequence, postgres_timestamptz, required_reference, - unix_ms_from_system_time, ResponseEventPersistenceError, ResponseEventReceipt, + map_rebuild_error, postgres_sequence, postgres_timestamptz, + require_contiguous_server_sequence, required_reference, unix_ms_from_system_time, + ResponseEventPersistenceError, ResponseEventReceipt, }; use crate::response::ResponseEvent; - use std::time::{Duration, UNIX_EPOCH}; use crate::response::WriteError; use crate::session::SessionState; + use std::time::{Duration, UNIX_EPOCH}; #[test] fn blank_numeric_and_overflow_sequences_fail_closed() { @@ -412,6 +422,16 @@ mod reference_guard_tests { "session_ipip_ko_quick" ); assert_eq!(postgres_sequence(1).unwrap(), 1); + assert!(require_contiguous_server_sequence(0, 1).is_ok()); + assert!(require_contiguous_server_sequence(1, 2).is_ok()); + assert!(matches!( + require_contiguous_server_sequence(1, 3), + Err(ResponseEventPersistenceError::InvalidSequence) + )); + assert!(matches!( + require_contiguous_server_sequence(0, 2), + Err(ResponseEventPersistenceError::InvalidSequence) + )); assert!(matches!( postgres_sequence(usize::MAX), Err(ResponseEventPersistenceError::InvalidSequence) diff --git a/tests/postgres_recovery_invariants.rs b/tests/postgres_recovery_invariants.rs index 313f1818..5a4f0828 100644 --- a/tests/postgres_recovery_invariants.rs +++ b/tests/postgres_recovery_invariants.rs @@ -9,6 +9,7 @@ use postgres::{error::SqlState, Client, NoTls}; use std::fs; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; const SOURCE_SCHEMA: &str = "recovery_backup_source_test"; const RESTORED_SCHEMA: &str = "recovery_backup_restored_test"; @@ -197,7 +198,8 @@ fn assert_restored_evidence(client: &mut Client) { let restored_event = client .query_one( &format!( - "SELECT session_ref, client_event_ref, payload_digest, server_sequence + "SELECT session_ref, client_event_ref, payload_digest, server_sequence, + observed_at, received_at FROM {RESTORED_SCHEMA}.response_event WHERE response_event_ref = 'response_event_recovery_alpha'" ), @@ -211,6 +213,14 @@ fn assert_restored_evidence(client: &mut Client) { ); assert_eq!(restored_event.get::<_, String>(2), DIGEST_A); assert_eq!(restored_event.get::<_, i64>(3), 1); + assert_eq!( + restored_event.get::<_, SystemTime>(4), + UNIX_EPOCH + Duration::from_secs(1_700_000_000) + ); + assert_eq!( + restored_event.get::<_, SystemTime>(5), + UNIX_EPOCH + Duration::from_millis(1_700_000_000_250) + ); } fn assert_restored_tenant_scoped_deduplication(client: &mut Client) { diff --git a/tests/postgres_response_event_persistence.rs b/tests/postgres_response_event_persistence.rs index 43e987ef..a17da296 100644 --- a/tests/postgres_response_event_persistence.rs +++ b/tests/postgres_response_event_persistence.rs @@ -7,6 +7,7 @@ use psychometrics_commons_runtime::postgres_response_event::{ ResponseEventReceipt, }; use psychometrics_commons_runtime::response::{ResponseEvent, ResponseLedger, ResponseWrite}; +use psychometrics_commons_runtime::scoring::{ScoringRequest, ScoringRequestInput}; use psychometrics_commons_runtime::session::SessionState; use std::sync::{Mutex, MutexGuard}; @@ -143,6 +144,26 @@ fn load_err(client: &mut Client, session_ref: &str) -> ResponseEventPersistenceE error } +fn load_receipts_err(client: &mut Client, session_ref: &str) -> ResponseEventPersistenceError { + let mut transaction = client.transaction().unwrap(); + let error = load_response_event_receipts(&mut transaction, session_ref).unwrap_err(); + transaction.rollback().unwrap(); + error +} + +fn scoring_input<'a>() -> ScoringRequestInput<'a> { + ScoringRequestInput { + scoring_request_ref: "scoring_request_ipip_ko_quick", + response_snapshot_ref: "response_snapshot_ipip_ko_quick", + assessment_spec_ref: "assessment_spec_ipip_bf_ko_quick", + instrument_version_ref: "instrument_version_ipip_bf_ko_quick", + scoring_version_ref: "scoring_version_ipip_mlsirm_v1", + calibration_reference: "calibration_ipip_bf_ko_quick", + norm_version_ref: Some("norm_ipip_bf_ko_quick"), + requested_output_schema_version: 1, + } +} + fn rebound_event( client_event_ref: &str, item_version_ref: &str, @@ -166,8 +187,8 @@ fn two_item_korean_path_survives_restart_and_exact_replay() { reset_response_event_table(&mut client); apply_response_event_migration(&mut client).unwrap(); - let mut live = ResponseLedger::new("session_ipip_ko_quick").unwrap(); - let first = live + let mut control = ResponseLedger::new("session_ipip_ko_quick").unwrap(); + let control_first = control .record( SessionState::Active, write( @@ -178,6 +199,36 @@ fn two_item_korean_path_survives_restart_and_exact_replay() { ), ) .unwrap(); + control + .record( + SessionState::Active, + write( + "server_event_item_02", + "client_event_item_02", + "item_version_n2_ko", + DIGEST_N2, + ), + ) + .unwrap(); + let expected_snapshot = control + .freeze_as(SessionState::Completed, "response_snapshot_ipip_ko_quick") + .unwrap(); + let expected_request = + ScoringRequest::from_snapshot(&expected_snapshot, scoring_input()).unwrap(); + + let mut first_only = ResponseLedger::new("session_ipip_ko_quick").unwrap(); + let first = first_only + .record( + SessionState::Active, + write( + "server_event_item_01", + "client_event_item_01", + "item_version_n1_ko", + DIGEST_N1, + ), + ) + .unwrap(); + assert_eq!(first, control_first); assert_eq!( persist_ok(&mut client, "session_ipip_ko_quick", &first), ResponseEventPersistenceDisposition::Inserted @@ -187,15 +238,15 @@ fn two_item_korean_path_survives_restart_and_exact_replay() { ResponseEventPersistenceDisposition::Duplicate ); - let after_first = load_ok(&mut client, "session_ipip_ko_quick"); - assert_eq!(after_first.events(), std::slice::from_ref(&first)); + let mut after_restart = load_ok(&mut client, "session_ipip_ko_quick"); + assert_eq!(after_restart.events(), std::slice::from_ref(&first)); let first_receipts = load_receipts_ok(&mut client, "session_ipip_ko_quick"); assert_eq!(first_receipts.len(), 1); assert_eq!(first_receipts[0].event(), &first); assert_eq!(first_receipts[0].observed_at_unix_ms(), OBSERVED_AT_MS); assert_eq!(first_receipts[0].received_at_unix_ms(), RECEIVED_AT_MS); - let second = live + let second = after_restart .record( SessionState::Active, write( @@ -212,10 +263,14 @@ fn two_item_korean_path_survives_restart_and_exact_replay() { ); let rebuilt = load_ok(&mut client, "session_ipip_ko_quick"); - assert_eq!(rebuilt, live); + assert_eq!(rebuilt, after_restart); + assert_eq!(rebuilt, control); let snapshot = rebuilt .freeze_as(SessionState::Completed, "response_snapshot_ipip_ko_quick") .unwrap(); + let request = ScoringRequest::from_snapshot(&snapshot, scoring_input()).unwrap(); + assert_eq!(snapshot, expected_snapshot); + assert_eq!(request, expected_request); assert_eq!(snapshot.event_count(), 2); assert_eq!(snapshot.last_sequence(), Some(2)); } @@ -232,6 +287,48 @@ fn empty_session_reload_is_an_empty_ledger() { assert_eq!(rebuilt.session_ref(), "session_ipip_ko_empty"); } +#[test] +fn reload_keeps_neighbor_session_prefixes_isolated() { + let _guard = response_event_test_guard(); + let mut client = test_client(); + reset_response_event_table(&mut client); + apply_response_event_migration(&mut client).unwrap(); + + let (_, alpha) = recorded_event( + "session_ipip_ko_alpha", + write( + "server_event_alpha_01", + "client_event_alpha_01", + "item_version_n1_ko", + DIGEST_N1, + ), + ); + let (_, beta) = recorded_event( + "session_ipip_ko_beta", + write( + "server_event_beta_01", + "client_event_beta_01", + "item_version_n1_ko", + DIGEST_N2, + ), + ); + persist_ok(&mut client, "session_ipip_ko_alpha", &alpha); + persist_ok(&mut client, "session_ipip_ko_beta", &beta); + + let loaded_alpha = load_ok(&mut client, "session_ipip_ko_alpha"); + let loaded_beta = load_ok(&mut client, "session_ipip_ko_beta"); + assert_eq!(loaded_alpha.events(), std::slice::from_ref(&alpha)); + assert_eq!(loaded_beta.events(), std::slice::from_ref(&beta)); + assert_eq!( + loaded_alpha.events()[0].server_event_ref(), + "server_event_alpha_01" + ); + assert_eq!( + loaded_beta.events()[0].server_event_ref(), + "server_event_beta_01" + ); +} + #[test] fn event_identity_rebinding_and_sequence_reuse_fail_closed() { let _guard = response_event_test_guard(); @@ -400,6 +497,10 @@ fn missing_relation_and_gapped_history_fail_closed() { load_err(&mut client, "session_ipip_ko_gap"), ResponseEventPersistenceError::InvalidSequence )); + assert!(matches!( + load_receipts_err(&mut client, "session_ipip_ko_gap"), + ResponseEventPersistenceError::InvalidSequence + )); } #[test] diff --git a/tests/response_event_persisted.rs b/tests/response_event_persisted.rs index 42eac469..84dd8ac7 100644 --- a/tests/response_event_persisted.rs +++ b/tests/response_event_persisted.rs @@ -105,6 +105,71 @@ fn reconstructed_two_item_korean_path_pins_the_same_scoring_request() { ); } +#[test] +fn restarted_korean_path_records_item_two_and_keeps_the_scoring_prefix() { + let mut control = ResponseLedger::new("session_ipip_ko_quick").unwrap(); + control + .record( + SessionState::Active, + write( + "server_event_item_01", + "client_event_item_01", + "item_version_n1_ko", + DIGEST_N1, + ), + ) + .unwrap(); + control + .record( + SessionState::Active, + write( + "server_event_item_02", + "client_event_item_02", + "item_version_n2_ko", + DIGEST_N2, + ), + ) + .unwrap(); + let expected_snapshot = control + .freeze_as(SessionState::Completed, "response_snapshot_ipip_ko_quick") + .unwrap(); + let expected_request = + ScoringRequest::from_snapshot(&expected_snapshot, scoring_input()).unwrap(); + + let mut after_restart = ResponseLedger::from_persisted( + "session_ipip_ko_quick", + vec![ResponseEvent::from_persisted( + "server_event_item_01", + "client_event_item_01", + "item_version_n1_ko", + DIGEST_N1, + 1, + ) + .unwrap()], + ) + .unwrap(); + after_restart + .record( + SessionState::Active, + write( + "server_event_item_02", + "client_event_item_02", + "item_version_n2_ko", + DIGEST_N2, + ), + ) + .unwrap(); + let rebuilt_snapshot = after_restart + .freeze_as(SessionState::Completed, "response_snapshot_ipip_ko_quick") + .unwrap(); + let rebuilt_request = + ScoringRequest::from_snapshot(&rebuilt_snapshot, scoring_input()).unwrap(); + + assert_eq!(after_restart.events(), control.events()); + assert_eq!(rebuilt_snapshot, expected_snapshot); + assert_eq!(rebuilt_request, expected_request); +} + #[test] fn persisted_event_reconstruction_fails_closed_on_identity_and_sequence() { assert!(matches!( From 4d1fbac59ccb71e8fc0d6dd07824eac719c1aa31 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:45:52 +0000 Subject: [PATCH 07/33] docs(response): name #221 as the persist/reload landing Prefer the continue-from-reload successor over #182 and the overlapping #174/#201 persist slices. HTTP response transport stays outside this head. Co-authored-by: Seongho Bae --- docs/TRACEABILITY.md | 4 ++-- docs/adr/0015-persistence-and-transaction-boundaries.md | 2 +- docs/architecture/AS_BUILT_SCHEMA.md | 2 +- docs/architecture/ERD.md | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index ad6fdf0b..79c0cfc7 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -23,7 +23,7 @@ An active PR, architecture document, conversation decision, or scheduler plan is | Anonymous core assessment | PRD §3.1, §9.1 | TRD §5, §10; UML anonymous sequence | ADR-0002, ADR-0003, ADR-0005 | Session lifecycle primitives implemented, including creation bound to one published locale-specific release; anonymous credential/HTTP flow is Target | | Pause/resume | PRD §3.1, §9.1 | TRD §5 | ADR-0005 | **Implemented** in `src/session.rs` with fail-closed transitions | | Sequence-aware item delivery evidence | PRD §3.1, §9 | TRD §5–7 | ADR-0005, ADR-0010 | **Implemented** domain primitive in `src/item_delivery.rs`; persistence/API delivery orchestration is Target | -| Idempotent response events | PRD §9.2 | TRD §6 | ADR-0005, ADR-0010 | **Implemented** in `src/response.rs` with canonical SHA-256 payload-digest identity; mid-session `response_event` persist/reload with continue-from-restart and fail-closed gapped receipts is Active PR #182 in `src/postgres_response_event.rs` / `migrations/0020_response_event.sql` and is not protected-main truth until integrated | +| Idempotent response events | PRD §9.2 | TRD §6 | ADR-0005, ADR-0010 | **Implemented** in `src/response.rs` with canonical SHA-256 payload-digest identity; mid-session `response_event` persist/reload with continue-from-restart and fail-closed gapped receipts is Active PR #221 in `src/postgres_response_event.rs` / `migrations/0020_response_event.sql` and is not protected-main truth until integrated | | Immutable response snapshot before scoring | PRD §9.3 | TRD §5–8 | ADR-0005, ADR-0010 | **Implemented** domain semantics in `src/response.rs` | | Version-pinned scoring | PRD §9.4, §10 | TRD §8 | ADR-0004, ADR-0010 | **Implemented** reusable product-side scoring dispatch contract in `src/scoring.rs` with canonical SHA-256 engine-artifact digest provenance plus `migrations/0011_scoring_request.sql` / `src/postgres_scoring_request.rs` request-identity persistence; live fast-mlsirm integration is Target | | Bounded asynchronous scoring retry/quarantine with stale-worker fencing | PRD §9.4, §10 | TRD §8; ADR-0015 transaction boundary | ADR-0004, ADR-0010, ADR-0015 | **Implemented** product lifecycle plus PostgreSQL enqueue, claim, retry, completion, expiry recovery, and cancellation without transferring a fence; live fast-mlsirm execution remains Target | @@ -134,7 +134,7 @@ Still-Target logical modules/adapters include remaining product aggregate persis **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** #182 response-event persist/reload stores each accepted answer under `response_event` so a mid-session restart can rebuild the same Korean IPIP Quick prefix, continue from the reloaded ledger, and freeze the same scoring request. Observed time and platform receipt time stay distinct on write, reload, and recovery COPY (`ResponseEventReceipt`). Exact replay is idempotent; client-identity, sequence, evidence, inverted/zero stored time, time rebinding, and gapped receipt history fail closed under `READ COMMITTED`. Neighbor sessions stay isolated by `session_ref`. Prefer this head over #174, #53, snapshot-only #151, and stale overlapping draft #201. HTTP transport and live scoring remain outside this slice. This is not protected-main truth until an unchanged reviewed/check-clean head is integrated. +**Active PR** #221 response-event persist/reload stores each accepted answer under `response_event` so a mid-session restart can rebuild the same Korean IPIP Quick prefix, continue from the reloaded ledger, and freeze the same scoring request. Observed time and platform receipt time stay distinct on write, reload, and recovery COPY (`ResponseEventReceipt`). Exact replay is idempotent; client-identity, sequence, evidence, inverted/zero stored time, time rebinding, and gapped receipt history fail closed under `READ COMMITTED`. Neighbor sessions stay isolated by `session_ref`. Prefer this head over #182, #174, #53, snapshot-only #151, and stale overlapping draft #201. HTTP transport and live scoring remain outside this slice. This is not protected-main truth until an unchanged reviewed/check-clean head is integrated. ## 5. ADR traceability by concern diff --git a/docs/adr/0015-persistence-and-transaction-boundaries.md b/docs/adr/0015-persistence-and-transaction-boundaries.md index e9e85cbb..566a0eba 100644 --- a/docs/adr/0015-persistence-and-transaction-boundaries.md +++ b/docs/adr/0015-persistence-and-transaction-boundaries.md @@ -6,7 +6,7 @@ - Scope: Psychometrics Commons-owned durable state, local transactions, migration boundaries, outbox/inbox integration - Supersedes: none - Superseded by: none -- Current/as-built status: protected main contains domain lifecycle primitives plus bounded PostgreSQL slices including completed `response_snapshot` persist; mid-session `response_event` persist/reload with distinct observed/received clocks is Active PR #182 work in `migrations/0020_response_event.sql` and is not protected-main truth until merged +- Current/as-built status: protected main contains domain lifecycle primitives plus bounded PostgreSQL slices including completed `response_snapshot` persist; mid-session `response_event` persist/reload with distinct observed/received clocks is Active PR #221 work in `migrations/0020_response_event.sql` and is not protected-main truth until merged - Target status: upstream PostgreSQL 18.x operational persistence with real-database concurrency/crash/recovery evidence and transactional outbox/inbox semantics - Migration status: active PR #24 introduces only the bounded integration-evidence slice; the remaining product schema still must be established from the logical ERD and this ADR without synthetic provenance backfills diff --git a/docs/architecture/AS_BUILT_SCHEMA.md b/docs/architecture/AS_BUILT_SCHEMA.md index 7160116b..64ceff71 100644 --- a/docs/architecture/AS_BUILT_SCHEMA.md +++ b/docs/architecture/AS_BUILT_SCHEMA.md @@ -60,7 +60,7 @@ The slice does **not** persist publication-event history, bound scientific evide ## Active PR response-event physical schema -PR #182 adds `migrations/0020_response_event.sql` and `src/postgres_response_event.rs` so each accepted answer is durable before snapshot freeze. The slice is **Active PR**, not protected-main truth. It stores opaque `response_event_ref` identity, session binding, client idempotency identity, item version, canonical SHA-256 payload digest, positive `server_sequence`, source-valid `observed_at`, and platform `received_at`. Exact replay is idempotent. Client-identity, sequence, evidence, and time rebinding fail closed. Reload reconstructs `ResponseLedger` and `ResponseEventReceipt` clocks in contiguous `server_sequence` order `1..=n` under `READ COMMITTED`. Gapped or reordered receipt history fails closed. A restart continues from the reloaded ledger and freezes the same scoring prefix. Inverted or zero stored times fail closed even if a check constraint was dropped. Recovery COPY preserves distinct observed/received clocks. HTTP response transport remains outside this slice. +PR #221 adds `migrations/0020_response_event.sql` and `src/postgres_response_event.rs` so each accepted answer is durable before snapshot freeze. The slice is **Active PR**, not protected-main truth. It stores opaque `response_event_ref` identity, session binding, client idempotency identity, item version, canonical SHA-256 payload digest, positive `server_sequence`, source-valid `observed_at`, and platform `received_at`. Exact replay is idempotent. Client-identity, sequence, evidence, and time rebinding fail closed. Reload reconstructs `ResponseLedger` and `ResponseEventReceipt` clocks in contiguous `server_sequence` order `1..=n` under `READ COMMITTED`. Gapped or reordered receipt history fails closed. A restart continues from the reloaded ledger and freezes the same scoring prefix. Inverted or zero stored times fail closed even if a check constraint was dropped. Recovery COPY preserves distinct observed/received clocks. HTTP response transport remains outside this slice. ## Logical-to-physical mapping rule diff --git a/docs/architecture/ERD.md b/docs/architecture/ERD.md index f7d93011..2d8bc262 100644 --- a/docs/architecture/ERD.md +++ b/docs/architecture/ERD.md @@ -425,7 +425,7 @@ The target ERD deliberately includes several logical entities that are not yet p - `instrument_release` is the locale-specific publication identity already owned by `src/instrument.rs`. Physical `migrations/0006_instrument_release.sql` persists that one-row aggregate (immutable manifest columns plus `publication_state`); HTTP publication transport remains Target. - `data_rights_request` and `data_rights_propagation_state` are the first durable export/deletion slice. Physical `migrations/0003_data_rights_propagation.sql` stores requested-state identity plus one local outbox event per dependent system; verification, processing, completion, and dependent-system execution remain Target. - `item_delivery_event` reflects the already-merged `src/item_delivery.rs` domain primitive; durable persistence/API orchestration is still Target. -- `response_event` is the accepted mid-session ledger. Physical `migrations/0020_response_event.sql` and `src/postgres_response_event.rs` persist/reload that prefix plus distinct observed/received clocks on Active PR #182; HTTP response transport remains Target. +- `response_event` is the accepted mid-session ledger. Physical `migrations/0020_response_event.sql` and `src/postgres_response_event.rs` persist/reload that prefix plus distinct observed/received clocks on Active PR #221; HTTP response transport remains Target. - `consent_ledger` and `consent_event` persist the already-merged `src/consent.rs` append-only ledger. Physical persistence is carried by Active PR #49 (`migrations/0005_consent_lifecycle.sql`); HTTP consent transport and derived snapshot tables remain Target. - `participant_identity_link` is the persistence target accepted by ADR-0020. The current `src/participant.rs` `keyverse_subject_ref` field is an application-domain first-link projection, not the future mutable persistence source of truth. - `longitudinal_enrollment`, `longitudinal_observation_record`, and `temporal_analysis_submission` make the ADR-0008 Commons-owned Gyeot/TEPP orchestration boundary explicit. No TEPP analytical kernel is duplicated here. From 7de134b414034ad849af85ac66558d0ac11a8f17 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:46:30 +0000 Subject: [PATCH 08/33] fix(response): drop untestable timestamptz overflow arm Every u64 millisecond offset is representable as SystemTime on the supported 64-bit hosts. Keep the zero-time fail-closed path and prove u64::MAX converts so the exact branch-coverage gate stays closed. Co-authored-by: Seongho Bae --- src/postgres_response_event.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/postgres_response_event.rs b/src/postgres_response_event.rs index 7fe71994..02c3cb82 100644 --- a/src/postgres_response_event.rs +++ b/src/postgres_response_event.rs @@ -352,9 +352,10 @@ fn postgres_timestamptz(unix_ms: u64) -> Result Result { @@ -441,6 +442,10 @@ mod reference_guard_tests { Err(ResponseEventPersistenceError::InvalidTimestamp) )); assert!(postgres_timestamptz(1_700_000_000_000).is_ok()); + assert_eq!( + postgres_timestamptz(u64::MAX).unwrap(), + UNIX_EPOCH + Duration::from_millis(u64::MAX) + ); assert!(matches!( unix_ms_from_system_time(UNIX_EPOCH), Err(ResponseEventPersistenceError::InvalidTimestamp) From 18cdc05910cf6bc77fd23e52ffab63d935431830 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 02:29:20 +0900 Subject: [PATCH 09/33] test(recovery): preserve inbox claim deadline evidence Processing consumption rows now require claim_deadline_at after #81. Seed the wall-clock deadline and prove binary restore preserves it. --- tests/postgres_recovery_invariants.rs | 30 ++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/tests/postgres_recovery_invariants.rs b/tests/postgres_recovery_invariants.rs index 5a4f0828..61378d0d 100644 --- a/tests/postgres_recovery_invariants.rs +++ b/tests/postgres_recovery_invariants.rs @@ -85,11 +85,11 @@ fn seed_recovery_critical_state(client: &mut Client) { INSERT INTO {SOURCE_SCHEMA}.integration_consumption ( consumer_ref, source_ref, tenant_ref, source_event_ref, consumption_ref, side_effect_ref, consumption_state, fencing_token, latest_event_at_unix_ms, - claim_expires_at_unix_ms, completion_evidence_ref, cause_code + claim_expires_at_unix_ms, claim_deadline_at, completion_evidence_ref, cause_code ) VALUES ( 'consumer_recovery_alpha', 'dependency_recovery_alpha', 'tenant_recovery_alpha', 'event_dependency_alpha', 'consumption_recovery_alpha', 'effect_recovery_alpha', - 'processing', 7, 12000, 13000, NULL, NULL + 'processing', 7, 12000, 13000, clock_timestamp() + INTERVAL '1 hour', NULL, NULL ); INSERT INTO {SOURCE_SCHEMA}.response_snapshot ( snapshot_ref, session_ref, event_count, last_sequence @@ -161,7 +161,8 @@ fn assert_restored_evidence(client: &mut Client) { let restored_consumption = client .query_one( &format!( - "SELECT consumption_state, fencing_token, claim_expires_at_unix_ms + "SELECT consumption_state, fencing_token, claim_expires_at_unix_ms, + claim_deadline_at IS NOT NULL FROM {RESTORED_SCHEMA}.integration_consumption WHERE consumption_ref = 'consumption_recovery_alpha'" ), @@ -171,6 +172,29 @@ fn assert_restored_evidence(client: &mut Client) { assert_eq!(restored_consumption.get::<_, String>(0), "processing"); assert_eq!(restored_consumption.get::<_, i64>(1), 7); assert_eq!(restored_consumption.get::<_, Option>(2), Some(13000)); + assert!(restored_consumption.get::<_, bool>(3)); + + let claim_deadline_matches_source: bool = client + .query_one( + &format!( + "SELECT source_row.claim_deadline_at = restored_row.claim_deadline_at + FROM {SOURCE_SCHEMA}.integration_consumption AS source_row + JOIN {RESTORED_SCHEMA}.integration_consumption AS restored_row + ON restored_row.consumer_ref = source_row.consumer_ref + AND restored_row.source_ref = source_row.source_ref + AND restored_row.tenant_ref = source_row.tenant_ref + AND restored_row.source_event_ref = source_row.source_event_ref + AND restored_row.consumption_ref = source_row.consumption_ref + WHERE source_row.consumption_ref = 'consumption_recovery_alpha'" + ), + &[], + ) + .expect("restored claim-deadline evidence should remain comparable") + .get(0); + assert!( + claim_deadline_matches_source, + "restore must preserve the exact database-authoritative processing claim deadline" + ); let restored_snapshot = client .query_one( From 27e793fad51a727c1f913cf017fe653e5e8c1c63 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 04:19:22 +0900 Subject: [PATCH 10/33] test(response): reject conflicting receipt history before return --- ...ostgres_response_event_receipt_conflict.rs | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 tests/postgres_response_event_receipt_conflict.rs diff --git a/tests/postgres_response_event_receipt_conflict.rs b/tests/postgres_response_event_receipt_conflict.rs new file mode 100644 index 00000000..34c9d2b9 --- /dev/null +++ b/tests/postgres_response_event_receipt_conflict.rs @@ -0,0 +1,64 @@ +//! Real PostgreSQL regression for conflicting stored receipt identity history. + +use postgres::{Client, NoTls}; +use psychometrics_commons_runtime::postgres_response_event::{ + apply_response_event_migration, load_response_event_receipts, ResponseEventPersistenceError, +}; + +const DIGEST_ONE: &str = + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const DIGEST_TWO: &str = + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +fn test_client() -> Client { + let connection = std::env::var("TEST_DATABASE_URL") + .expect("TEST_DATABASE_URL must identify the isolated CI PostgreSQL database"); + let mut client = Client::connect(&connection, NoTls) + .expect("isolated CI PostgreSQL database must be reachable"); + client + .batch_execute( + "CREATE SCHEMA IF NOT EXISTS response_event_receipt_conflict_test;\ + SET search_path TO response_event_receipt_conflict_test;\ + DROP TABLE IF EXISTS response_event;", + ) + .unwrap(); + client +} + +#[test] +fn receipt_reload_rejects_contiguous_history_with_duplicate_client_identity() { + let mut client = test_client(); + apply_response_event_migration(&mut client).unwrap(); + client + .batch_execute( + "ALTER TABLE response_event DROP CONSTRAINT response_event_session_client_unique;", + ) + .unwrap(); + client + .execute( + "INSERT INTO response_event (\ + response_event_ref, session_ref, client_event_ref, item_version_ref, \ + payload_digest, server_sequence, observed_at, received_at\ + ) VALUES (\ + 'server_event_item_one', 'session_ipip_ko_conflict', 'client_event_reused', \ + 'item_version_n_one_ko', $1, 1, TIMESTAMPTZ '2023-11-14 22:13:20+00', \ + TIMESTAMPTZ '2023-11-14 22:13:20.250+00'\ + ), (\ + 'server_event_item_two', 'session_ipip_ko_conflict', 'client_event_reused', \ + 'item_version_n_two_ko', $2, 2, TIMESTAMPTZ '2023-11-14 22:13:21+00', \ + TIMESTAMPTZ '2023-11-14 22:13:21.250+00'\ + )", + &[&DIGEST_ONE, &DIGEST_TWO], + ) + .unwrap(); + + let mut transaction = client.transaction().unwrap(); + let error = load_response_event_receipts(&mut transaction, "session_ipip_ko_conflict") + .expect_err("conflicting stored client identity must fail closed before receipts return"); + transaction.rollback().unwrap(); + + assert!(matches!( + error, + ResponseEventPersistenceError::ConflictingReplay + )); +} From 8074987583796551d9097adf5be68e2dc5deded9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 04:19:43 +0900 Subject: [PATCH 11/33] fix(response): validate receipt ledger before return Rebuild the persisted response ledger before exposing receipt clocks so contiguous sequence history with conflicting client/server identities fails closed for direct scoring or TEPP callers. --- src/postgres_response_event.rs | 113 +++++++++++++++++++++++---------- 1 file changed, 80 insertions(+), 33 deletions(-) diff --git a/src/postgres_response_event.rs b/src/postgres_response_event.rs index 02c3cb82..a0cabe7c 100644 --- a/src/postgres_response_event.rs +++ b/src/postgres_response_event.rs @@ -200,8 +200,9 @@ pub fn persist_response_event( /// Rebuild accepted events and their observed/received clocks after restart. /// /// Rows are read in `server_sequence` order. A missing session returns an empty -/// list. Gapped, reordered, conflicting, inverted, or zero stored times fail -/// closed. Observed time stays distinct from platform receipt time. +/// list. Gapped, reordered, conflicting identities, inverted, or zero stored +/// times fail closed before any receipt is returned. Observed time stays +/// distinct from platform receipt time. /// /// # Errors /// @@ -222,7 +223,7 @@ pub fn load_response_event_receipts( &[&session_ref], )?; let mut receipts = Vec::with_capacity(rows.len()); - for (index, row) in rows.into_iter().enumerate() { + for row in rows { let server_event_ref: String = row.get(0); let client_event_ref: String = row.get(1); let item_version_ref: String = row.get(2); @@ -232,7 +233,6 @@ pub fn load_response_event_receipts( let received_at: SystemTime = row.get(6); let sequence = usize::try_from(server_sequence) .map_err(|_| ResponseEventPersistenceError::InvalidSequence)?; - require_contiguous_server_sequence(index, sequence)?; let observed_at_unix_ms = unix_ms_from_system_time(observed_at)?; let received_at_unix_ms = unix_ms_from_system_time(received_at)?; if observed_at_unix_ms > received_at_unix_ms { @@ -252,6 +252,7 @@ pub fn load_response_event_receipts( received_at_unix_ms, }); } + require_contiguous_receipt_history(session_ref, &receipts)?; Ok(receipts) } @@ -325,6 +326,18 @@ fn classify_unique_violation(error: postgres::Error) -> ResponseEventPersistence } } +fn require_contiguous_receipt_history( + session_ref: &str, + receipts: &[ResponseEventReceipt], +) -> Result<(), ResponseEventPersistenceError> { + let events = receipts + .iter() + .map(|receipt| receipt.event.clone()) + .collect(); + ResponseLedger::from_persisted(session_ref, events).map_err(map_rebuild_error)?; + Ok(()) +} + fn map_rebuild_error(error: WriteError) -> ResponseEventPersistenceError { match error { WriteError::InvalidReference => ResponseEventPersistenceError::InvalidReference, @@ -352,9 +365,6 @@ fn postgres_timestamptz(unix_ms: u64) -> Result Result Result<(), ResponseEventPersistenceError> { - if sequence == index + 1 { - Ok(()) - } else { - Err(ResponseEventPersistenceError::InvalidSequence) - } -} - fn require_read_committed( transaction: &mut Transaction<'_>, ) -> Result<(), ResponseEventPersistenceError> { @@ -400,7 +399,7 @@ fn require_read_committed( mod reference_guard_tests { use super::{ map_rebuild_error, postgres_sequence, postgres_timestamptz, - require_contiguous_server_sequence, required_reference, unix_ms_from_system_time, + require_contiguous_receipt_history, required_reference, unix_ms_from_system_time, ResponseEventPersistenceError, ResponseEventReceipt, }; use crate::response::ResponseEvent; @@ -423,16 +422,6 @@ mod reference_guard_tests { "session_ipip_ko_quick" ); assert_eq!(postgres_sequence(1).unwrap(), 1); - assert!(require_contiguous_server_sequence(0, 1).is_ok()); - assert!(require_contiguous_server_sequence(1, 2).is_ok()); - assert!(matches!( - require_contiguous_server_sequence(1, 3), - Err(ResponseEventPersistenceError::InvalidSequence) - )); - assert!(matches!( - require_contiguous_server_sequence(0, 2), - Err(ResponseEventPersistenceError::InvalidSequence) - )); assert!(matches!( postgres_sequence(usize::MAX), Err(ResponseEventPersistenceError::InvalidSequence) @@ -442,10 +431,6 @@ mod reference_guard_tests { Err(ResponseEventPersistenceError::InvalidTimestamp) )); assert!(postgres_timestamptz(1_700_000_000_000).is_ok()); - assert_eq!( - postgres_timestamptz(u64::MAX).unwrap(), - UNIX_EPOCH + Duration::from_millis(u64::MAX) - ); assert!(matches!( unix_ms_from_system_time(UNIX_EPOCH), Err(ResponseEventPersistenceError::InvalidTimestamp) @@ -477,6 +462,68 @@ mod reference_guard_tests { } } + #[test] + fn gapped_or_duplicate_receipt_history_fails_closed() { + let first = ResponseEvent::from_persisted( + "server_event_item_01", + "client_event_item_01", + "item_version_n1_ko", + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + 1, + ) + .unwrap(); + let gapped = ResponseEvent::from_persisted( + "server_event_item_03", + "client_event_item_03", + "item_version_n3_ko", + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + 3, + ) + .unwrap(); + let duplicate_server = ResponseEvent::from_persisted( + "server_event_item_01", + "client_event_item_02", + "item_version_n2_ko", + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + 2, + ) + .unwrap(); + let first_receipt = ResponseEventReceipt { + event: first.clone(), + observed_at_unix_ms: 1_700_000_000_000, + received_at_unix_ms: 1_700_000_000_250, + }; + assert!(matches!( + require_contiguous_receipt_history( + "session_ipip_ko_quick", + &[ + first_receipt.clone(), + ResponseEventReceipt { + event: gapped, + observed_at_unix_ms: 1_700_000_000_500, + received_at_unix_ms: 1_700_000_000_750, + }, + ] + ), + Err(ResponseEventPersistenceError::InvalidSequence) + )); + assert!(matches!( + require_contiguous_receipt_history( + "session_ipip_ko_quick", + &[ + first_receipt, + ResponseEventReceipt { + event: duplicate_server, + observed_at_unix_ms: 1_700_000_000_500, + received_at_unix_ms: 1_700_000_000_750, + }, + ] + ), + Err(ResponseEventPersistenceError::ConflictingReplay) + )); + assert!(require_contiguous_receipt_history("session_ipip_ko_quick", &[]).is_ok()); + } + #[test] fn receipt_keeps_distinct_observed_and_received_times() { let event = ResponseEvent::from_persisted( From ba6d8aa3bb30fdf178b8db12dad0013ccc642585 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 06:24:07 +0900 Subject: [PATCH 12/33] test(response): reject write-time server sequence gaps --- tests/postgres_response_event_sequence_gap.rs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 tests/postgres_response_event_sequence_gap.rs diff --git a/tests/postgres_response_event_sequence_gap.rs b/tests/postgres_response_event_sequence_gap.rs new file mode 100644 index 00000000..1ee129e0 --- /dev/null +++ b/tests/postgres_response_event_sequence_gap.rs @@ -0,0 +1,57 @@ +//! Regression contract: response-event persistence rejects a non-contiguous server sequence. + +use postgres::{Client, NoTls}; +use psychometrics_commons_runtime::postgres_response_event::{ + apply_response_event_migration, persist_response_event, ResponseEventPersistenceError, +}; +use psychometrics_commons_runtime::response::ResponseEvent; + +const OBSERVED_AT_MS: u64 = 1_700_000_000_000; +const RECEIVED_AT_MS: u64 = 1_700_000_000_250; + +fn test_client() -> Client { + let connection = std::env::var("TEST_DATABASE_URL") + .expect("TEST_DATABASE_URL must identify the isolated CI PostgreSQL database"); + let mut client = Client::connect(&connection, NoTls) + .expect("isolated CI PostgreSQL database must be reachable"); + client + .batch_execute( + "CREATE SCHEMA IF NOT EXISTS response_event_sequence_gap_test;\ + SET search_path TO response_event_sequence_gap_test;\ + DROP TABLE IF EXISTS response_event;", + ) + .unwrap(); + apply_response_event_migration(&mut client).unwrap(); + client +} + +#[test] +fn persist_rejects_a_server_sequence_gap_before_commit() { + let mut client = test_client(); + let gapped = ResponseEvent::from_persisted( + "server_event_item_02", + "client_event_item_02", + "item_version_n2_ko", + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + 2, + ) + .unwrap(); + + let mut transaction = client.transaction().unwrap(); + let error = persist_response_event( + &mut transaction, + "session_ipip_ko_sequence_gap", + &gapped, + OBSERVED_AT_MS, + RECEIVED_AT_MS, + ) + .unwrap_err(); + + assert!(matches!(error, ResponseEventPersistenceError::InvalidSequence)); + let persisted_rows: i64 = transaction + .query_one("SELECT COUNT(*) FROM response_event", &[]) + .unwrap() + .get(0); + assert_eq!(persisted_rows, 0, "a rejected gap must leave no durable row"); + transaction.rollback().unwrap(); +} From 3d8cbec90c2d0a4ab05e50b36d765d1abdb76de2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 07:40:06 +0900 Subject: [PATCH 13/33] test(response): isolate unexpected unique-constraint fail-closed Record the second event on the same ledger so only the extra session unique index fires, not the known sequence unique. rustfmt the receipt-conflict and sequence-gap tests so Runtime CI can proceed past fmt. --- tests/postgres_response_event_persistence.rs | 41 +++++++++++-------- ...ostgres_response_event_receipt_conflict.rs | 6 +-- tests/postgres_response_event_sequence_gap.rs | 10 ++++- 3 files changed, 33 insertions(+), 24 deletions(-) diff --git a/tests/postgres_response_event_persistence.rs b/tests/postgres_response_event_persistence.rs index a17da296..9247d016 100644 --- a/tests/postgres_response_event_persistence.rs +++ b/tests/postgres_response_event_persistence.rs @@ -542,30 +542,35 @@ fn unexpected_unique_constraint_and_negative_sequence_fail_closed() { reset_response_event_table(&mut client); apply_response_event_migration(&mut client).unwrap(); - let (_, first) = recorded_event( - "session_ipip_ko_extra", - write( - "server_event_item_01", - "client_event_item_01", - "item_version_n1_ko", - DIGEST_N1, - ), - ); + let mut ledger = ResponseLedger::new("session_ipip_ko_extra").unwrap(); + let first = ledger + .record( + SessionState::Active, + write( + "server_event_item_01", + "client_event_item_01", + "item_version_n1_ko", + DIGEST_N1, + ), + ) + .unwrap(); persist_ok(&mut client, "session_ipip_ko_extra", &first); client .batch_execute( "CREATE UNIQUE INDEX response_event_session_only_unique ON response_event (session_ref);", ) .unwrap(); - let (_, second) = recorded_event( - "session_ipip_ko_extra", - write( - "server_event_item_02", - "client_event_item_02", - "item_version_n2_ko", - DIGEST_N2, - ), - ); + let second = ledger + .record( + SessionState::Active, + write( + "server_event_item_02", + "client_event_item_02", + "item_version_n2_ko", + DIGEST_N2, + ), + ) + .unwrap(); assert!(matches!( persist_err(&mut client, "session_ipip_ko_extra", &second), ResponseEventPersistenceError::Database(_) diff --git a/tests/postgres_response_event_receipt_conflict.rs b/tests/postgres_response_event_receipt_conflict.rs index 34c9d2b9..d8049c81 100644 --- a/tests/postgres_response_event_receipt_conflict.rs +++ b/tests/postgres_response_event_receipt_conflict.rs @@ -5,10 +5,8 @@ use psychometrics_commons_runtime::postgres_response_event::{ apply_response_event_migration, load_response_event_receipts, ResponseEventPersistenceError, }; -const DIGEST_ONE: &str = - "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; -const DIGEST_TWO: &str = - "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const DIGEST_ONE: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const DIGEST_TWO: &str = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; fn test_client() -> Client { let connection = std::env::var("TEST_DATABASE_URL") diff --git a/tests/postgres_response_event_sequence_gap.rs b/tests/postgres_response_event_sequence_gap.rs index 1ee129e0..e396fe00 100644 --- a/tests/postgres_response_event_sequence_gap.rs +++ b/tests/postgres_response_event_sequence_gap.rs @@ -47,11 +47,17 @@ fn persist_rejects_a_server_sequence_gap_before_commit() { ) .unwrap_err(); - assert!(matches!(error, ResponseEventPersistenceError::InvalidSequence)); + assert!(matches!( + error, + ResponseEventPersistenceError::InvalidSequence + )); let persisted_rows: i64 = transaction .query_one("SELECT COUNT(*) FROM response_event", &[]) .unwrap() .get(0); - assert_eq!(persisted_rows, 0, "a rejected gap must leave no durable row"); + assert_eq!( + persisted_rows, 0, + "a rejected gap must leave no durable row" + ); transaction.rollback().unwrap(); } From 5a11276ac45b2ac609625d54f4bbeda2f0ae83e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 09:12:46 +0900 Subject: [PATCH 14/33] style(response): backtick PostgreSQL in receipt-conflict rustdoc --- tests/postgres_response_event_receipt_conflict.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/postgres_response_event_receipt_conflict.rs b/tests/postgres_response_event_receipt_conflict.rs index d8049c81..d307317d 100644 --- a/tests/postgres_response_event_receipt_conflict.rs +++ b/tests/postgres_response_event_receipt_conflict.rs @@ -1,4 +1,4 @@ -//! Real PostgreSQL regression for conflicting stored receipt identity history. +//! Real `PostgreSQL` regression for conflicting stored receipt identity history. use postgres::{Client, NoTls}; use psychometrics_commons_runtime::postgres_response_event::{ From a2bf4076e4e7a8226000a59f06c0f0534ff0be77 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 09:20:11 +0900 Subject: [PATCH 15/33] fix(response): reject write-time server sequence gaps before insert Persist of sequence 2 into an empty session ledger returned Inserted. The next contiguous sequence is now MAX(server_sequence)+1, or 1 when the session has no rows, so a gap cannot become a durable prefix. --- src/postgres_response_event.rs | 21 +++++++++++++++++-- tests/postgres_response_event_sequence_gap.rs | 1 + 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/postgres_response_event.rs b/src/postgres_response_event.rs index a0cabe7c..96212759 100644 --- a/src/postgres_response_event.rs +++ b/src/postgres_response_event.rs @@ -142,8 +142,8 @@ pub fn apply_response_event_migration( /// # Errors /// /// Returns [`ResponseEventPersistenceError`] for an unbound identity, -/// unsupported isolation, conflicting replay, a sequence conflict, an invalid -/// sequence or timestamp, or a database failure. +/// unsupported isolation, conflicting replay, a sequence conflict, a gapped +/// or out-of-range sequence, an invalid timestamp, or a database failure. pub fn persist_response_event( transaction: &mut Transaction<'_>, session_ref: &str, @@ -162,6 +162,10 @@ pub fn persist_response_event( if observed_at_unix_ms > received_at_unix_ms { return Err(ResponseEventPersistenceError::InvalidTimestamp); } + let next_sequence = next_contiguous_sequence(transaction, session_ref)?; + if server_sequence > next_sequence { + return Err(ResponseEventPersistenceError::InvalidSequence); + } let inserted = match transaction.execute( "INSERT INTO response_event (\ @@ -361,6 +365,19 @@ fn postgres_sequence(value: usize) -> Result i64::try_from(value).map_err(|_| ResponseEventPersistenceError::InvalidSequence) } +fn next_contiguous_sequence( + transaction: &mut Transaction<'_>, + session_ref: &str, +) -> Result { + let highest: Option = transaction + .query_one( + "SELECT MAX(server_sequence) FROM response_event WHERE session_ref = $1", + &[&session_ref], + )? + .get(0); + Ok(highest.map_or(1, |value| value.saturating_add(1))) +} + fn postgres_timestamptz(unix_ms: u64) -> Result { if unix_ms == 0 { return Err(ResponseEventPersistenceError::InvalidTimestamp); diff --git a/tests/postgres_response_event_sequence_gap.rs b/tests/postgres_response_event_sequence_gap.rs index e396fe00..08cb3144 100644 --- a/tests/postgres_response_event_sequence_gap.rs +++ b/tests/postgres_response_event_sequence_gap.rs @@ -59,5 +59,6 @@ fn persist_rejects_a_server_sequence_gap_before_commit() { persisted_rows, 0, "a rejected gap must leave no durable row" ); + assert!(error.to_string().contains("gapped"), "{error}"); transaction.rollback().unwrap(); } From 1bc6148cda5652523183418a37858f875a918411 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 11:26:17 +0900 Subject: [PATCH 16/33] ci(supply-chain): restore protected-main SBOM contracts --- .github/workflows/sbom-evidence.yml | 79 ++++++++++++++++ scripts/validate_spdx_sbom.py | 116 ++++++++++++++++++++++++ tests/test_sbom_evidence.py | 134 ++++++++++++++++++++++++++++ tests/test_validate_spdx_sbom.py | 111 +++++++++++++++++++++++ 4 files changed, 440 insertions(+) create mode 100644 .github/workflows/sbom-evidence.yml create mode 100644 scripts/validate_spdx_sbom.py create mode 100644 tests/test_sbom_evidence.py create mode 100644 tests/test_validate_spdx_sbom.py diff --git a/.github/workflows/sbom-evidence.yml b/.github/workflows/sbom-evidence.yml new file mode 100644 index 00000000..7e08a3b5 --- /dev/null +++ b/.github/workflows/sbom-evidence.yml @@ -0,0 +1,79 @@ +name: SPDX SBOM evidence + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: psychometrics-commons-sbom-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + generate: + name: Generate and validate locked SPDX SBOM + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Checkout exact head + uses: actions/checkout@631c942040754b6e095e929c1677c07e10ed4f87 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - name: Verify SBOM workflow and validator contracts + run: | + python3 tests/test_sbom_evidence.py + python3 tests/test_validate_spdx_sbom.py + - name: Generate local SPDX JSON + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 + with: + path: . + format: spdx-json + output-file: sbom.spdx.json + syft-version: v1.51.0 + dependency-snapshot: false + upload-artifact: false + upload-release-assets: false + - name: Validate locked dependency coverage + run: python3 scripts/validate_spdx_sbom.py sbom.spdx.json Cargo.lock + - name: Record SBOM checksum + run: sha256sum sbom.spdx.json > sbom.spdx.json.sha256 + - name: Preserve exact SBOM evidence + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: sbom-spdx-${{ github.event.pull_request.head.sha || github.sha }} + path: | + sbom.spdx.json + sbom.spdx.json.sha256 + if-no-files-found: error + retention-days: 14 + + verify-evidence: + name: Reverify preserved SPDX evidence + needs: generate + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Checkout exact head for locked dependency evidence + uses: actions/checkout@631c942040754b6e095e929c1677c07e10ed4f87 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - name: Download exact SBOM evidence + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: sbom-spdx-${{ github.event.pull_request.head.sha || github.sha }} + path: evidence + - name: Verify checksum after artifact handoff + working-directory: evidence + run: sha256sum --check sbom.spdx.json.sha256 + - name: Reverify locked dependency coverage after artifact handoff + run: python3 scripts/validate_spdx_sbom.py evidence/sbom.spdx.json Cargo.lock diff --git a/scripts/validate_spdx_sbom.py b/scripts/validate_spdx_sbom.py new file mode 100644 index 00000000..fe678274 --- /dev/null +++ b/scripts/validate_spdx_sbom.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Validate generated SPDX JSON against the repository's locked Rust dependencies. + +The check is intentionally small and deterministic. It does not try to replace an SPDX schema +validator or a vulnerability scanner. It proves that the generated file is structurally recognizable +as a supported SPDX 2.x JSON document and that every registry/git dependency pinned by +``Cargo.lock`` is represented by name and version before the SBOM is retained as build evidence. +""" + +from __future__ import annotations + +import json +from pathlib import Path +import sys +import tomllib +from typing import Any + + +SUPPORTED_SPDX_VERSIONS = ("SPDX-2.0", "SPDX-2.1", "SPDX-2.2", "SPDX-2.3") + + +class SbomValidationError(ValueError): + """Raised when generated SBOM evidence is missing or inconsistent with Cargo.lock.""" + + +def _load_json(path: Path) -> dict[str, Any]: + """Load one JSON object, rejecting non-object roots with an operator-readable error.""" + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise SbomValidationError(f"cannot read SPDX JSON from {path}: {error}") from error + if not isinstance(payload, dict): + raise SbomValidationError("SPDX JSON root must be an object") + return payload + + +def _locked_external_packages(path: Path) -> set[tuple[str, str]]: + """Return third-party name/version identities pinned by Cargo.lock.""" + try: + cargo_lock = tomllib.loads(path.read_text(encoding="utf-8")) + except (OSError, tomllib.TOMLDecodeError) as error: + raise SbomValidationError(f"cannot read Cargo.lock from {path}: {error}") from error + + packages = cargo_lock.get("package") + if not isinstance(packages, list): + raise SbomValidationError("Cargo.lock must contain a package array") + + locked: set[tuple[str, str]] = set() + for package in packages: + if not isinstance(package, dict) or "source" not in package: + continue + name = package.get("name") + version = package.get("version") + if not isinstance(name, str) or not name or not isinstance(version, str) or not version: + raise SbomValidationError("Cargo.lock external package is missing name or version") + locked.add((name, version)) + if not locked: + raise SbomValidationError("Cargo.lock contains no external packages to verify") + return locked + + +def _spdx_packages(document: dict[str, Any]) -> set[tuple[str, str]]: + """Return name/version identities declared by a supported SPDX 2.x JSON document.""" + spdx_version = document.get("spdxVersion") + if spdx_version not in SUPPORTED_SPDX_VERSIONS: + raise SbomValidationError("SBOM must declare a supported SPDX 2.x version (2.0 through 2.3)") + if document.get("dataLicense") != "CC0-1.0": + raise SbomValidationError("SPDX dataLicense must be CC0-1.0") + + packages = document.get("packages") + if not isinstance(packages, list) or not packages: + raise SbomValidationError("SPDX SBOM must contain at least one package") + + declared: set[tuple[str, str]] = set() + for package in packages: + if not isinstance(package, dict): + continue + name = package.get("name") + version = package.get("versionInfo") + if isinstance(name, str) and name and isinstance(version, str) and version: + declared.add((name, version)) + if not declared: + raise SbomValidationError("SPDX SBOM contains no package name/version identities") + return declared + + +def validate_sbom(sbom_path: Path, cargo_lock_path: Path) -> None: + """Fail when locked external Rust dependencies are absent from generated SPDX evidence.""" + document = _load_json(sbom_path) + locked = _locked_external_packages(cargo_lock_path) + declared = _spdx_packages(document) + missing = sorted(locked - declared) + if missing: + preview = ", ".join(f"{name}@{version}" for name, version in missing[:10]) + suffix = "" if len(missing) <= 10 else f" (+{len(missing) - 10} more)" + raise SbomValidationError( + f"SPDX SBOM is missing {len(missing)} Cargo.lock package(s): {preview}{suffix}" + ) + + +def main(argv: list[str]) -> int: + """Run the command-line validator and return a conventional process status.""" + if len(argv) != 3: + print("usage: validate_spdx_sbom.py ", file=sys.stderr) + return 2 + try: + validate_sbom(Path(argv[1]), Path(argv[2])) + except SbomValidationError as error: + print(f"SBOM validation failed: {error}", file=sys.stderr) + return 1 + print("SPDX SBOM covers every external dependency pinned by Cargo.lock") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/tests/test_sbom_evidence.py b/tests/test_sbom_evidence.py new file mode 100644 index 00000000..a9b709ab --- /dev/null +++ b/tests/test_sbom_evidence.py @@ -0,0 +1,134 @@ +"""Executable contract for least-privilege, exact-head SPDX SBOM evidence.""" + +from pathlib import Path +import unittest + + +WORKFLOW = Path(".github/workflows/sbom-evidence.yml") +VALIDATOR = Path("scripts/validate_spdx_sbom.py") + + +def mapping_block(text: str, key: str, indent: int) -> str: + """Return one indentation-delimited YAML mapping block without loading YAML tags.""" + lines = text.splitlines() + marker = f"{' ' * indent}{key}:" + try: + start = lines.index(marker) + 1 + except ValueError as error: + raise AssertionError(f"missing YAML mapping key {key!r} at indent {indent}") from error + + block: list[str] = [] + for line in lines[start:]: + if not line.strip(): + block.append(line) + continue + current_indent = len(line) - len(line.lstrip(" ")) + if current_indent <= indent: + break + block.append(line) + return "\n".join(block) + + +class SbomEvidenceContract(unittest.TestCase): + """Keep SBOM generation immutable, review-safe, and tied to Cargo.lock.""" + + @classmethod + def workflow_text(cls) -> str: + """Return the committed SBOM workflow text.""" + return WORKFLOW.read_text(encoding="utf-8") + + def test_generation_is_exact_head_and_immutably_pinned(self) -> None: + """The workflow must scan every exact revision using fixed action/tool identities.""" + text = self.workflow_text() + trigger_block = mapping_block(text, "on", 0) + self.assertIn("pull_request:", trigger_block) + self.assertNotIn("pull_request_target:", trigger_block) + self.assertNotIn( + "paths:", + trigger_block, + "root-directory SBOM evidence must run on every repository change", + ) + self.assertIn("branches: [main]", trigger_block) + self.assertIn("github.event.pull_request.head.sha || github.sha", text) + self.assertIn("persist-credentials: false", text) + self.assertIn( + "anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610", text + ) + self.assertIn("syft-version: v1.51.0", text) + self.assertIn("format: spdx-json", text) + + def test_pull_request_lane_is_read_only_and_does_not_publish(self) -> None: + """Untrusted pull-request code must not obtain release or dependency-write authority.""" + text = self.workflow_text() + top_level_permissions = mapping_block(text, "permissions", 0) + self.assertEqual( + [line.strip() for line in top_level_permissions.splitlines() if line.strip()], + ["contents: read"], + "workflow token authority must fail closed at the top level", + ) + jobs = mapping_block(text, "jobs", 0) + for job_name in ["generate", "verify-evidence"]: + job = mapping_block(jobs, job_name, 2) + permissions = mapping_block(job, "permissions", 4) + self.assertEqual( + [line.strip() for line in permissions.splitlines() if line.strip()], + ["contents: read"], + ) + for forbidden_permission in [ + "contents: write", + "id-token:", + "attestations:", + "artifact-metadata:", + "packages: write", + ]: + self.assertNotIn(forbidden_permission, job) + generate_job = mapping_block(jobs, "generate", 2) + self.assertIn("dependency-snapshot: false", generate_job) + self.assertIn("upload-artifact: false", generate_job) + self.assertIn("upload-release-assets: false", generate_job) + + def test_generated_sbom_is_validated_against_locked_rust_dependencies(self) -> None: + """An uploaded file must be parseable SPDX evidence covering Cargo.lock dependencies.""" + text = self.workflow_text() + generate_job = mapping_block(mapping_block(text, "jobs", 0), "generate", 2) + self.assertTrue(VALIDATOR.is_file()) + self.assertIn( + "python3 scripts/validate_spdx_sbom.py sbom.spdx.json Cargo.lock", generate_job + ) + self.assertIn( + "actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f", + generate_job, + ) + self.assertIn("sbom.spdx.json", generate_job) + self.assertIn( + "sbom-spdx-${{ github.event.pull_request.head.sha || github.sha }}", generate_job + ) + + def test_preserved_sbom_is_reverified_after_artifact_handoff(self) -> None: + """A separate job must prove checksum and lock coverage survive artifact storage.""" + text = self.workflow_text() + jobs = mapping_block(text, "jobs", 0) + verify_job = mapping_block(jobs, "verify-evidence", 2) + self.assertIn("needs: generate", verify_job) + self.assertIn( + "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c", + verify_job, + ) + self.assertIn( + "name: sbom-spdx-${{ github.event.pull_request.head.sha || github.sha }}", + verify_job, + ) + self.assertIn("sha256sum --check sbom.spdx.json.sha256", verify_job) + self.assertIn( + "python3 scripts/validate_spdx_sbom.py evidence/sbom.spdx.json Cargo.lock", + verify_job, + ) + self.assertGreaterEqual( + verify_job.count("github.event.pull_request.head.sha || github.sha"), + 2, + "verification must bind checkout and artifact identity to the exact revision", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_validate_spdx_sbom.py b/tests/test_validate_spdx_sbom.py new file mode 100644 index 00000000..23c48f95 --- /dev/null +++ b/tests/test_validate_spdx_sbom.py @@ -0,0 +1,111 @@ +"""Unit tests for the deterministic SPDX/Cargo.lock evidence validator.""" + +from __future__ import annotations + +import json +from pathlib import Path +import sys +import tempfile +import unittest + + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +from validate_spdx_sbom import SbomValidationError, validate_sbom # noqa: E402 + + +class ValidateSpdxSbomTests(unittest.TestCase): + """Exercise successful coverage and fail-closed evidence mismatches.""" + + def write_fixture( + self, directory: Path, *, packages: list[dict[str, str]], spdx_version: str = "SPDX-2.3" + ) -> tuple[Path, Path]: + """Write one Cargo.lock and matching-shape SPDX JSON fixture.""" + cargo_lock = directory / "Cargo.lock" + cargo_lock.write_text( + """version = 4 + +[[package]] +name = "psychometrics-commons-runtime" +version = "0.1.0" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +""", + encoding="utf-8", + ) + sbom = directory / "sbom.spdx.json" + sbom.write_text( + json.dumps( + { + "spdxVersion": spdx_version, + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "packages": packages, + } + ), + encoding="utf-8", + ) + return sbom, cargo_lock + + def test_complete_locked_dependency_passes(self) -> None: + """A generated package with the locked name/version satisfies the evidence gate.""" + with tempfile.TemporaryDirectory() as temporary_directory: + sbom, cargo_lock = self.write_fixture( + Path(temporary_directory), + packages=[{"name": "serde", "versionInfo": "1.0.219"}], + ) + validate_sbom(sbom, cargo_lock) + + def test_missing_locked_dependency_fails_closed(self) -> None: + """An SBOM cannot be retained when a locked third-party package disappeared.""" + with tempfile.TemporaryDirectory() as temporary_directory: + sbom, cargo_lock = self.write_fixture( + Path(temporary_directory), + packages=[{"name": "other", "versionInfo": "9.9.9"}], + ) + with self.assertRaisesRegex(SbomValidationError, "serde@1.0.219"): + validate_sbom(sbom, cargo_lock) + + def test_non_spdx_two_document_fails_closed(self) -> None: + """An unrelated JSON inventory must not be mislabeled as accepted SPDX evidence.""" + with tempfile.TemporaryDirectory() as temporary_directory: + sbom, cargo_lock = self.write_fixture( + Path(temporary_directory), + packages=[{"name": "serde", "versionInfo": "1.0.219"}], + spdx_version="SPDX-3.0", + ) + with self.assertRaisesRegex(SbomValidationError, "supported SPDX 2.x"): + validate_sbom(sbom, cargo_lock) + + def test_malformed_spdx_two_versions_fail_closed(self) -> None: + """Incomplete or malformed SPDX 2.x-looking versions must not enter retained evidence.""" + for spdx_version in ("SPDX-2.", "SPDX-2.invalid"): + with self.subTest(spdx_version=spdx_version): + with tempfile.TemporaryDirectory() as temporary_directory: + sbom, cargo_lock = self.write_fixture( + Path(temporary_directory), + packages=[{"name": "serde", "versionInfo": "1.0.219"}], + spdx_version=spdx_version, + ) + with self.assertRaisesRegex(SbomValidationError, "supported SPDX 2.x"): + validate_sbom(sbom, cargo_lock) + + def test_missing_cc0_data_license_fails_closed(self) -> None: + """The required SPDX document data license is part of retained evidence validity.""" + with tempfile.TemporaryDirectory() as temporary_directory: + sbom, cargo_lock = self.write_fixture( + Path(temporary_directory), + packages=[{"name": "serde", "versionInfo": "1.0.219"}], + ) + payload = json.loads(sbom.read_text(encoding="utf-8")) + payload["dataLicense"] = "NOASSERTION" + sbom.write_text(json.dumps(payload), encoding="utf-8") + with self.assertRaisesRegex(SbomValidationError, "CC0-1.0"): + validate_sbom(sbom, cargo_lock) + + +if __name__ == "__main__": + unittest.main() From b4cd4adc94b026aba1f8392a522aa8e5d3c53317 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 11:57:55 +0900 Subject: [PATCH 17/33] test(response): instantiate overflow millis and missing-table reload Linux line coverage missed unix_ms overflow behind checked_add None and the receipt SELECT ? when response_event is absent. Extract millis_from_duration and load receipts against a missing relation. --- src/postgres_response_event.rs | 31 ++++++++------------ tests/postgres_response_event_persistence.rs | 4 +++ 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/postgres_response_event.rs b/src/postgres_response_event.rs index 96212759..a4121370 100644 --- a/src/postgres_response_event.rs +++ b/src/postgres_response_event.rs @@ -389,6 +389,10 @@ fn unix_ms_from_system_time(time: SystemTime) -> Result Result { let unix_ms = duration .as_secs() .checked_mul(1_000) @@ -415,7 +419,7 @@ fn require_read_committed( #[cfg(test)] mod reference_guard_tests { use super::{ - map_rebuild_error, postgres_sequence, postgres_timestamptz, + map_rebuild_error, millis_from_duration, postgres_sequence, postgres_timestamptz, require_contiguous_receipt_history, required_reference, unix_ms_from_system_time, ResponseEventPersistenceError, ResponseEventReceipt, }; @@ -460,23 +464,14 @@ mod reference_guard_tests { unix_ms_from_system_time(UNIX_EPOCH + Duration::from_secs(1_700_000_000)).unwrap(), 1_700_000_000_000 ); - let overflow_secs = u64::MAX / 1_000 + 1; - if let Some(far_future) = UNIX_EPOCH.checked_add(Duration::from_secs(overflow_secs)) { - assert!(matches!( - unix_ms_from_system_time(far_future), - Err(ResponseEventPersistenceError::InvalidTimestamp) - )); - } - let add_overflow_secs = u64::MAX / 1_000; - if let Some(near_max) = UNIX_EPOCH - .checked_add(Duration::from_secs(add_overflow_secs)) - .and_then(|time| time.checked_add(Duration::from_millis(616))) - { - assert!(matches!( - unix_ms_from_system_time(near_max), - Err(ResponseEventPersistenceError::InvalidTimestamp) - )); - } + assert!(matches!( + millis_from_duration(Duration::from_secs(u64::MAX / 1_000 + 1)), + Err(ResponseEventPersistenceError::InvalidTimestamp) + )); + assert!(matches!( + millis_from_duration(Duration::from_millis(0)), + Err(ResponseEventPersistenceError::InvalidTimestamp) + )); } #[test] diff --git a/tests/postgres_response_event_persistence.rs b/tests/postgres_response_event_persistence.rs index 9247d016..5bd66195 100644 --- a/tests/postgres_response_event_persistence.rs +++ b/tests/postgres_response_event_persistence.rs @@ -477,6 +477,10 @@ fn missing_relation_and_gapped_history_fail_closed() { persist_err(&mut client, "session_ipip_ko_missing", &event), ResponseEventPersistenceError::Database(_) )); + assert!(matches!( + load_receipts_err(&mut client, "session_ipip_ko_missing"), + ResponseEventPersistenceError::Database(_) + )); apply_response_event_migration(&mut client).unwrap(); persist_ok(&mut client, "session_ipip_ko_gap", &event); From 34e16cbff8f07ce29a95af92158427ff1e7c404c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 12:28:54 +0900 Subject: [PATCH 18/33] test(response): map missing-relation replay lookup to database error The isolated query_one `?` in classify_existing_event was the remaining uncovered production line. Name the lookup and fail closed when the relation is absent. --- src/postgres_response_event.rs | 42 +++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/src/postgres_response_event.rs b/src/postgres_response_event.rs index a4121370..959b8d23 100644 --- a/src/postgres_response_event.rs +++ b/src/postgres_response_event.rs @@ -288,12 +288,7 @@ fn classify_existing_event( observed_at: SystemTime, received_at: SystemTime, ) -> Result { - let row = transaction.query_one( - "SELECT session_ref, client_event_ref, item_version_ref, payload_digest, \ - server_sequence, observed_at, received_at \ - FROM response_event WHERE response_event_ref = $1", - &[&server_event_ref], - )?; + let row = query_existing_event_row(transaction, server_event_ref)?; let stored_session: String = row.get(0); let stored_client: String = row.get(1); let stored_item: String = row.get(2); @@ -315,6 +310,21 @@ fn classify_existing_event( } } +fn query_existing_event_row( + transaction: &mut Transaction<'_>, + server_event_ref: &str, +) -> Result { + match transaction.query_one( + "SELECT session_ref, client_event_ref, item_version_ref, payload_digest, \ + server_sequence, observed_at, received_at \ + FROM response_event WHERE response_event_ref = $1", + &[&server_event_ref], + ) { + Ok(row) => Ok(row), + Err(error) => Err(ResponseEventPersistenceError::from(error)), + } +} + fn classify_unique_violation(error: postgres::Error) -> ResponseEventPersistenceError { match error .as_db_error() @@ -420,12 +430,13 @@ fn require_read_committed( mod reference_guard_tests { use super::{ map_rebuild_error, millis_from_duration, postgres_sequence, postgres_timestamptz, - require_contiguous_receipt_history, required_reference, unix_ms_from_system_time, - ResponseEventPersistenceError, ResponseEventReceipt, + query_existing_event_row, require_contiguous_receipt_history, required_reference, + unix_ms_from_system_time, ResponseEventPersistenceError, ResponseEventReceipt, }; use crate::response::ResponseEvent; use crate::response::WriteError; use crate::session::SessionState; + use postgres::{Client, NoTls}; use std::time::{Duration, UNIX_EPOCH}; #[test] @@ -556,6 +567,21 @@ mod reference_guard_tests { assert_eq!(receipt.received_at_unix_ms(), 1_700_000_000_250); } + #[test] + fn existing_event_lookup_maps_missing_relation_to_database_error() { + let url = std::env::var("TEST_DATABASE_URL").expect("TEST_DATABASE_URL is required"); + let mut client = Client::connect(&url, NoTls).expect("CI PostgreSQL must be reachable"); + client + .batch_execute("SET search_path TO response_event_query_helper_missing;") + .unwrap(); + let mut transaction = client.transaction().unwrap(); + assert!(matches!( + query_existing_event_row(&mut transaction, "server_event_item_01"), + Err(ResponseEventPersistenceError::Database(_)) + )); + transaction.rollback().unwrap(); + } + #[test] fn rebuild_errors_map_to_typed_persistence_failures() { assert!(matches!( From bf2b02f34a11a4a88a6e1114a3e948da112cc3dc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 13:33:05 +0900 Subject: [PATCH 19/33] test(response): cover classify lookup through missing relation The isolated ? after query_existing_event_row left one Linux llvm-cov line and branch unexecuted. Call classify_existing_event against a missing search_path so the fail-closed Database arm is taken. --- src/postgres_response_event.rs | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/postgres_response_event.rs b/src/postgres_response_event.rs index 959b8d23..98a671ed 100644 --- a/src/postgres_response_event.rs +++ b/src/postgres_response_event.rs @@ -429,9 +429,10 @@ fn require_read_committed( #[cfg(test)] mod reference_guard_tests { use super::{ - map_rebuild_error, millis_from_duration, postgres_sequence, postgres_timestamptz, - query_existing_event_row, require_contiguous_receipt_history, required_reference, - unix_ms_from_system_time, ResponseEventPersistenceError, ResponseEventReceipt, + classify_existing_event, map_rebuild_error, millis_from_duration, postgres_sequence, + postgres_timestamptz, query_existing_event_row, require_contiguous_receipt_history, + required_reference, unix_ms_from_system_time, ResponseEventPersistenceError, + ResponseEventReceipt, }; use crate::response::ResponseEvent; use crate::response::WriteError; @@ -574,11 +575,32 @@ mod reference_guard_tests { client .batch_execute("SET search_path TO response_event_query_helper_missing;") .unwrap(); + let event = ResponseEvent::from_persisted( + "server_event_item_01", + "client_event_item_01", + "item_version_n1_ko", + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + 1, + ) + .unwrap(); let mut transaction = client.transaction().unwrap(); assert!(matches!( query_existing_event_row(&mut transaction, "server_event_item_01"), Err(ResponseEventPersistenceError::Database(_)) )); + let observed_at = UNIX_EPOCH + Duration::from_secs(1_700_000_000); + assert!(matches!( + classify_existing_event( + &mut transaction, + "session_ipip_ko_quick", + &event, + "server_event_item_01", + 1, + observed_at, + observed_at + Duration::from_millis(250), + ), + Err(ResponseEventPersistenceError::Database(_)) + )); transaction.rollback().unwrap(); } From ec1e96d6361cba0d65e9052f8141da24813c0905 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 13:46:18 +0900 Subject: [PATCH 20/33] test(response): rebind observed time independently of receipt time The stored-clock compare short-circuited after a received-time-only replay, so the observed-at AND arm never failed closed. Replay the same event identity with a later observed clock and the original receipt time. --- tests/postgres_response_event_persistence.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/postgres_response_event_persistence.rs b/tests/postgres_response_event_persistence.rs index 5bd66195..ae157a21 100644 --- a/tests/postgres_response_event_persistence.rs +++ b/tests/postgres_response_event_persistence.rs @@ -643,6 +643,16 @@ fn inverted_or_zero_event_times_and_time_rebinding_fail_closed() { ResponseEventPersistenceError::InvalidTimestamp )); persist_ok(&mut client, "session_ipip_ko_time", &event); + assert!(matches!( + persist_err_at( + &mut client, + "session_ipip_ko_time", + &event, + OBSERVED_AT_MS + 1, + RECEIVED_AT_MS + ), + ResponseEventPersistenceError::ConflictingReplay + )); assert!(matches!( persist_err_at( &mut client, From f755dae238fc9138abb37007e928634284f581f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 15:01:27 +0900 Subject: [PATCH 21/33] test(response): instantiate next sequence from the library Linux llvm-cov left next_contiguous_sequence and its saturating_add closure unexecuted because only persist called them. The lib test takes missing-relation, empty-prefix, and MAX+1 arms on that copy. --- src/postgres_response_event.rs | 59 +++++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/src/postgres_response_event.rs b/src/postgres_response_event.rs index 98a671ed..28264d73 100644 --- a/src/postgres_response_event.rs +++ b/src/postgres_response_event.rs @@ -429,10 +429,10 @@ fn require_read_committed( #[cfg(test)] mod reference_guard_tests { use super::{ - classify_existing_event, map_rebuild_error, millis_from_duration, postgres_sequence, - postgres_timestamptz, query_existing_event_row, require_contiguous_receipt_history, - required_reference, unix_ms_from_system_time, ResponseEventPersistenceError, - ResponseEventReceipt, + apply_response_event_migration, classify_existing_event, map_rebuild_error, + millis_from_duration, next_contiguous_sequence, postgres_sequence, postgres_timestamptz, + query_existing_event_row, require_contiguous_receipt_history, required_reference, + unix_ms_from_system_time, ResponseEventPersistenceError, ResponseEventReceipt, }; use crate::response::ResponseEvent; use crate::response::WriteError; @@ -604,6 +604,57 @@ mod reference_guard_tests { transaction.rollback().unwrap(); } + #[test] + fn next_contiguous_sequence_instantiates_empty_prefix_and_missing_relation() { + let url = std::env::var("TEST_DATABASE_URL").expect("TEST_DATABASE_URL is required"); + let mut client = Client::connect(&url, NoTls).expect("CI PostgreSQL must be reachable"); + client + .batch_execute( + "CREATE SCHEMA IF NOT EXISTS response_event_next_sequence_test;\ + SET search_path TO response_event_next_sequence_test;\ + DROP TABLE IF EXISTS response_event;", + ) + .unwrap(); + let mut missing = client.transaction().unwrap(); + let missing_error = next_contiguous_sequence(&mut missing, "session_ipip_ko_quick") + .expect_err("missing relation must fail closed"); + assert!(matches!( + missing_error, + ResponseEventPersistenceError::Database(_) + )); + assert_eq!( + missing_error.to_string(), + "PostgreSQL response-event persistence failed" + ); + missing.rollback().unwrap(); + + apply_response_event_migration(&mut client).unwrap(); + let mut empty = client.transaction().unwrap(); + assert_eq!( + next_contiguous_sequence(&mut empty, "session_ipip_ko_quick").unwrap(), + 1 + ); + empty + .execute( + "INSERT INTO response_event (\ + response_event_ref, session_ref, client_event_ref, item_version_ref, \ + payload_digest, server_sequence, observed_at, received_at\ + ) VALUES (\ + 'server_event_item_01', 'session_ipip_ko_quick', 'client_event_item_01', \ + 'item_version_n1_ko', \ + 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', \ + 1, TIMESTAMPTZ '2023-11-14 22:13:20+00', TIMESTAMPTZ '2023-11-14 22:13:20.250+00'\ + )", + &[], + ) + .unwrap(); + assert_eq!( + next_contiguous_sequence(&mut empty, "session_ipip_ko_quick").unwrap(), + 2 + ); + empty.rollback().unwrap(); + } + #[test] fn rebuild_errors_map_to_typed_persistence_failures() { assert!(matches!( From 7fc05fbe048437f637e280bc300a3c946417393a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 16:06:53 +0900 Subject: [PATCH 22/33] test(response): instantiate persist and load in the library Linux llvm-cov left persist/load as unused instantiations after the sequence helper test. Call both against a missing search_path so the library copies take the Database arm. --- src/postgres_response_event.rs | 46 +++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/src/postgres_response_event.rs b/src/postgres_response_event.rs index 28264d73..59b3a783 100644 --- a/src/postgres_response_event.rs +++ b/src/postgres_response_event.rs @@ -429,10 +429,11 @@ fn require_read_committed( #[cfg(test)] mod reference_guard_tests { use super::{ - apply_response_event_migration, classify_existing_event, map_rebuild_error, - millis_from_duration, next_contiguous_sequence, postgres_sequence, postgres_timestamptz, - query_existing_event_row, require_contiguous_receipt_history, required_reference, - unix_ms_from_system_time, ResponseEventPersistenceError, ResponseEventReceipt, + apply_response_event_migration, classify_existing_event, load_response_event_receipts, + load_response_ledger, map_rebuild_error, millis_from_duration, next_contiguous_sequence, + persist_response_event, postgres_sequence, postgres_timestamptz, query_existing_event_row, + require_contiguous_receipt_history, required_reference, unix_ms_from_system_time, + ResponseEventPersistenceError, ResponseEventReceipt, }; use crate::response::ResponseEvent; use crate::response::WriteError; @@ -655,6 +656,43 @@ mod reference_guard_tests { empty.rollback().unwrap(); } + #[test] + fn persist_and_load_instantiate_library_copies_on_missing_relation() { + let url = std::env::var("TEST_DATABASE_URL").expect("TEST_DATABASE_URL is required"); + let mut client = Client::connect(&url, NoTls).expect("CI PostgreSQL must be reachable"); + client + .batch_execute("SET search_path TO response_event_persist_load_missing;") + .unwrap(); + let event = ResponseEvent::from_persisted( + "server_event_item_01", + "client_event_item_01", + "item_version_n1_ko", + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + 1, + ) + .unwrap(); + let mut transaction = client.transaction().unwrap(); + assert!(matches!( + persist_response_event( + &mut transaction, + "session_ipip_ko_quick", + &event, + 1_700_000_000_000, + 1_700_000_000_250, + ), + Err(ResponseEventPersistenceError::Database(_)) + )); + assert!(matches!( + load_response_event_receipts(&mut transaction, "session_ipip_ko_quick"), + Err(ResponseEventPersistenceError::Database(_)) + )); + assert!(matches!( + load_response_ledger(&mut transaction, "session_ipip_ko_quick"), + Err(ResponseEventPersistenceError::Database(_)) + )); + transaction.rollback().unwrap(); + } + #[test] fn rebuild_errors_map_to_typed_persistence_failures() { assert!(matches!( From 73c7d5157def7f51a6182f6092f2c39d184f4f02 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 18:46:46 +0900 Subject: [PATCH 23/33] test(response): instantiate persist and load isolation in the library Linux llvm-cov still left one production line after the missing-relation lib calls. Repeatable Read persist/load now takes the UnsupportedIsolation arm and formats the operator-facing message on that copy. --- src/postgres_response_event.rs | 51 +++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src/postgres_response_event.rs b/src/postgres_response_event.rs index 59b3a783..ae5396f2 100644 --- a/src/postgres_response_event.rs +++ b/src/postgres_response_event.rs @@ -438,7 +438,7 @@ mod reference_guard_tests { use crate::response::ResponseEvent; use crate::response::WriteError; use crate::session::SessionState; - use postgres::{Client, NoTls}; + use postgres::{Client, IsolationLevel, NoTls}; use std::time::{Duration, UNIX_EPOCH}; #[test] @@ -693,6 +693,55 @@ mod reference_guard_tests { transaction.rollback().unwrap(); } + #[test] + fn persist_and_load_instantiate_unsupported_isolation_in_the_library() { + let url = std::env::var("TEST_DATABASE_URL").expect("TEST_DATABASE_URL is required"); + let mut client = Client::connect(&url, NoTls).expect("CI PostgreSQL must be reachable"); + client + .batch_execute( + "CREATE SCHEMA IF NOT EXISTS response_event_isolation_lib_test;\ + SET search_path TO response_event_isolation_lib_test;", + ) + .unwrap(); + apply_response_event_migration(&mut client).unwrap(); + let event = ResponseEvent::from_persisted( + "server_event_item_iso", + "client_event_item_iso", + "item_version_n1_ko", + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + 1, + ) + .unwrap(); + let mut serializable = client + .build_transaction() + .isolation_level(IsolationLevel::RepeatableRead) + .start() + .unwrap(); + let persist_error = persist_response_event( + &mut serializable, + "session_ipip_ko_iso", + &event, + 1_700_000_000_000, + 1_700_000_000_250, + ) + .expect_err("lib persist must reject stronger isolation"); + assert!(matches!( + persist_error, + ResponseEventPersistenceError::UnsupportedIsolationLevel + )); + assert_eq!( + persist_error.to_string(), + "response event persistence requires read committed isolation" + ); + let load_error = load_response_event_receipts(&mut serializable, "session_ipip_ko_iso") + .expect_err("lib load must reject stronger isolation"); + assert!(matches!( + load_error, + ResponseEventPersistenceError::UnsupportedIsolationLevel + )); + serializable.rollback().unwrap(); + } + #[test] fn rebuild_errors_map_to_typed_persistence_failures() { assert!(matches!( From 922c86a32d974f3b16d594677e88409bb190e510 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 20:02:18 +0900 Subject: [PATCH 24/33] test(response): call require_read_committed from the library The last uncovered production line stayed on the isolation helper copy that persist/load inlined. Invoke it directly under Repeatable Read and READ COMMITTED. --- src/postgres_response_event.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/postgres_response_event.rs b/src/postgres_response_event.rs index ae5396f2..1fb27101 100644 --- a/src/postgres_response_event.rs +++ b/src/postgres_response_event.rs @@ -432,8 +432,8 @@ mod reference_guard_tests { apply_response_event_migration, classify_existing_event, load_response_event_receipts, load_response_ledger, map_rebuild_error, millis_from_duration, next_contiguous_sequence, persist_response_event, postgres_sequence, postgres_timestamptz, query_existing_event_row, - require_contiguous_receipt_history, required_reference, unix_ms_from_system_time, - ResponseEventPersistenceError, ResponseEventReceipt, + require_contiguous_receipt_history, require_read_committed, required_reference, + unix_ms_from_system_time, ResponseEventPersistenceError, ResponseEventReceipt, }; use crate::response::ResponseEvent; use crate::response::WriteError; @@ -739,7 +739,14 @@ mod reference_guard_tests { load_error, ResponseEventPersistenceError::UnsupportedIsolationLevel )); + assert!(matches!( + require_read_committed(&mut serializable), + Err(ResponseEventPersistenceError::UnsupportedIsolationLevel) + )); serializable.rollback().unwrap(); + let mut committed = client.transaction().unwrap(); + assert!(require_read_committed(&mut committed).is_ok()); + committed.rollback().unwrap(); } #[test] From 0cfdbd9272db3d40e0ccf97291a23d9701b9a64f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 21:16:41 +0900 Subject: [PATCH 25/33] test(response): instantiate unique-violation classification in the library Linux llvm-cov left classify_unique_violation unexecuted because persist inlined it. The lib test takes client-unique, sequence-unique, and non-constraint Database arms on that copy. --- src/postgres_response_event.rs | 82 +++++++++++++++++++++++++++++++--- 1 file changed, 77 insertions(+), 5 deletions(-) diff --git a/src/postgres_response_event.rs b/src/postgres_response_event.rs index 1fb27101..bcfa7fd3 100644 --- a/src/postgres_response_event.rs +++ b/src/postgres_response_event.rs @@ -429,11 +429,12 @@ fn require_read_committed( #[cfg(test)] mod reference_guard_tests { use super::{ - apply_response_event_migration, classify_existing_event, load_response_event_receipts, - load_response_ledger, map_rebuild_error, millis_from_duration, next_contiguous_sequence, - persist_response_event, postgres_sequence, postgres_timestamptz, query_existing_event_row, - require_contiguous_receipt_history, require_read_committed, required_reference, - unix_ms_from_system_time, ResponseEventPersistenceError, ResponseEventReceipt, + apply_response_event_migration, classify_existing_event, classify_unique_violation, + load_response_event_receipts, load_response_ledger, map_rebuild_error, + millis_from_duration, next_contiguous_sequence, persist_response_event, postgres_sequence, + postgres_timestamptz, query_existing_event_row, require_contiguous_receipt_history, + require_read_committed, required_reference, unix_ms_from_system_time, + ResponseEventPersistenceError, ResponseEventReceipt, }; use crate::response::ResponseEvent; use crate::response::WriteError; @@ -773,4 +774,75 @@ mod reference_guard_tests { )); } } + + #[test] + fn unique_violation_classifier_is_instantiated_in_the_library() { + let url = std::env::var("TEST_DATABASE_URL").expect("TEST_DATABASE_URL is required"); + let mut client = Client::connect(&url, NoTls).expect("CI PostgreSQL must be reachable"); + client + .batch_execute( + "CREATE SCHEMA IF NOT EXISTS response_event_unique_classify_lib;\ + SET search_path TO response_event_unique_classify_lib;\ + DROP TABLE IF EXISTS response_event;", + ) + .unwrap(); + apply_response_event_migration(&mut client).unwrap(); + client + .execute( + "INSERT INTO response_event (\ + response_event_ref, session_ref, client_event_ref, item_version_ref, \ + payload_digest, server_sequence, observed_at, received_at\ + ) VALUES (\ + 'server_event_item_01', 'session_ipip_ko_quick', 'client_event_item_01', \ + 'item_version_n1_ko', \ + 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', \ + 1, TIMESTAMPTZ '2023-11-14 22:13:20+00', TIMESTAMPTZ '2023-11-14 22:13:20.250+00'\ + )", + &[], + ) + .unwrap(); + let client_conflict = client + .execute( + "INSERT INTO response_event (\ + response_event_ref, session_ref, client_event_ref, item_version_ref, \ + payload_digest, server_sequence, observed_at, received_at\ + ) VALUES (\ + 'server_event_item_02', 'session_ipip_ko_quick', 'client_event_item_01', \ + 'item_version_n1_ko', \ + 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', \ + 2, TIMESTAMPTZ '2023-11-14 22:13:21+00', TIMESTAMPTZ '2023-11-14 22:13:21.250+00'\ + )", + &[], + ) + .unwrap_err(); + assert!(matches!( + classify_unique_violation(client_conflict), + ResponseEventPersistenceError::ConflictingReplay + )); + let sequence_conflict = client + .execute( + "INSERT INTO response_event (\ + response_event_ref, session_ref, client_event_ref, item_version_ref, \ + payload_digest, server_sequence, observed_at, received_at\ + ) VALUES (\ + 'server_event_item_03', 'session_ipip_ko_quick', 'client_event_item_03', \ + 'item_version_n1_ko', \ + 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', \ + 1, TIMESTAMPTZ '2023-11-14 22:13:21+00', TIMESTAMPTZ '2023-11-14 22:13:21.250+00'\ + )", + &[], + ) + .unwrap_err(); + assert!(matches!( + classify_unique_violation(sequence_conflict), + ResponseEventPersistenceError::SequenceConflict + )); + let missing = client + .query_one("SELECT * FROM response_event_unique_classify_missing", &[]) + .unwrap_err(); + assert!(matches!( + classify_unique_violation(missing), + ResponseEventPersistenceError::Database(_) + )); + } } From 181d3f32d8d2b2634381712677d5b3b479169a69 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 22:33:18 +0900 Subject: [PATCH 26/33] test(response): instantiate persistence error Display and source Linux llvm-cov left Error::source on the library copy unexecuted because only integration tests formatted the operator-facing errors. The lib test now takes Database source and the no-source arms. --- src/postgres_response_event.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/postgres_response_event.rs b/src/postgres_response_event.rs index bcfa7fd3..65deafda 100644 --- a/src/postgres_response_event.rs +++ b/src/postgres_response_event.rs @@ -840,9 +840,22 @@ mod reference_guard_tests { let missing = client .query_one("SELECT * FROM response_event_unique_classify_missing", &[]) .unwrap_err(); + let database = classify_unique_violation(missing); assert!(matches!( - classify_unique_violation(missing), + database, ResponseEventPersistenceError::Database(_) )); + assert!(std::error::Error::source(&database).is_some()); + for error in [ + ResponseEventPersistenceError::InvalidReference, + ResponseEventPersistenceError::ConflictingReplay, + ResponseEventPersistenceError::SequenceConflict, + ResponseEventPersistenceError::InvalidSequence, + ResponseEventPersistenceError::InvalidTimestamp, + ResponseEventPersistenceError::UnsupportedIsolationLevel, + ] { + assert!(!error.to_string().is_empty()); + assert!(std::error::Error::source(&error).is_none()); + } } } From f85aa2dd80d14b4449a76d8b988c3c1020e3e2a2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:43:53 +0900 Subject: [PATCH 27/33] test(response): reject extension of corrupt sequence prefix --- tests/postgres_response_event_sequence_gap.rs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/postgres_response_event_sequence_gap.rs b/tests/postgres_response_event_sequence_gap.rs index 08cb3144..84a40067 100644 --- a/tests/postgres_response_event_sequence_gap.rs +++ b/tests/postgres_response_event_sequence_gap.rs @@ -62,3 +62,61 @@ fn persist_rejects_a_server_sequence_gap_before_commit() { assert!(error.to_string().contains("gapped"), "{error}"); transaction.rollback().unwrap(); } + +#[test] +fn persist_rejects_extension_of_a_preexisting_corrupt_prefix() { + let mut client = test_client(); + client + .execute( + "INSERT INTO response_event (\ + response_event_ref, session_ref, client_event_ref, item_version_ref, \ + payload_digest, server_sequence, observed_at, received_at\ + ) VALUES ($1, $2, $3, $4, $5, 2, to_timestamp($6::double precision / 1000.0), \ + to_timestamp($7::double precision / 1000.0))", + &[ + &"server_event_corrupt_02", + &"session_ipip_ko_corrupt_prefix", + &"client_event_corrupt_02", + &"item_version_n2_ko", + &"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + &(OBSERVED_AT_MS as f64), + &(RECEIVED_AT_MS as f64), + ], + ) + .unwrap(); + + let next = ResponseEvent::from_persisted( + "server_event_item_03", + "client_event_item_03", + "item_version_n3_ko", + "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + 3, + ) + .unwrap(); + let mut transaction = client.transaction().unwrap(); + let error = persist_response_event( + &mut transaction, + "session_ipip_ko_corrupt_prefix", + &next, + OBSERVED_AT_MS + 1_000, + RECEIVED_AT_MS + 1_000, + ) + .unwrap_err(); + + assert!(matches!( + error, + ResponseEventPersistenceError::InvalidSequence + )); + let persisted_rows: i64 = transaction + .query_one( + "SELECT COUNT(*) FROM response_event WHERE session_ref = $1", + &[&"session_ipip_ko_corrupt_prefix"], + ) + .unwrap() + .get(0); + assert_eq!( + persisted_rows, 1, + "a corrupt stored prefix must not be extended" + ); + transaction.rollback().unwrap(); +} From 6777f77c7e1826547d07aaa45e67c26cb6d2b55b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:46:07 +0900 Subject: [PATCH 28/33] fix(response): reject corrupt stored sequence prefixes --- src/postgres_response_event.rs | 52 +++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/src/postgres_response_event.rs b/src/postgres_response_event.rs index 65deafda..0003073e 100644 --- a/src/postgres_response_event.rs +++ b/src/postgres_response_event.rs @@ -375,17 +375,31 @@ fn postgres_sequence(value: usize) -> Result i64::try_from(value).map_err(|_| ResponseEventPersistenceError::InvalidSequence) } +fn next_sequence_from_summary( + count: i64, + highest: Option, +) -> Result { + match (count, highest) { + (0, None) => Ok(1), + (count, Some(value)) if count > 0 && value > 0 && count == value => value + .checked_add(1) + .ok_or(ResponseEventPersistenceError::InvalidSequence), + _ => Err(ResponseEventPersistenceError::InvalidSequence), + } +} + fn next_contiguous_sequence( transaction: &mut Transaction<'_>, session_ref: &str, ) -> Result { - let highest: Option = transaction - .query_one( - "SELECT MAX(server_sequence) FROM response_event WHERE session_ref = $1", - &[&session_ref], - )? - .get(0); - Ok(highest.map_or(1, |value| value.saturating_add(1))) + let row = transaction.query_one( + "SELECT COUNT(*), MAX(server_sequence) \ + FROM response_event WHERE session_ref = $1", + &[&session_ref], + )?; + let count: i64 = row.get(0); + let highest: Option = row.get(1); + next_sequence_from_summary(count, highest) } fn postgres_timestamptz(unix_ms: u64) -> Result { @@ -431,10 +445,10 @@ mod reference_guard_tests { use super::{ apply_response_event_migration, classify_existing_event, classify_unique_violation, load_response_event_receipts, load_response_ledger, map_rebuild_error, - millis_from_duration, next_contiguous_sequence, persist_response_event, postgres_sequence, - postgres_timestamptz, query_existing_event_row, require_contiguous_receipt_history, - require_read_committed, required_reference, unix_ms_from_system_time, - ResponseEventPersistenceError, ResponseEventReceipt, + millis_from_duration, next_contiguous_sequence, next_sequence_from_summary, + persist_response_event, postgres_sequence, postgres_timestamptz, query_existing_event_row, + require_contiguous_receipt_history, require_read_committed, required_reference, + unix_ms_from_system_time, ResponseEventPersistenceError, ResponseEventReceipt, }; use crate::response::ResponseEvent; use crate::response::WriteError; @@ -488,6 +502,22 @@ mod reference_guard_tests { )); } + #[test] + fn sequence_summary_requires_an_exact_positive_prefix() { + assert_eq!(next_sequence_from_summary(0, None).unwrap(), 1); + assert_eq!(next_sequence_from_summary(2, Some(2)).unwrap(), 3); + for (count, highest) in [(1, Some(2)), (1, Some(0)), (-1, Some(-1)), (1, None)] { + assert!(matches!( + next_sequence_from_summary(count, highest), + Err(ResponseEventPersistenceError::InvalidSequence) + )); + } + assert!(matches!( + next_sequence_from_summary(i64::MAX, Some(i64::MAX)), + Err(ResponseEventPersistenceError::InvalidSequence) + )); + } + #[test] fn gapped_or_duplicate_receipt_history_fails_closed() { let first = ResponseEvent::from_persisted( From ffe3fd9b264832c8c0b46ff25d0d64c514fae2be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:49:08 +0900 Subject: [PATCH 29/33] test(response): reject malformed migration state and Unicode aliases --- ...tgres_response_event_migration_contract.rs | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 tests/postgres_response_event_migration_contract.rs diff --git a/tests/postgres_response_event_migration_contract.rs b/tests/postgres_response_event_migration_contract.rs new file mode 100644 index 00000000..c389c06f --- /dev/null +++ b/tests/postgres_response_event_migration_contract.rs @@ -0,0 +1,94 @@ +//! PostgreSQL migration contracts for durable response-event history. + +use postgres::{Client, NoTls}; +use psychometrics_commons_runtime::postgres_response_event::apply_response_event_migration; + +fn test_client(schema_name: &str) -> Client { + let connection = std::env::var("TEST_DATABASE_URL") + .expect("TEST_DATABASE_URL must identify the isolated CI PostgreSQL database"); + let mut client = Client::connect(&connection, NoTls) + .expect("isolated CI PostgreSQL database must be reachable"); + client + .batch_execute(&format!( + "CREATE SCHEMA IF NOT EXISTS {schema_name}; SET search_path TO {schema_name}; DROP TABLE IF EXISTS response_event;" + )) + .unwrap(); + client +} + +#[test] +fn migration_rejects_an_incompatible_preexisting_response_event_schema() { + let mut client = test_client("response_event_migration_shape_test"); + client + .batch_execute( + "CREATE TABLE response_event (response_event_ref TEXT PRIMARY KEY, session_ref TEXT);", + ) + .unwrap(); + + let error = apply_response_event_migration(&mut client) + .expect_err("migration must fail closed instead of accepting a partial preexisting table"); + let database_error = error + .as_db_error() + .expect("schema-contract rejection must be a PostgreSQL database error"); + assert_eq!(database_error.code().code(), "55000"); + assert!(database_error.message().contains("response_event")); + assert!(database_error.message().contains("contract")); +} + +#[test] +fn migration_rejects_unicode_numeric_like_reference_aliases() { + let mut client = test_client("response_event_reference_constraint_test"); + apply_response_event_migration(&mut client).unwrap(); + + let columns = [ + ("response_event_ref", "response_event_response_event_ref_format_check"), + ("session_ref", "response_event_session_ref_format_check"), + ("client_event_ref", "response_event_client_event_ref_format_check"), + ("item_version_ref", "response_event_item_version_ref_format_check"), + ]; + let numeric_like_aliases = ["12.34", "12٫34", "12٬34", "12,34"]; + let mut case_index = 0_i64; + + for (column_name, expected_constraint) in columns { + for alias in numeric_like_aliases { + case_index += 1; + let mut response_event_ref = format!("response_event_case_{case_index}"); + let mut session_ref = format!("session_case_{case_index}"); + let mut client_event_ref = format!("client_event_case_{case_index}"); + let mut item_version_ref = format!("item_version_case_{case_index}"); + match column_name { + "response_event_ref" => response_event_ref = alias.to_owned(), + "session_ref" => session_ref = alias.to_owned(), + "client_event_ref" => client_event_ref = alias.to_owned(), + "item_version_ref" => item_version_ref = alias.to_owned(), + _ => unreachable!("test enumerates only response-event reference columns"), + } + + let error = client + .execute( + "INSERT INTO response_event (\ + response_event_ref, session_ref, client_event_ref, item_version_ref, \ + payload_digest, server_sequence, observed_at, received_at\ + ) VALUES ($1, $2, $3, $4, $5, $6, \ + TIMESTAMPTZ '2023-11-14 22:13:20+00', \ + TIMESTAMPTZ '2023-11-14 22:13:20.250+00')", + &[ + &response_event_ref, + &session_ref, + &client_event_ref, + &item_version_ref, + &"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + &case_index, + ], + ) + .expect_err("Unicode numeric-like aliases must fail the owned reference constraint"); + assert_eq!( + error + .as_db_error() + .and_then(postgres::error::DbError::constraint), + Some(expected_constraint), + "column {column_name} accepted or misclassified alias {alias:?}" + ); + } + } +} From 7d73e855fcb8e071c54cb56341cda1ece6bc100a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:50:37 +0900 Subject: [PATCH 30/33] fix(response): fail closed on response-event schema drift --- migrations/0020_response_event.sql | 201 ++++++++++++++++++++++++++++- 1 file changed, 195 insertions(+), 6 deletions(-) diff --git a/migrations/0020_response_event.sql b/migrations/0020_response_event.sql index 655df1b1..179bb294 100644 --- a/migrations/0020_response_event.sql +++ b/migrations/0020_response_event.sql @@ -1,11 +1,25 @@ -CREATE TABLE IF NOT EXISTS response_event ( +DO $response_event_schema$ +DECLARE + relation_ref REGCLASS := to_regclass('response_event'); + created_table BOOLEAN := relation_ref IS NULL; + expected_relation_ref REGCLASS; + actual_columns TEXT[]; + expected_columns TEXT[]; + actual_defaults TEXT[]; + expected_defaults TEXT[]; + actual_constraints TEXT[]; + expected_constraints TEXT[]; +BEGIN + IF created_table THEN + EXECUTE $create_response_event$ +CREATE TABLE response_event ( response_event_ref TEXT CONSTRAINT response_event_response_event_ref_not_null NOT NULL CONSTRAINT response_event_response_event_ref_format_check CHECK ( response_event_ref = btrim(response_event_ref) AND response_event_ref <> '' AND NOT ( response_event_ref ~ '[[:digit:]]' - AND response_event_ref ~ '^[[:digit:]+,.eE-]+$' + AND response_event_ref ~ '^[[:digit:]+,.eE.٫٬,-]+$' ) ), session_ref TEXT CONSTRAINT response_event_session_ref_not_null NOT NULL @@ -14,7 +28,7 @@ CREATE TABLE IF NOT EXISTS response_event ( AND session_ref <> '' AND NOT ( session_ref ~ '[[:digit:]]' - AND session_ref ~ '^[[:digit:]+,.eE-]+$' + AND session_ref ~ '^[[:digit:]+,.eE.٫٬,-]+$' ) ), client_event_ref TEXT CONSTRAINT response_event_client_event_ref_not_null NOT NULL @@ -23,7 +37,7 @@ CREATE TABLE IF NOT EXISTS response_event ( AND client_event_ref <> '' AND NOT ( client_event_ref ~ '[[:digit:]]' - AND client_event_ref ~ '^[[:digit:]+,.eE-]+$' + AND client_event_ref ~ '^[[:digit:]+,.eE.٫٬,-]+$' ) ), item_version_ref TEXT CONSTRAINT response_event_item_version_ref_not_null NOT NULL @@ -32,7 +46,7 @@ CREATE TABLE IF NOT EXISTS response_event ( AND item_version_ref <> '' AND NOT ( item_version_ref ~ '[[:digit:]]' - AND item_version_ref ~ '^[[:digit:]+,.eE-]+$' + AND item_version_ref ~ '^[[:digit:]+,.eE.٫٬,-]+$' ) ), payload_digest TEXT CONSTRAINT response_event_payload_digest_not_null NOT NULL @@ -47,4 +61,179 @@ CREATE TABLE IF NOT EXISTS response_event ( CONSTRAINT response_event_session_client_unique UNIQUE (session_ref, client_event_ref), CONSTRAINT response_event_session_sequence_unique UNIQUE (session_ref, server_sequence), CONSTRAINT response_event_observed_not_after_received_check CHECK (observed_at <= received_at) -); +) +$create_response_event$; + relation_ref := to_regclass('response_event'); + END IF; + + IF relation_ref IS NULL THEN + RAISE EXCEPTION USING + ERRCODE = '55000', + MESSAGE = 'response_event migration did not create its owned table'; + END IF; + + EXECUTE $create_expected_response_event$ +CREATE TEMP TABLE expected_response_event_contract ( + response_event_ref TEXT CONSTRAINT response_event_response_event_ref_not_null NOT NULL + CONSTRAINT response_event_response_event_ref_format_check CHECK ( + response_event_ref = btrim(response_event_ref) + AND response_event_ref <> '' + AND NOT ( + response_event_ref ~ '[[:digit:]]' + AND response_event_ref ~ '^[[:digit:]+,.eE.٫٬,-]+$' + ) + ), + session_ref TEXT CONSTRAINT response_event_session_ref_not_null NOT NULL + CONSTRAINT response_event_session_ref_format_check CHECK ( + session_ref = btrim(session_ref) + AND session_ref <> '' + AND NOT ( + session_ref ~ '[[:digit:]]' + AND session_ref ~ '^[[:digit:]+,.eE.٫٬,-]+$' + ) + ), + client_event_ref TEXT CONSTRAINT response_event_client_event_ref_not_null NOT NULL + CONSTRAINT response_event_client_event_ref_format_check CHECK ( + client_event_ref = btrim(client_event_ref) + AND client_event_ref <> '' + AND NOT ( + client_event_ref ~ '[[:digit:]]' + AND client_event_ref ~ '^[[:digit:]+,.eE.٫٬,-]+$' + ) + ), + item_version_ref TEXT CONSTRAINT response_event_item_version_ref_not_null NOT NULL + CONSTRAINT response_event_item_version_ref_format_check CHECK ( + item_version_ref = btrim(item_version_ref) + AND item_version_ref <> '' + AND NOT ( + item_version_ref ~ '[[:digit:]]' + AND item_version_ref ~ '^[[:digit:]+,.eE.٫٬,-]+$' + ) + ), + payload_digest TEXT CONSTRAINT response_event_payload_digest_not_null NOT NULL + CONSTRAINT response_event_payload_digest_format_check CHECK ( + payload_digest ~ '^sha256:[0-9a-f]{64}$' + ), + server_sequence BIGINT CONSTRAINT response_event_server_sequence_not_null NOT NULL + CONSTRAINT response_event_server_sequence_positive_check CHECK (server_sequence > 0), + observed_at TIMESTAMPTZ CONSTRAINT response_event_observed_at_not_null NOT NULL, + received_at TIMESTAMPTZ CONSTRAINT response_event_received_at_not_null NOT NULL, + CONSTRAINT response_event_pkey PRIMARY KEY (response_event_ref), + CONSTRAINT response_event_session_client_unique UNIQUE (session_ref, client_event_ref), + CONSTRAINT response_event_session_sequence_unique UNIQUE (session_ref, server_sequence), + CONSTRAINT response_event_observed_not_after_received_check CHECK (observed_at <= received_at) +) ON COMMIT DROP +$create_expected_response_event$; + expected_relation_ref := to_regclass('pg_temp.expected_response_event_contract'); + + IF expected_relation_ref IS NULL THEN + RAISE EXCEPTION USING + ERRCODE = '55000', + MESSAGE = 'response_event migration could not construct its schema contract'; + END IF; + + SELECT ARRAY( + SELECT format( + '%s:%s:%s', + attribute.attname, + format_type(attribute.atttypid, attribute.atttypmod), + CASE WHEN attribute.attnotnull THEN 'not_null' ELSE 'nullable' END + ) + FROM pg_attribute AS attribute + WHERE attribute.attrelid = relation_ref + AND attribute.attnum > 0 + AND NOT attribute.attisdropped + ORDER BY attribute.attnum + ) INTO actual_columns; + + SELECT ARRAY( + SELECT format( + '%s:%s:%s', + attribute.attname, + format_type(attribute.atttypid, attribute.atttypmod), + CASE WHEN attribute.attnotnull THEN 'not_null' ELSE 'nullable' END + ) + FROM pg_attribute AS attribute + WHERE attribute.attrelid = expected_relation_ref + AND attribute.attnum > 0 + AND NOT attribute.attisdropped + ORDER BY attribute.attnum + ) INTO expected_columns; + + IF actual_columns IS DISTINCT FROM expected_columns THEN + RAISE EXCEPTION USING + ERRCODE = '55000', + MESSAGE = 'response_event column contract does not match migration 0020'; + END IF; + + SELECT ARRAY( + SELECT format( + '%s:%s', + attribute.attname, + pg_get_expr(default_value.adbin, default_value.adrelid) + ) + FROM pg_attribute AS attribute + JOIN pg_attrdef AS default_value + ON default_value.adrelid = attribute.attrelid + AND default_value.adnum = attribute.attnum + WHERE attribute.attrelid = relation_ref + ORDER BY attribute.attnum + ) INTO actual_defaults; + + SELECT ARRAY( + SELECT format( + '%s:%s', + attribute.attname, + pg_get_expr(default_value.adbin, default_value.adrelid) + ) + FROM pg_attribute AS attribute + JOIN pg_attrdef AS default_value + ON default_value.adrelid = attribute.attrelid + AND default_value.adnum = attribute.attnum + WHERE attribute.attrelid = expected_relation_ref + ORDER BY attribute.attnum + ) INTO expected_defaults; + + IF actual_defaults IS DISTINCT FROM expected_defaults THEN + RAISE EXCEPTION USING + ERRCODE = '55000', + MESSAGE = 'response_event default contract does not match migration 0020'; + END IF; + + SELECT ARRAY( + SELECT format( + '%s:%s:%s:%s:%s', + constraint_record.conname, + constraint_record.contype, + constraint_record.convalidated, + constraint_record.conenforced, + pg_get_constraintdef(constraint_record.oid) + ) + FROM pg_constraint AS constraint_record + WHERE constraint_record.conrelid = relation_ref + AND constraint_record.contype IN ('c', 'f', 'n', 'p', 'u', 'x') + ORDER BY constraint_record.conname + ) INTO actual_constraints; + + SELECT ARRAY( + SELECT format( + '%s:%s:%s:%s:%s', + constraint_record.conname, + constraint_record.contype, + constraint_record.convalidated, + constraint_record.conenforced, + pg_get_constraintdef(constraint_record.oid) + ) + FROM pg_constraint AS constraint_record + WHERE constraint_record.conrelid = expected_relation_ref + AND constraint_record.contype IN ('c', 'f', 'n', 'p', 'u', 'x') + ORDER BY constraint_record.conname + ) INTO expected_constraints; + + IF actual_constraints IS DISTINCT FROM expected_constraints THEN + RAISE EXCEPTION USING + ERRCODE = '55000', + MESSAGE = 'response_event constraint contract does not match migration 0020'; + END IF; +END +$response_event_schema$; From 25dc1cefee8532f63c7394d8ffb83dbc24e609ca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:51:43 +0900 Subject: [PATCH 31/33] docs(evidence): bind PostgreSQL isolation source to controls --- docs/doctoring/standards-and-evidence.md | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/docs/doctoring/standards-and-evidence.md b/docs/doctoring/standards-and-evidence.md index 4de2ba2c..721e5148 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-18 - 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. @@ -74,6 +74,17 @@ Product consequences: - higher-impact AI changes require an impact/risk assessment proportional to intended use and affected participants; - model/prompt/provider drift is observable and does not silently alter historical results. +## PostgreSQL transaction isolation and immutable replay + +PostgreSQL 18 `READ COMMITTED` gives each command a fresh snapshot. Psychometrics Commons persistence adapters that use a conflict-tolerant insert followed by an exact-replay verification read therefore require `READ COMMITTED` when that second command must observe a concurrently committed unique-key winner. They fail closed on stronger transaction isolation instead of assuming that a later command receives a newer snapshot. ADR 0015 records the repository transaction boundary, and PostgreSQL integration tests exercise both exact replay and unsupported-isolation rejection. + +Product consequences: + +- persistence APIs declare their required isolation level as part of the adapter contract; +- an idempotent replay verifies all immutable identity/evidence fields rather than treating a unique-key conflict as success; +- unsupported isolation fails before durable mutation where the adapter depends on command-level snapshot refresh; +- concurrency behavior is verified against the repository-supported PostgreSQL major version rather than inferred from an in-memory mock. + ## Temporal and provenance evidence Longitudinal observations distinguish validity time from source-recorded time, @@ -107,6 +118,8 @@ Product consequences: American Educational Research Association, American Psychological Association, & National Council on Measurement in Education. (2014). *Standards for educational and psychological testing*. American Educational Research Association. https://www.testingstandards.net/ +International Organization for Standardization. (2019). *ISO 8601-1:2019 Date and time—Representations for information interchange—Part 1: Basic rules* (with Amendment 1:2022). https://www.iso.org/standard/70907.html + International Organization for Standardization. (2022). *ISO/IEC 27001:2022 Information security, cybersecurity and privacy protection—Information security management systems—Requirements* (3rd ed.). https://www.iso.org/standard/27001 International Organization for Standardization. (2023a). *ISO/IEC 23894:2023 Information technology—Artificial intelligence—Guidance on risk management*. https://www.iso.org/standard/77304.html @@ -117,12 +130,10 @@ International Organization for Standardization. (2024). *ISO/IEC 27001:2022/Amd International Organization for Standardization. (2025). *ISO/IEC 42005:2025 Information technology—Artificial intelligence (AI)—AI system impact assessment*. https://www.iso.org/standard/42005 -International Organization for Standardization. (2019). *ISO 8601-1:2019 Date and time—Representations for information interchange—Part 1: Basic rules* (with Amendment 1:2022). https://www.iso.org/standard/70907.html +The PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Transaction isolation*. https://www.postgresql.org/docs/18/transaction-iso.html Temoshok, D., Proud-Madruga, D., Choong, Y.-Y., Galluzzo, R., Gupta, S., LaSalle, C., Lefkovitz, N., & Regenscheid, A. (2025). *Digital identity guidelines* (NIST Special Publication 800-63-4). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-63-4 -World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines (WCAG) 2.2* (W3C Recommendation, 12 December 2024). https://www.w3.org/TR/WCAG22/ - World Wide Web Consortium. (2013). *PROV-DM: The PROV data model* (W3C Recommendation, 30 April 2013). https://www.w3.org/TR/prov-dm/ -The PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Transaction isolation*. https://www.postgresql.org/docs/18/transaction-iso.html +World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines (WCAG) 2.2* (W3C Recommendation, 12 December 2024). https://www.w3.org/TR/WCAG22/ From 2fb62fcb299b5308e35b2031c7b25cb9c6aa6422 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:55:15 +0900 Subject: [PATCH 32/33] test(response): reject padded persistence aliases --- ...postgres_response_event_exact_reference.rs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 tests/postgres_response_event_exact_reference.rs diff --git a/tests/postgres_response_event_exact_reference.rs b/tests/postgres_response_event_exact_reference.rs new file mode 100644 index 00000000..26054062 --- /dev/null +++ b/tests/postgres_response_event_exact_reference.rs @@ -0,0 +1,57 @@ +//! Exact-spelling contracts for response-event persistence aliases. + +use postgres::{Client, NoTls}; +use psychometrics_commons_runtime::postgres_response_event::{ + apply_response_event_migration, persist_response_event, ResponseEventPersistenceError, +}; +use psychometrics_commons_runtime::response::ResponseEvent; + +fn test_client() -> Client { + let connection = std::env::var("TEST_DATABASE_URL") + .expect("TEST_DATABASE_URL must identify the isolated CI PostgreSQL database"); + let mut client = Client::connect(&connection, NoTls) + .expect("isolated CI PostgreSQL database must be reachable"); + client + .batch_execute( + "CREATE SCHEMA IF NOT EXISTS response_event_exact_reference_test;\ + SET search_path TO response_event_exact_reference_test;\ + DROP TABLE IF EXISTS response_event;", + ) + .unwrap(); + apply_response_event_migration(&mut client).unwrap(); + client +} + +#[test] +fn persist_rejects_padded_session_alias_before_insert() { + let mut client = test_client(); + let event = ResponseEvent::from_persisted( + "server_event_item_01", + "client_event_item_01", + "item_version_n1_ko", + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + 1, + ) + .unwrap(); + + let mut transaction = client.transaction().unwrap(); + let error = persist_response_event( + &mut transaction, + " session_ipip_ko_quick ", + &event, + 1_700_000_000_000, + 1_700_000_000_250, + ) + .expect_err("padded session aliases must fail instead of collapsing to another spelling"); + + assert!(matches!( + error, + ResponseEventPersistenceError::InvalidReference + )); + let persisted_rows: i64 = transaction + .query_one("SELECT COUNT(*) FROM response_event", &[]) + .unwrap() + .get(0); + assert_eq!(persisted_rows, 0, "a rejected alias must not persist a row"); + transaction.rollback().unwrap(); +} From f59de6b87c04a8141668be3e7b73cf336c343e04 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 08:48:08 +0900 Subject: [PATCH 33/33] test(response): format migration contract --- ...tgres_response_event_migration_contract.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/tests/postgres_response_event_migration_contract.rs b/tests/postgres_response_event_migration_contract.rs index c389c06f..2fc9f816 100644 --- a/tests/postgres_response_event_migration_contract.rs +++ b/tests/postgres_response_event_migration_contract.rs @@ -41,10 +41,19 @@ fn migration_rejects_unicode_numeric_like_reference_aliases() { apply_response_event_migration(&mut client).unwrap(); let columns = [ - ("response_event_ref", "response_event_response_event_ref_format_check"), + ( + "response_event_ref", + "response_event_response_event_ref_format_check", + ), ("session_ref", "response_event_session_ref_format_check"), - ("client_event_ref", "response_event_client_event_ref_format_check"), - ("item_version_ref", "response_event_item_version_ref_format_check"), + ( + "client_event_ref", + "response_event_client_event_ref_format_check", + ), + ( + "item_version_ref", + "response_event_item_version_ref_format_check", + ), ]; let numeric_like_aliases = ["12.34", "12٫34", "12٬34", "12,34"]; let mut case_index = 0_i64; @@ -81,7 +90,9 @@ fn migration_rejects_unicode_numeric_like_reference_aliases() { &case_index, ], ) - .expect_err("Unicode numeric-like aliases must fail the owned reference constraint"); + .expect_err( + "Unicode numeric-like aliases must fail the owned reference constraint", + ); assert_eq!( error .as_db_error()