diff --git a/CHANGELOG.md b/CHANGELOG.md index b2f801dd..c4e8d88b 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 +- Consented longitudinal enrollment: a participant can join a Gyeot-collected EMA/ESM program only after a tenant-owned participant record and an active longitudinal observation grant. Collection re-checks that grant, so a later revoke stops Gyeot even after resume. Work and home membership stay distinct, research refusal does not block personal enrollment, and pause/resume/withdraw keep the enrollment evidence. - Scoring-job cancel and lease-expiry fallback classification lock the current row until the caller transaction ends, so concurrent workers cannot rewrite terminal or unleased evidence. - PostgreSQL operational-store readiness probe classifies the supported major version and write-readiness, and fails closed when a caller-declared required relation is missing. - PostgreSQL scoring-job cancellation: queued, leased, or retry-scheduled work becomes cancelled without transferring a fence, exact replay is idempotent, and completed or quarantined evidence cannot be rewritten. diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 38abfe2a..44b96503 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -28,6 +28,10 @@ Use these terms consistently across PRD, TRD, ADRs, APIs, diagrams, code, UI, an | **consent form** | Versioned content/policy defining a specific processing purpose and participant decision surface. | | **consent snapshot** | Immutable evidence of one participant's purpose-specific decision under an exact consent-form/scope version. | | **research contribution** | Explicit product-domain opt-in record that makes approved data potentially eligible for research processing under a defined scope. Service use alone does not create one. | +| **longitudinal enrollment** | Product-owned record that a participant joined one versioned EMA/ESM program after granting longitudinal observation consent. Gyeot collection still requires a current longitudinal-observation grant at observation time. It is not a TEPP analysis job. | +| **collection system** | External EMA/ESM collection owner referenced by enrollment. Gyeot is the current collection system of record. | +| **Gyeot** | CWL bounded context that owns participant-facing EMA/ESM collection, offline sync, and momentary observation capture. | +| **TEPP** | CWL bounded context that owns temporal, event, multilevel, and multiple-membership analytical artifacts. It does not collect mobile observations. | | **research participant** | Pseudonymous research-domain identity separated from operational participant identity through a restricted linkage boundary. | | **restricted linkage** | Highly restricted mapping between operational participant identity and research pseudonym identity. Never part of a public release. | | **research staging** | Purpose-limited pseudonymized data projection used for privacy/scientific review before a dataset snapshot is approved. | diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 72bc73c2..f8054d59 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -43,7 +43,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/normalized-ingestion/orchestration adapter | +| EMA/ESM longitudinal flow | PRD §4 | TRD §16; UML longitudinal sequence; logical ERD extension | ADR-0008 | **Target** on evaluated main. Active PR work adds a consented enrollment domain contract; persistence, observation ingestion, live Gyeot/TEPP adapters, and HTTP 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 | @@ -128,10 +128,12 @@ migrations/ └── 0012_integration_consumption.sql ``` -Still-Target logical modules/adapters include remaining product aggregate persistence/repositories, public/admin HTTP and event transports, live fast-mlsirm/Keyverse/Gyeot/TEPP/semantic-data-portal adapters, research-release staging, deterministic narrative mapping, longitudinal normalized ingestion, participant identity-link history persistence, runtime health transports/metrics, and Measurement Workbench orchestration. +Still-Target logical modules/adapters include remaining product aggregate persistence/repositories, public/admin HTTP and event transports, live fast-mlsirm/Keyverse/Gyeot/TEPP/semantic-data-portal adapters, research-release staging, deterministic narrative mapping, longitudinal observation ingestion, participant identity-link history persistence, runtime health transports/metrics, and Measurement Workbench orchestration. ### Active implementation work that is not protected-main truth +**Active PR** #199 longitudinal enrollment is not protected-main truth until an unchanged reviewed/check-clean head is integrated. `src/longitudinal.rs` binds Gyeot program enrollment to a tenant-owned `ParticipantRecord` and an active longitudinal-observation consent snapshot, re-checks that snapshot before collection, preserves explicit multiple-membership contexts, and keeps pause/resume/withdraw fail-closed. Observation ingestion, persistence, and live Gyeot/TEPP adapters remain outside this slice. Do not merge #184; that head authorized collection from enrollment state alone. + **Active PR** #76 data-rights processing-start persistence is not protected-main truth until an unchanged reviewed/check-clean head is integrated. Identity-verified requests persist an immutable operation identity and processing-start time under `FOR UPDATE` so later lifecycle composition cannot race the classified row. Dependent-system execution remains outside this slice. ## 5. ADR traceability by concern diff --git a/docs/adr/0008-gyeot-and-tepp-longitudinal-boundary.md b/docs/adr/0008-gyeot-and-tepp-longitudinal-boundary.md index e5046868..ecb677be 100644 --- a/docs/adr/0008-gyeot-and-tepp-longitudinal-boundary.md +++ b/docs/adr/0008-gyeot-and-tepp-longitudinal-boundary.md @@ -61,6 +61,35 @@ Sync outages leave bounded local queues and clear user state. Clock anomalies ar - **TEPP collects mobile observations directly:** couples modeling to client lifecycle. - **One timestamp and one group per observation:** scientifically invalid for the intended designs. +## As-built versus target + +Active PR #199 adds the product enrollment primitive in `src/longitudinal.rs`. It is `IMPLEMENTED_ON_ACTIVE_PR`, not protected-main truth, until the exact reviewed head is merged. Do not treat #184 as the landing vehicle; that head authorized collection from enrollment state alone. + +As-built on this PR: + +- enrollment requires a tenant-owned `ParticipantRecord` plus an active `ConsentPurpose::LongitudinalObservation` grant and fails closed when that grant is missing or revoked; +- `authorize_collection` re-checks the current consent snapshot, so a later revoke stops Gyeot collection even if the enrollment is still `Enrolled`; +- research refusal does not block personal EMA/ESM enrollment; +- work/home and other membership contexts stay distinct and reject duplicates; +- pause, resume, and withdraw are fail-closed and do not erase enrollment evidence. + +Still target: + +- PostgreSQL enrollment/observation persistence; +- live Gyeot collection and TEPP analysis adapters; +- HTTP enrollment transport; +- observation-time fields (`observed_at`, `recorded_at`, `received_at`, `available_at`, `valid_from` / `valid_to`) on ingested records. + +## References + +Bolger, N., & Laurenceau, J.-P. (2013). *Intensive longitudinal methods: An introduction to diary and experience sampling research*. Guilford Press. + +Curran, P. J., & Bauer, D. J. (2011). The disaggregation of within-person and between-person effects in longitudinal models of change. *Annual Review of Psychology, 62*, 583–619. https://doi.org/10.1146/annurev.psych.093008.100356 + +Diez Roux, A. V. (2002). A glossary for multilevel analysis. *Journal of Epidemiology & Community Health, 56*(8), 588–594. https://doi.org/10.1136/jech.56.8.588 + +Hamaker, E. L., & Wichers, M. (2017). No time like the present: Discovering the hidden dynamics in intensive longitudinal data. *Current Directions in Psychological Science, 26*(1), 10–15. https://doi.org/10.1177/0963721416666518 + ## Reversal conditions Collection or analysis implementations may change if their contracts remain stable. Revisit the boundary only if one component ceases independent use and the combined ownership demonstrably reduces rather than increases coupling. diff --git a/docs/architecture/ERD.md b/docs/architecture/ERD.md index 8f21954a..fda3a2be 100644 --- a/docs/architecture/ERD.md +++ b/docs/architecture/ERD.md @@ -49,7 +49,9 @@ erDiagram dataset_snapshot ||--o{ dataset_snapshot_member : contains dataset_snapshot ||--o{ research_release : released_as + tenant_account ||--o{ longitudinal_enrollment : scopes assessment_participant ||--o{ longitudinal_enrollment : enrolls + longitudinal_enrollment ||--o{ enrollment_membership_context : declares longitudinal_enrollment ||--o{ longitudinal_observation_record : ingests longitudinal_enrollment ||--o{ temporal_analysis_submission : submits @@ -309,6 +311,7 @@ erDiagram longitudinal_enrollment { string enrollment_ref PK + string tenant_ref FK string participant_ref FK string program_ref string consent_snapshot_ref @@ -318,6 +321,13 @@ erDiagram timestamp latest_event_at } + enrollment_membership_context { + string membership_assignment_ref PK + string enrollment_ref FK + string membership_context_ref + int declaration_order + } + longitudinal_observation_record { string observation_record_ref PK string enrollment_ref FK @@ -427,7 +437,7 @@ The target ERD deliberately includes several logical entities that are not yet p - `item_delivery_event` reflects the already-merged `src/item_delivery.rs` domain primitive; durable persistence/API orchestration is still 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. +- `longitudinal_enrollment`, `enrollment_membership_context`, `longitudinal_observation_record`, and `temporal_analysis_submission` make the ADR-0008 Commons-owned Gyeot/TEPP orchestration boundary explicit. Membership contexts stay in a child table so work and home are not flattened onto the enrollment row. No TEPP analytical kernel is duplicated here. - `integration_outbox`, `integration_delivery_attempt`, `integration_inbox`, and `integration_consumption` reflect `src/integration.rs` domain semantics. Outbox/inbox/delivery-attempt tables are on protected main; `integration_consumption` pending/processing/completed/quarantined persistence and expire-and-reclaim of a crashed processing claim exist only on this Active PR until merged. This section is a maturity guard: a logical entity may be architecture-complete without being as-built database evidence. @@ -507,7 +517,8 @@ Requirements: The Commons longitudinal tables are orchestration/evidence records only: -- `longitudinal_enrollment` binds product participant, program, consent, and collection-system references; +- `longitudinal_enrollment` binds tenant, product participant, program, consent, and collection-system references; +- `enrollment_membership_context` stores each declared membership once, in declaration order, so later TEPP analysis is not forced into one primary group; - `longitudinal_observation_record` stores normalized observation identity/time/construct/version/context references required to reproduce a submission, not a duplicate Gyeot application database; - `temporal_analysis_submission` records exact observation-set digest, TEPP analysis specification, lifecycle, and returned artifact reference. diff --git a/src/consent.rs b/src/consent.rs index 718e7fb9..8410e1eb 100644 --- a/src/consent.rs +++ b/src/consent.rs @@ -131,8 +131,18 @@ impl ConsentSnapshot { /// Return whether the latest decision for `purpose` is an active grant. #[must_use] pub fn is_granted(&self, purpose: ConsentPurpose) -> bool { - self.latest_event(purpose) - .is_some_and(|event| event.decision == ConsentDecision::Granted) + self.active_granted_at(purpose).is_some() + } + + /// Return the server time of the latest active grant for `purpose`. + /// + /// A revoked or never-granted purpose returns `None` so enrollment and + /// other purpose-bound commands can fail closed before they start work. + #[must_use] + pub fn active_granted_at(&self, purpose: ConsentPurpose) -> Option { + self.latest_event(purpose).and_then(|event| { + (event.decision == ConsentDecision::Granted).then_some(event.occurred_at_unix_ms) + }) } /// Return the consent-form version for an active grant, if present. diff --git a/src/lib.rs b/src/lib.rs index 8b586a68..e4f406ae 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,6 +17,7 @@ pub mod health; pub mod instrument; pub mod integration; pub mod item_delivery; +pub mod longitudinal; pub mod narrative; pub mod participant; pub mod postgres_consent; diff --git a/src/longitudinal.rs b/src/longitudinal.rs new file mode 100644 index 00000000..c10ded36 --- /dev/null +++ b/src/longitudinal.rs @@ -0,0 +1,425 @@ +//! Consented longitudinal program enrollment for Gyeot-collected EMA/ESM. +//! +//! This module owns product enrollment, purpose-specific consent binding, and +//! explicit multiple-membership context. It does not collect mobile observations, +//! implement TEPP temporal or multilevel kernels, or rewrite historical +//! enrollment evidence after withdrawal. + +use crate::consent::{ConsentPurpose, ConsentSnapshot}; +use crate::participant::ParticipantRecord; +use crate::reference::normalized_reference; +use std::collections::HashSet; +use std::error::Error; +use std::fmt::{Display, Formatter}; + +/// Lifecycle state of one consented longitudinal program enrollment. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum EnrollmentState { + /// The participant is enrolled and Gyeot may collect observations. + Enrolled, + /// Collection is paused while the enrollment and membership evidence remain. + Paused, + /// The participant left the program. Historical enrollment evidence remains. + Withdrawn, +} + +/// Borrowed input for one new longitudinal enrollment. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LongitudinalEnrollmentInput<'a> { + /// Opaque enrollment identity minted by the product runtime. + pub enrollment_ref: &'a str, + /// Tenant that owns the participant and program. + pub tenant_ref: &'a str, + /// Operational participant who granted longitudinal observation consent. + pub participant_ref: &'a str, + /// Versioned EMA/ESM program the participant is joining. + pub program_ref: &'a str, + /// Collection-system reference. Gyeot owns collection; this is not a TEPP id. + pub collection_system_ref: &'a str, + /// Explicit multiple-membership contexts. Duplicates are rejected. + pub membership_context_refs: &'a [&'a str], + /// Server-authoritative enrollment time as Unix milliseconds. + pub enrolled_at_unix_ms: u64, +} + +/// Product-owned enrollment that authorizes Gyeot collection for one program. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LongitudinalEnrollment { + enrollment_ref: String, + tenant_ref: String, + participant_ref: String, + program_ref: String, + collection_system_ref: String, + consent_snapshot_ref: String, + membership_context_refs: Vec, + state: EnrollmentState, + enrolled_at_unix_ms: u64, + latest_event_ref: Option, + latest_event_at_unix_ms: u64, +} + +/// Fail-closed error for longitudinal enrollment commands. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum LongitudinalEnrollmentError { + /// A required reference was blank or numeric-like after normalization. + EmptyReference, + /// The consent snapshot belongs to a different operational participant. + ParticipantMismatch, + /// Longitudinal observation consent is missing or revoked. + LongitudinalConsentRequired, + /// Enrollment time is zero or not after the authorizing consent grant. + InvalidStartTime, + /// The same membership context was declared more than once. + DuplicateMembershipContext, + /// The requested pause, resume, or withdraw is not legal in this state. + InvalidTransition, + /// A later command used a server time at or before the last event. + NonMonotonicTimestamp, + /// The enrollment is already withdrawn and the new evidence does not match. + AlreadyWithdrawn, + /// The caller tenant does not own the participant record. + CrossTenantDenied, +} + +impl Display for LongitudinalEnrollmentError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::EmptyReference => { + "copy an opaque enrollment, tenant, participant, program, collection-system, membership, or event reference instead of a blank or numeric id" + } + Self::ParticipantMismatch => { + "use the consent snapshot that belongs to this participant" + } + Self::LongitudinalConsentRequired => { + "ask the participant to grant longitudinal observation consent before enrollment" + } + Self::InvalidStartTime => { + "enroll only after the longitudinal consent grant, with a non-zero server time" + } + Self::DuplicateMembershipContext => { + "declare each membership context once; do not collapse duplicates into one group" + } + Self::InvalidTransition => { + "use pause only while enrolled, resume only while paused, and withdraw from an open enrollment" + } + Self::NonMonotonicTimestamp => { + "use a later server time than the last enrollment event" + } + Self::AlreadyWithdrawn => { + "this enrollment is already withdrawn; replay the same withdrawal evidence or start a new enrollment" + } + Self::CrossTenantDenied => { + "enroll this participant only under the tenant that owns the participant record" + } + }) + } +} + +impl Error for LongitudinalEnrollmentError {} + +impl LongitudinalEnrollment { + /// Enroll one participant in a Gyeot-collected program after consent. + /// + /// Research refusal does not block personal EMA/ESM enrollment. Membership + /// contexts stay distinct so later TEPP analysis is not flattened to one + /// primary group. + /// + /// # Errors + /// + /// Returns [`LongitudinalEnrollmentError`] when a reference is invalid, the + /// participant record does not own the tenant or participant, the snapshot + /// belongs to another participant, longitudinal consent is missing or + /// revoked, enrollment time is not after the grant, or a membership context + /// is duplicated. + pub fn enroll( + input: LongitudinalEnrollmentInput<'_>, + participant: &ParticipantRecord, + snapshot: &ConsentSnapshot, + ) -> Result { + let enrollment_ref = required_reference(input.enrollment_ref)?; + let tenant_ref = required_reference(input.tenant_ref)?; + let participant_ref = required_reference(input.participant_ref)?; + let program_ref = required_reference(input.program_ref)?; + let collection_system_ref = required_reference(input.collection_system_ref)?; + if participant.participant_ref() != participant_ref + || snapshot.participant_ref() != participant_ref + { + return Err(LongitudinalEnrollmentError::ParticipantMismatch); + } + if participant.tenant_ref() != tenant_ref { + return Err(LongitudinalEnrollmentError::CrossTenantDenied); + } + let granted_at = snapshot + .active_granted_at(ConsentPurpose::LongitudinalObservation) + .ok_or(LongitudinalEnrollmentError::LongitudinalConsentRequired)?; + if input.enrolled_at_unix_ms == 0 || input.enrolled_at_unix_ms <= granted_at { + return Err(LongitudinalEnrollmentError::InvalidStartTime); + } + let membership_context_refs = unique_memberships(input.membership_context_refs)?; + + Ok(Self { + enrollment_ref: enrollment_ref.to_owned(), + tenant_ref: tenant_ref.to_owned(), + participant_ref: participant_ref.to_owned(), + program_ref: program_ref.to_owned(), + collection_system_ref: collection_system_ref.to_owned(), + consent_snapshot_ref: snapshot.snapshot_ref().to_owned(), + membership_context_refs, + state: EnrollmentState::Enrolled, + enrolled_at_unix_ms: input.enrolled_at_unix_ms, + latest_event_ref: None, + latest_event_at_unix_ms: input.enrolled_at_unix_ms, + }) + } + + /// Return the opaque enrollment identity. + #[must_use] + pub fn enrollment_ref(&self) -> &str { + &self.enrollment_ref + } + + /// Return the tenant that owns this enrollment. + #[must_use] + pub fn tenant_ref(&self) -> &str { + &self.tenant_ref + } + + /// Return the operational participant bound to this enrollment. + #[must_use] + pub fn participant_ref(&self) -> &str { + &self.participant_ref + } + + /// Return the versioned program the participant joined. + #[must_use] + pub fn program_ref(&self) -> &str { + &self.program_ref + } + + /// Return the Gyeot collection-system reference. + #[must_use] + pub fn collection_system_ref(&self) -> &str { + &self.collection_system_ref + } + + /// Return the consent snapshot that authorized enrollment. + #[must_use] + pub fn consent_snapshot_ref(&self) -> &str { + &self.consent_snapshot_ref + } + + /// Return explicit membership contexts in declaration order. + #[must_use] + pub fn membership_context_refs(&self) -> &[String] { + &self.membership_context_refs + } + + /// Return the current enrollment lifecycle state. + #[must_use] + pub const fn state(&self) -> EnrollmentState { + self.state + } + + /// Return when enrollment began as Unix milliseconds. + #[must_use] + pub const fn enrolled_at_unix_ms(&self) -> u64 { + self.enrolled_at_unix_ms + } + + /// Return the latest pause, resume, or withdraw event reference. + #[must_use] + pub fn latest_event_ref(&self) -> Option<&str> { + self.latest_event_ref.as_deref() + } + + /// Return the latest enrollment-event time as Unix milliseconds. + #[must_use] + pub const fn latest_event_at_unix_ms(&self) -> u64 { + self.latest_event_at_unix_ms + } + + /// Authorize Gyeot collection from the current consent snapshot. + /// + /// Enrollment state alone is not enough. A later longitudinal revoke, a + /// snapshot for another participant, a pause, or a withdrawal fail closed. + /// + /// # Errors + /// + /// Returns [`LongitudinalEnrollmentError`] when the snapshot belongs to + /// another participant, the enrollment is paused or withdrawn, or + /// longitudinal observation consent is missing or revoked. + pub fn authorize_collection( + &self, + snapshot: &ConsentSnapshot, + ) -> Result<(), LongitudinalEnrollmentError> { + if snapshot.participant_ref() != self.participant_ref { + return Err(LongitudinalEnrollmentError::ParticipantMismatch); + } + match self.state { + EnrollmentState::Withdrawn => Err(LongitudinalEnrollmentError::AlreadyWithdrawn), + EnrollmentState::Paused => Err(LongitudinalEnrollmentError::InvalidTransition), + EnrollmentState::Enrolled => snapshot + .active_granted_at(ConsentPurpose::LongitudinalObservation) + .ok_or(LongitudinalEnrollmentError::LongitudinalConsentRequired) + .map(|_| ()), + } + } + + /// Pause collection while keeping enrollment and membership evidence. + /// + /// Exact replay of the same pause evidence is idempotent. + /// + /// # Errors + /// + /// Returns [`LongitudinalEnrollmentError`] for a blank event reference, a + /// withdrawn enrollment, a pause that is not later than the last event, or + /// a pause attempted while already paused with different evidence. + pub fn pause( + &self, + event_ref: &str, + paused_at_unix_ms: u64, + ) -> Result { + let event_ref = required_reference(event_ref)?; + match self.state { + EnrollmentState::Withdrawn => Err(LongitudinalEnrollmentError::AlreadyWithdrawn), + EnrollmentState::Paused => { + self.exact_replay(event_ref, paused_at_unix_ms, EnrollmentState::Paused) + } + EnrollmentState::Enrolled => { + self.advance(event_ref, paused_at_unix_ms, EnrollmentState::Paused) + } + } + } + + /// Resume collection after a pause. + /// + /// Exact replay of the same resume evidence is idempotent. + /// + /// # Errors + /// + /// Returns [`LongitudinalEnrollmentError`] for a blank event reference, a + /// withdrawn enrollment, a resume that is not later than the last event, or + /// a resume attempted while already enrolled with different evidence. + pub fn resume( + &self, + event_ref: &str, + resumed_at_unix_ms: u64, + ) -> Result { + let event_ref = required_reference(event_ref)?; + match self.state { + EnrollmentState::Withdrawn => Err(LongitudinalEnrollmentError::AlreadyWithdrawn), + EnrollmentState::Enrolled => { + self.exact_replay(event_ref, resumed_at_unix_ms, EnrollmentState::Enrolled) + } + EnrollmentState::Paused => { + self.advance(event_ref, resumed_at_unix_ms, EnrollmentState::Enrolled) + } + } + } + + /// Withdraw from the program without erasing enrollment evidence. + /// + /// Exact replay of the same withdrawal evidence is idempotent. + /// + /// # Errors + /// + /// Returns [`LongitudinalEnrollmentError`] for a blank event reference, a + /// withdrawal that is not later than the last event, or a second + /// conflicting withdrawal. + pub fn withdraw( + &self, + event_ref: &str, + withdrawn_at_unix_ms: u64, + ) -> Result { + let event_ref = required_reference(event_ref)?; + if self.state == EnrollmentState::Withdrawn { + return self.exact_replay(event_ref, withdrawn_at_unix_ms, EnrollmentState::Withdrawn); + } + self.advance(event_ref, withdrawn_at_unix_ms, EnrollmentState::Withdrawn) + } + + fn exact_replay( + &self, + event_ref: &str, + event_at_unix_ms: u64, + expected_state: EnrollmentState, + ) -> Result { + if self.state == expected_state + && self.latest_event_ref.as_deref() == Some(event_ref) + && self.latest_event_at_unix_ms == event_at_unix_ms + { + return Ok(self.clone()); + } + if self.state == EnrollmentState::Withdrawn { + return Err(LongitudinalEnrollmentError::AlreadyWithdrawn); + } + if event_at_unix_ms <= self.latest_event_at_unix_ms { + return Err(LongitudinalEnrollmentError::NonMonotonicTimestamp); + } + Err(LongitudinalEnrollmentError::InvalidTransition) + } + + fn advance( + &self, + event_ref: &str, + event_at_unix_ms: u64, + target: EnrollmentState, + ) -> Result { + if event_at_unix_ms <= self.latest_event_at_unix_ms { + return Err(LongitudinalEnrollmentError::NonMonotonicTimestamp); + } + Ok(self.with_event(event_ref, event_at_unix_ms, target)) + } + + fn with_event(&self, event_ref: &str, event_at_unix_ms: u64, state: EnrollmentState) -> Self { + let mut next = self.clone(); + next.state = state; + next.latest_event_ref = Some(event_ref.to_owned()); + next.latest_event_at_unix_ms = event_at_unix_ms; + next + } +} + +fn required_reference(reference: &str) -> Result<&str, LongitudinalEnrollmentError> { + normalized_reference(reference).ok_or(LongitudinalEnrollmentError::EmptyReference) +} + +fn unique_memberships( + membership_context_refs: &[&str], +) -> Result, LongitudinalEnrollmentError> { + let mut seen = HashSet::with_capacity(membership_context_refs.len()); + let mut normalized = Vec::with_capacity(membership_context_refs.len()); + for membership_ref in membership_context_refs { + let membership_ref = required_reference(membership_ref)?; + if !seen.insert(membership_ref.to_owned()) { + return Err(LongitudinalEnrollmentError::DuplicateMembershipContext); + } + normalized.push(membership_ref.to_owned()); + } + Ok(normalized) +} + +#[cfg(test)] +mod enrollment_error_source_tests { + use super::LongitudinalEnrollmentError; + use std::error::Error; + + #[test] + fn enrollment_errors_carry_no_nested_source() { + for error in [ + LongitudinalEnrollmentError::EmptyReference, + LongitudinalEnrollmentError::ParticipantMismatch, + LongitudinalEnrollmentError::LongitudinalConsentRequired, + LongitudinalEnrollmentError::InvalidStartTime, + LongitudinalEnrollmentError::DuplicateMembershipContext, + LongitudinalEnrollmentError::InvalidTransition, + LongitudinalEnrollmentError::NonMonotonicTimestamp, + LongitudinalEnrollmentError::AlreadyWithdrawn, + LongitudinalEnrollmentError::CrossTenantDenied, + ] { + assert!(error.source().is_none()); + } + } +} diff --git a/tests/documentation_architecture_contract.rs b/tests/documentation_architecture_contract.rs index 7b5c6fad..f44917d5 100644 --- a/tests/documentation_architecture_contract.rs +++ b/tests/documentation_architecture_contract.rs @@ -308,6 +308,7 @@ fn erd_covers_current_delivery_identity_and_longitudinal_boundaries() { "item_delivery_event", "participant_identity_link", "longitudinal_enrollment", + "enrollment_membership_context", "longitudinal_observation_record", "temporal_analysis_submission", ] { diff --git a/tests/longitudinal_enrollment_contract.rs b/tests/longitudinal_enrollment_contract.rs new file mode 100644 index 00000000..2de28328 --- /dev/null +++ b/tests/longitudinal_enrollment_contract.rs @@ -0,0 +1,479 @@ +//! Realistic enrollment contracts for Gyeot-collected EMA/ESM programs. +//! +//! A Seoul clinic participant can start a 14-day mood diary only after granting +//! longitudinal observation consent. Work and home membership stay distinct so +//! later TEPP analysis is not forced into one primary group. + +use psychometrics_commons_runtime::consent::{ + ConsentDecision, ConsentEventInput, ConsentLedger, ConsentPurpose, ConsentSnapshot, +}; +use psychometrics_commons_runtime::longitudinal::{ + EnrollmentState, LongitudinalEnrollment, LongitudinalEnrollmentError, + LongitudinalEnrollmentInput, +}; +use psychometrics_commons_runtime::participant::ParticipantRecord; + +fn seoul_participant() -> ParticipantRecord { + ParticipantRecord::new_anonymous( + "participant_clinic_seoul", + "tenant_clinic_seoul", + 1_724_000_000_000, + ) + .unwrap() +} + +fn granted_longitudinal_ledger() -> (ConsentLedger, ConsentSnapshot) { + let mut ledger = ConsentLedger::new("participant_clinic_seoul").unwrap(); + ledger + .record(ConsentEventInput { + event_ref: "consent_event_service", + purpose: ConsentPurpose::ServiceOperation, + decision: ConsentDecision::Granted, + consent_form_version_ref: "service_form_ko_v1", + research_scope_ref: None, + occurred_at_unix_ms: 1_724_000_000_000, + }) + .unwrap(); + ledger + .record(ConsentEventInput { + event_ref: "consent_event_longitudinal", + purpose: ConsentPurpose::LongitudinalObservation, + decision: ConsentDecision::Granted, + consent_form_version_ref: "ema_mood_form_ko_v1", + research_scope_ref: None, + occurred_at_unix_ms: 1_724_000_100_000, + }) + .unwrap(); + let snapshot = ledger.snapshot_as("consent_snapshot_ema_seoul").unwrap(); + (ledger, snapshot) +} + +fn granted_longitudinal_snapshot() -> ConsentSnapshot { + granted_longitudinal_ledger().1 +} + +fn enroll_seoul(snapshot: &ConsentSnapshot) -> LongitudinalEnrollment { + LongitudinalEnrollment::enroll(seoul_mood_enrollment(), &seoul_participant(), snapshot).unwrap() +} + +fn seoul_mood_enrollment() -> LongitudinalEnrollmentInput<'static> { + LongitudinalEnrollmentInput { + enrollment_ref: "enrollment_mood_diary_seoul", + tenant_ref: "tenant_clinic_seoul", + participant_ref: "participant_clinic_seoul", + program_ref: "program_mood_diary_14_day", + collection_system_ref: "gyeot_collection_seoul", + membership_context_refs: &["membership_work_clinic", "membership_home_household"], + enrolled_at_unix_ms: 1_724_000_200_000, + } +} + +#[test] +fn seoul_clinic_ema_enrolls_after_longitudinal_consent_with_distinct_memberships() { + let snapshot = granted_longitudinal_snapshot(); + assert_eq!( + snapshot.active_granted_at(ConsentPurpose::LongitudinalObservation), + Some(1_724_000_100_000) + ); + + let enrollment = enroll_seoul(&snapshot); + + assert_eq!(enrollment.enrollment_ref(), "enrollment_mood_diary_seoul"); + assert_eq!(enrollment.tenant_ref(), "tenant_clinic_seoul"); + assert_eq!(enrollment.participant_ref(), "participant_clinic_seoul"); + assert_eq!(enrollment.program_ref(), "program_mood_diary_14_day"); + assert_eq!(enrollment.collection_system_ref(), "gyeot_collection_seoul"); + assert_eq!( + enrollment.consent_snapshot_ref(), + "consent_snapshot_ema_seoul" + ); + assert_eq!( + enrollment.membership_context_refs(), + &[ + "membership_work_clinic".to_owned(), + "membership_home_household".to_owned() + ] + ); + assert_eq!(enrollment.state(), EnrollmentState::Enrolled); + assert_eq!(enrollment.enrolled_at_unix_ms(), 1_724_000_200_000); + assert_eq!(enrollment.latest_event_at_unix_ms(), 1_724_000_200_000); + assert_eq!(enrollment.authorize_collection(&snapshot), Ok(())); +} + +#[test] +fn research_refusal_does_not_block_personal_ema_enrollment() { + let mut ledger = ConsentLedger::new("participant_clinic_seoul").unwrap(); + ledger + .record(ConsentEventInput { + event_ref: "consent_event_longitudinal", + purpose: ConsentPurpose::LongitudinalObservation, + decision: ConsentDecision::Granted, + consent_form_version_ref: "ema_mood_form_ko_v1", + research_scope_ref: None, + occurred_at_unix_ms: 1_724_000_100_000, + }) + .unwrap(); + ledger + .record(ConsentEventInput { + event_ref: "consent_event_research_refuse", + purpose: ConsentPurpose::ResearchContribution, + decision: ConsentDecision::Revoked, + consent_form_version_ref: "research_form_ko_v1", + research_scope_ref: Some("research_scope_big_five_ko"), + occurred_at_unix_ms: 1_724_000_150_000, + }) + .unwrap(); + let snapshot = ledger + .snapshot_as("consent_snapshot_personal_only") + .unwrap(); + + let enrollment = enroll_seoul(&snapshot); + assert_eq!(enrollment.state(), EnrollmentState::Enrolled); + assert_eq!(enrollment.authorize_collection(&snapshot), Ok(())); +} + +#[test] +fn missing_or_revoked_longitudinal_consent_fails_closed() { + let mut ledger = ConsentLedger::new("participant_clinic_seoul").unwrap(); + ledger + .record(ConsentEventInput { + event_ref: "consent_event_service", + purpose: ConsentPurpose::ServiceOperation, + decision: ConsentDecision::Granted, + consent_form_version_ref: "service_form_ko_v1", + research_scope_ref: None, + occurred_at_unix_ms: 1_724_000_000_000, + }) + .unwrap(); + let service_only = ledger.snapshot_as("consent_snapshot_service_only").unwrap(); + assert_eq!( + LongitudinalEnrollment::enroll( + seoul_mood_enrollment(), + &seoul_participant(), + &service_only + ), + Err(LongitudinalEnrollmentError::LongitudinalConsentRequired) + ); + + ledger + .record(ConsentEventInput { + event_ref: "consent_event_longitudinal", + purpose: ConsentPurpose::LongitudinalObservation, + decision: ConsentDecision::Granted, + consent_form_version_ref: "ema_mood_form_ko_v1", + research_scope_ref: None, + occurred_at_unix_ms: 1_724_000_100_000, + }) + .unwrap(); + ledger + .record(ConsentEventInput { + event_ref: "consent_event_longitudinal_revoke", + purpose: ConsentPurpose::LongitudinalObservation, + decision: ConsentDecision::Revoked, + consent_form_version_ref: "ema_mood_form_ko_v1", + research_scope_ref: None, + occurred_at_unix_ms: 1_724_000_180_000, + }) + .unwrap(); + let revoked = ledger.snapshot_as("consent_snapshot_revoked").unwrap(); + assert_eq!( + revoked.active_granted_at(ConsentPurpose::LongitudinalObservation), + None + ); + assert_eq!( + LongitudinalEnrollment::enroll(seoul_mood_enrollment(), &seoul_participant(), &revoked), + Err(LongitudinalEnrollmentError::LongitudinalConsentRequired) + ); +} + +#[test] +fn enrollment_rejects_mismatched_participant_early_time_and_duplicate_membership() { + let snapshot = granted_longitudinal_snapshot(); + let mut other_person = seoul_mood_enrollment(); + other_person.participant_ref = "participant_other_clinic"; + assert_eq!( + LongitudinalEnrollment::enroll(other_person, &seoul_participant(), &snapshot), + Err(LongitudinalEnrollmentError::ParticipantMismatch) + ); + + let mut too_early = seoul_mood_enrollment(); + too_early.enrolled_at_unix_ms = 1_724_000_100_000; + assert_eq!( + LongitudinalEnrollment::enroll(too_early, &seoul_participant(), &snapshot), + Err(LongitudinalEnrollmentError::InvalidStartTime) + ); + + let mut zero_time = seoul_mood_enrollment(); + zero_time.enrolled_at_unix_ms = 0; + assert_eq!( + LongitudinalEnrollment::enroll(zero_time, &seoul_participant(), &snapshot), + Err(LongitudinalEnrollmentError::InvalidStartTime) + ); + + let mut duplicate_membership = seoul_mood_enrollment(); + duplicate_membership.membership_context_refs = + &["membership_work_clinic", " membership_work_clinic "]; + assert_eq!( + LongitudinalEnrollment::enroll(duplicate_membership, &seoul_participant(), &snapshot), + Err(LongitudinalEnrollmentError::DuplicateMembershipContext) + ); +} + +#[test] +fn blank_or_numeric_enrollment_references_fail_closed() { + let snapshot = granted_longitudinal_snapshot(); + for (mut input, _label) in [ + ( + LongitudinalEnrollmentInput { + enrollment_ref: "12", + ..seoul_mood_enrollment() + }, + "enrollment", + ), + ( + LongitudinalEnrollmentInput { + tenant_ref: " ", + ..seoul_mood_enrollment() + }, + "tenant", + ), + ( + LongitudinalEnrollmentInput { + program_ref: "1.0e3", + ..seoul_mood_enrollment() + }, + "program", + ), + ( + LongitudinalEnrollmentInput { + collection_system_ref: "", + ..seoul_mood_enrollment() + }, + "collection", + ), + ] { + input.membership_context_refs = &["membership_work_clinic"]; + assert_eq!( + LongitudinalEnrollment::enroll(input, &seoul_participant(), &snapshot), + Err(LongitudinalEnrollmentError::EmptyReference) + ); + } + + let mut blank_membership = seoul_mood_enrollment(); + blank_membership.membership_context_refs = &[" "]; + assert_eq!( + LongitudinalEnrollment::enroll(blank_membership, &seoul_participant(), &snapshot), + Err(LongitudinalEnrollmentError::EmptyReference) + ); + + let mut no_membership = seoul_mood_enrollment(); + no_membership.membership_context_refs = &[]; + let enrollment = + LongitudinalEnrollment::enroll(no_membership, &seoul_participant(), &snapshot).unwrap(); + assert!(enrollment.membership_context_refs().is_empty()); + assert_eq!( + enrollment.pause(" ", 1_724_000_300_000), + Err(LongitudinalEnrollmentError::EmptyReference) + ); +} + +#[test] +fn pause_resume_and_withdraw_keep_history_and_reject_illegal_moves() { + let snapshot = granted_longitudinal_snapshot(); + let enrolled = enroll_seoul(&snapshot); + + let paused = enrolled + .pause("enrollment_event_pause", 1_724_000_300_000) + .unwrap(); + assert_eq!(paused.state(), EnrollmentState::Paused); + assert_eq!( + paused.authorize_collection(&snapshot), + Err(LongitudinalEnrollmentError::InvalidTransition) + ); + assert_eq!(paused.latest_event_ref(), Some("enrollment_event_pause")); + assert_eq!(paused.enrolled_at_unix_ms(), 1_724_000_200_000); + + assert_eq!( + paused.pause("enrollment_event_pause", 1_724_000_300_000), + Ok(paused.clone()) + ); + assert_eq!( + paused.pause("enrollment_event_pause_other", 1_724_000_310_000), + Err(LongitudinalEnrollmentError::InvalidTransition) + ); + assert_eq!( + enrolled.resume("enrollment_event_resume_early", 1_724_000_300_000), + Err(LongitudinalEnrollmentError::InvalidTransition) + ); + assert_eq!( + enrolled.resume("enrollment_event_resume_early", 1_724_000_100_000), + Err(LongitudinalEnrollmentError::NonMonotonicTimestamp) + ); + assert_eq!( + enrolled.withdraw(" ", 1_724_000_300_000), + Err(LongitudinalEnrollmentError::EmptyReference) + ); + + let resumed = paused + .resume("enrollment_event_resume", 1_724_000_400_000) + .unwrap(); + assert_eq!(resumed.state(), EnrollmentState::Enrolled); + assert_eq!(resumed.authorize_collection(&snapshot), Ok(())); + assert_eq!( + resumed.resume("enrollment_event_resume", 1_724_000_400_000), + Ok(resumed.clone()) + ); + + assert_eq!( + resumed.pause("enrollment_event_pause_late", 1_724_000_350_000), + Err(LongitudinalEnrollmentError::NonMonotonicTimestamp) + ); + + let withdrawn = resumed + .withdraw("enrollment_event_withdraw", 1_724_000_500_000) + .unwrap(); + assert_eq!(withdrawn.state(), EnrollmentState::Withdrawn); + assert_eq!( + withdrawn.authorize_collection(&snapshot), + Err(LongitudinalEnrollmentError::AlreadyWithdrawn) + ); + assert_eq!(withdrawn.program_ref(), "program_mood_diary_14_day"); + assert_eq!( + withdrawn.withdraw("enrollment_event_withdraw", 1_724_000_500_000), + Ok(withdrawn.clone()) + ); + assert_eq!( + withdrawn.withdraw("enrollment_event_withdraw_other", 1_724_000_600_000), + Err(LongitudinalEnrollmentError::AlreadyWithdrawn) + ); + assert_eq!( + withdrawn.pause("enrollment_event_pause_after", 1_724_000_600_000), + Err(LongitudinalEnrollmentError::AlreadyWithdrawn) + ); + assert_eq!( + withdrawn.resume("enrollment_event_resume_after", 1_724_000_600_000), + Err(LongitudinalEnrollmentError::AlreadyWithdrawn) + ); +} + +#[test] +fn enrollment_error_text_tells_the_operator_the_next_safe_action() { + assert_eq!( + LongitudinalEnrollmentError::EmptyReference.to_string(), + "copy an opaque enrollment, tenant, participant, program, collection-system, membership, or event reference instead of a blank or numeric id" + ); + assert_eq!( + LongitudinalEnrollmentError::ParticipantMismatch.to_string(), + "use the consent snapshot that belongs to this participant" + ); + assert_eq!( + LongitudinalEnrollmentError::LongitudinalConsentRequired.to_string(), + "ask the participant to grant longitudinal observation consent before enrollment" + ); + assert_eq!( + LongitudinalEnrollmentError::InvalidStartTime.to_string(), + "enroll only after the longitudinal consent grant, with a non-zero server time" + ); + assert_eq!( + LongitudinalEnrollmentError::DuplicateMembershipContext.to_string(), + "declare each membership context once; do not collapse duplicates into one group" + ); + assert_eq!( + LongitudinalEnrollmentError::InvalidTransition.to_string(), + "use pause only while enrolled, resume only while paused, and withdraw from an open enrollment" + ); + assert_eq!( + LongitudinalEnrollmentError::NonMonotonicTimestamp.to_string(), + "use a later server time than the last enrollment event" + ); + assert_eq!( + LongitudinalEnrollmentError::AlreadyWithdrawn.to_string(), + "this enrollment is already withdrawn; replay the same withdrawal evidence or start a new enrollment" + ); + assert_eq!( + LongitudinalEnrollmentError::CrossTenantDenied.to_string(), + "enroll this participant only under the tenant that owns the participant record" + ); +} + +#[test] +fn cross_tenant_or_unbound_participant_record_fails_closed() { + let snapshot = granted_longitudinal_snapshot(); + let other_tenant = ParticipantRecord::new_anonymous( + "participant_clinic_seoul", + "tenant_other_clinic", + 1_724_000_000_000, + ) + .unwrap(); + assert_eq!( + LongitudinalEnrollment::enroll(seoul_mood_enrollment(), &other_tenant, &snapshot), + Err(LongitudinalEnrollmentError::CrossTenantDenied) + ); + + let other_person = ParticipantRecord::new_anonymous( + "participant_other_clinic", + "tenant_clinic_seoul", + 1_724_000_000_000, + ) + .unwrap(); + assert_eq!( + LongitudinalEnrollment::enroll(seoul_mood_enrollment(), &other_person, &snapshot), + Err(LongitudinalEnrollmentError::ParticipantMismatch) + ); +} + +#[test] +fn revoke_after_enroll_stops_collection_even_after_resume() { + let (mut ledger, snapshot) = granted_longitudinal_ledger(); + let enrolled = enroll_seoul(&snapshot); + assert_eq!(enrolled.authorize_collection(&snapshot), Ok(())); + + let paused = enrolled + .pause("enrollment_event_pause", 1_724_000_300_000) + .unwrap(); + ledger + .record(ConsentEventInput { + event_ref: "consent_event_longitudinal_revoke_after", + purpose: ConsentPurpose::LongitudinalObservation, + decision: ConsentDecision::Revoked, + consent_form_version_ref: "ema_mood_form_ko_v1", + research_scope_ref: None, + occurred_at_unix_ms: 1_724_000_350_000, + }) + .unwrap(); + let revoked = ledger + .snapshot_as("consent_snapshot_revoked_after_enroll") + .unwrap(); + + let resumed = paused + .resume("enrollment_event_resume", 1_724_000_400_000) + .unwrap(); + assert_eq!(resumed.state(), EnrollmentState::Enrolled); + assert_eq!( + resumed.authorize_collection(&revoked), + Err(LongitudinalEnrollmentError::LongitudinalConsentRequired) + ); + assert_eq!( + enrolled.authorize_collection(&revoked), + Err(LongitudinalEnrollmentError::LongitudinalConsentRequired) + ); + + let mut other_ledger = ConsentLedger::new("participant_other_clinic").unwrap(); + other_ledger + .record(ConsentEventInput { + event_ref: "consent_event_other_longitudinal", + purpose: ConsentPurpose::LongitudinalObservation, + decision: ConsentDecision::Granted, + consent_form_version_ref: "ema_mood_form_ko_v1", + research_scope_ref: None, + occurred_at_unix_ms: 1_724_000_100_000, + }) + .unwrap(); + let other_snapshot = other_ledger + .snapshot_as("consent_snapshot_other_clinic") + .unwrap(); + assert_eq!( + enrolled.authorize_collection(&other_snapshot), + Err(LongitudinalEnrollmentError::ParticipantMismatch) + ); +}