From 2fc3a36b779895d1be2e972bcfa27b44f1964520 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 01:43:48 -0700 Subject: [PATCH 1/6] test(longitudinal): reject reused Commons observation identity --- ...ongitudinal_observation_record_identity.rs | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 tests/longitudinal_observation_record_identity.rs diff --git a/tests/longitudinal_observation_record_identity.rs b/tests/longitudinal_observation_record_identity.rs new file mode 100644 index 00000000..022cbfa6 --- /dev/null +++ b/tests/longitudinal_observation_record_identity.rs @@ -0,0 +1,68 @@ +//! Regression contract for unique Commons longitudinal observation identities. + +use psychometrics_commons_runtime::longitudinal_observation::{ + LongitudinalObservationError, LongitudinalObservationInput, LongitudinalObservationSet, + MembershipShareInput, ObservationTimeInput, +}; + +static MEMBERSHIPS: [MembershipShareInput<'static>; 1] = [MembershipShareInput { + membership_context_ref: "seoul_clinic_ward_a", + weight_parts_per_10_000: 10_000, +}]; + +fn observation<'a>( + observation_record_ref: &'a str, + source_observation_ref: &'a str, + received_at_unix_ms: u64, + ingested_at_unix_ms: u64, +) -> LongitudinalObservationInput<'a> { + LongitudinalObservationInput { + observation_record_ref, + enrollment_ref: "enrollment_seoul_clinic_2026q3", + source_system_ref: "gyeot_ema_android", + source_observation_ref, + construct_ref: "construct_anxious_mood", + measure_ref: "measure_anxious_mood_ema_v3", + membership_shares: &MEMBERSHIPS, + time: ObservationTimeInput { + validity_start_at_unix_ms: 1_776_297_000_000, + validity_end_at_unix_ms: 1_776_297_000_000, + recorded_at_unix_ms: received_at_unix_ms - 1_000, + received_at_unix_ms, + ingested_at_unix_ms, + timezone_name: "Asia/Seoul", + utc_offset_minutes: 540, + }, + } +} + +#[test] +fn distinct_source_observations_cannot_reuse_one_commons_record_identity() { + let mut observations = LongitudinalObservationSet::new(); + observations + .ingest(observation( + "observation_record_shared_identity", + "gyeot_obs_first", + 1_776_336_120_000, + 1_776_336_180_000, + )) + .expect("first source observation owns the Commons record identity"); + + let conflict = observations + .ingest(observation( + "observation_record_shared_identity", + "gyeot_obs_second", + 1_776_336_240_000, + 1_776_336_300_000, + )) + .expect_err("a distinct source observation must not reuse the Commons record identity"); + + assert_eq!( + conflict, + LongitudinalObservationError::ObservationIdentityConflict + ); + assert!(conflict + .to_string() + .contains("observation-record identity")); + assert_eq!(observations.len(), 1); +} From a2fe87dad1ffa429d79aa9ebb5c488688693ea28 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 01:45:47 -0700 Subject: [PATCH 2/6] fix(longitudinal): keep Commons observation IDs unique --- src/longitudinal_observation.rs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/longitudinal_observation.rs b/src/longitudinal_observation.rs index 890719c2..e116d6ca 100644 --- a/src/longitudinal_observation.rs +++ b/src/longitudinal_observation.rs @@ -233,6 +233,8 @@ pub enum LongitudinalObservationError { NonMonotonicIngestion, /// The same source observation was replayed with different evidence. IdempotencyConflict, + /// A distinct source observation tried to reuse an existing Commons record identity. + ObservationIdentityConflict, } impl Display for LongitudinalObservationError { @@ -274,6 +276,9 @@ impl Display for LongitudinalObservationError { Self::IdempotencyConflict => { "replay the first accepted observation for this source identity; do not last-write-win a new clock" } + Self::ObservationIdentityConflict => { + "mint a new Commons observation-record identity for a distinct source observation; do not reuse an existing record identity" + } }) } } @@ -311,14 +316,17 @@ impl LongitudinalObservationSet { /// /// Source identity is `(enrollment_ref, source_system_ref, source_observation_ref)`. /// An exact retry is a no-op. A retry with different clocks, membership, or - /// construct evidence fails closed. Source clocks that arrive after receipt are - /// flagged and kept. Platform ingest cannot precede receipt or the previous - /// accepted ingest time. + /// construct evidence fails closed. A distinct source observation must also + /// receive a distinct Commons `observation_record_ref`; a reused record identity + /// fails closed instead of aliasing two immutable observations. Source clocks + /// that arrive after receipt are flagged and kept. Platform ingest cannot precede + /// receipt or the previous accepted ingest time. /// /// # Errors /// /// Returns [`LongitudinalObservationError`] when a reference, clock, timezone, - /// membership share, platform order, or source-identity replay is invalid. + /// membership share, platform order, source-identity replay, or Commons record + /// identity reuse is invalid. pub fn ingest( &mut self, input: LongitudinalObservationInput<'_>, @@ -334,6 +342,13 @@ impl LongitudinalObservationSet { } return Err(LongitudinalObservationError::IdempotencyConflict); } + if self + .records + .iter() + .any(|record| record.observation_record_ref == candidate.observation_record_ref) + { + return Err(LongitudinalObservationError::ObservationIdentityConflict); + } if self .records .last() From cf251b590e3d3af1df6ea886261e50bf2d017813 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 01:51:01 -0700 Subject: [PATCH 3/6] test(longitudinal): prove rejected identity reuse preserves original --- tests/longitudinal_observation_record_identity.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/longitudinal_observation_record_identity.rs b/tests/longitudinal_observation_record_identity.rs index 022cbfa6..3fe10a73 100644 --- a/tests/longitudinal_observation_record_identity.rs +++ b/tests/longitudinal_observation_record_identity.rs @@ -65,4 +65,14 @@ fn distinct_source_observations_cannot_reuse_one_commons_record_identity() { .to_string() .contains("observation-record identity")); assert_eq!(observations.len(), 1); -} + + let replay = observations + .ingest(observation( + "observation_record_shared_identity", + "gyeot_obs_first", + 1_776_336_120_000, + 1_776_336_180_000, + )) + .expect("the rejected collision must not replace the accepted record"); + assert_eq!(replay.source_observation_ref(), "gyeot_obs_first"); +} \ No newline at end of file From b9680ed0f6b2d6a8104e547206d69407c49f79f3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 01:52:26 -0700 Subject: [PATCH 4/6] docs(longitudinal): explain record identity rejection plainly --- src/longitudinal_observation.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/longitudinal_observation.rs b/src/longitudinal_observation.rs index e116d6ca..c7fbd40e 100644 --- a/src/longitudinal_observation.rs +++ b/src/longitudinal_observation.rs @@ -316,10 +316,10 @@ impl LongitudinalObservationSet { /// /// Source identity is `(enrollment_ref, source_system_ref, source_observation_ref)`. /// An exact retry is a no-op. A retry with different clocks, membership, or - /// construct evidence fails closed. A distinct source observation must also - /// receive a distinct Commons `observation_record_ref`; a reused record identity - /// fails closed instead of aliasing two immutable observations. Source clocks - /// that arrive after receipt are flagged and kept. Platform ingest cannot precede + /// construct evidence returns an error. Each distinct source observation must use + /// a distinct Commons `observation_record_ref`. If a record identity is reused, + /// `ingest` returns an error and does not add a second record. Source clocks that + /// arrive after receipt are flagged and kept. Platform ingest cannot precede /// receipt or the previous accepted ingest time. /// /// # Errors From 64d88c7079459490f979cb60f8b8ec569ecf510a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 19:49:59 +0900 Subject: [PATCH 5/6] style: format longitudinal identity regression --- tests/longitudinal_observation_record_identity.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/longitudinal_observation_record_identity.rs b/tests/longitudinal_observation_record_identity.rs index 3fe10a73..a09ba88c 100644 --- a/tests/longitudinal_observation_record_identity.rs +++ b/tests/longitudinal_observation_record_identity.rs @@ -61,9 +61,7 @@ fn distinct_source_observations_cannot_reuse_one_commons_record_identity() { conflict, LongitudinalObservationError::ObservationIdentityConflict ); - assert!(conflict - .to_string() - .contains("observation-record identity")); + assert!(conflict.to_string().contains("observation-record identity")); assert_eq!(observations.len(), 1); let replay = observations @@ -75,4 +73,4 @@ fn distinct_source_observations_cannot_reuse_one_commons_record_identity() { )) .expect("the rejected collision must not replace the accepted record"); assert_eq!(replay.source_observation_ref(), "gyeot_obs_first"); -} \ No newline at end of file +} From 31df209059ca1be7b93fb869025fc1c8b139fefb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:13:55 +0900 Subject: [PATCH 6/6] docs(longitudinal): trace observation identity invariant --- docs/TRACEABILITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index dc943c3f..2395872d 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -44,7 +44,7 @@ An active PR, architecture document, conversation decision, or scheduler plan is | Operation-scoped capability health | PRD §7, §13 | `docs/OPERABILITY.md` §3–4; Deployment/Operations | ADR-0011, ADR-0017 | **Implemented** domain health/readiness contract in `src/health.rs` plus `src/postgres_health.rs` PostgreSQL major/write-readiness and caller-declared relation presence; HTTP probes, measured thresholds, and deployment evidence remain Target | | Korean/English exact locale versions | PRD §3.1, §9.9 | TRD §28; instrument release + locale governance | ADR-0013, ADR-0019 | **Partially implemented**: locale is pinned/validated by `src/instrument.rs`; actual English/Korean form content, rights, translation, invariance and serving are Target | | WCAG 2.2 AA supported reference client | PRD §9.10 | TRD §27; Quality Attributes | ADR-0002, ADR-0013 | Target; no reference client implementation on evaluated main | -| EMA/ESM longitudinal flow | PRD §4 | TRD §16; UML longitudinal sequence; logical ERD extension | ADR-0008 | External Gyeot/TEPP dependencies + Target Commons enrollment/orchestration adapter; `src/longitudinal_observation.rs` records validity, recorded, received, and ingested clocks with explicit membership shares. Enrollment state, PostgreSQL persistence, HTTP, Gyeot collection, and TEPP kernels remain Target | +| EMA/ESM longitudinal flow | PRD §4 | TRD §16; UML longitudinal sequence; logical ERD extension | ADR-0008 | External Gyeot/TEPP dependencies + Target Commons enrollment/orchestration adapter; `src/longitudinal_observation.rs` records validity, recorded, received, and ingested clocks with explicit membership shares. Active PR #262 additionally rejects reuse of one Commons `observation_record_ref` for distinct source observations without replacing the accepted record. Enrollment state, PostgreSQL persistence, HTTP, Gyeot collection, and TEPP kernels remain Target | | Measurement Workbench | PRD §6 | C4/component view; UML publication-evidence sequence; Measurement Governance | ADR-0001, ADR-0002, ADR-0004, ADR-0019 | Target; fast-mlsirm/Inkspan/RankWeave are External dependencies | | Headless replaceable clients | PRD §7 | TRD §1, §18; C4 | ADR-0001, ADR-0002 | Architecture established; public transport is Target | | Community/Hosted/Enterprise profiles | PRD §7, §13 | TRD deployment sections; Deployment/Operations | ADR-0011, ADR-0017 | Target deployment packaging/evidence |