From c728de71d371964e9470963b87d43f2727f010c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 10:41:29 +0900 Subject: [PATCH 01/16] test(auth): bind anonymous proof to exact session resource --- tests/anonymous_resource_authorization.rs | 137 ++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 tests/anonymous_resource_authorization.rs diff --git a/tests/anonymous_resource_authorization.rs b/tests/anonymous_resource_authorization.rs new file mode 100644 index 00000000..80b9f0dc --- /dev/null +++ b/tests/anonymous_resource_authorization.rs @@ -0,0 +1,137 @@ +//! Contract tests for fail-closed anonymous assessment-session resource authorization. + +use psychometrics_commons_runtime::anonymous_authorization::{ + authorize_anonymous_session, AnonymousResourceAuthorizationError, +}; +use psychometrics_commons_runtime::anonymous_session::AnonymousSessionContext; +use psychometrics_commons_runtime::authorization::{ResourceKind, ResourceScope}; + +fn anonymous_context() -> AnonymousSessionContext { + AnonymousSessionContext::new( + "tenant_alpha", + "participant_alpha", + "session_alpha", + "anonymous_authorization_evidence_alpha", + 2_000, + ) + .unwrap() +} + +fn session_resource( + tenant_ref: &str, + participant_ref: &str, + session_ref: &str, +) -> ResourceScope { + ResourceScope::participant_owned( + ResourceKind::AssessmentSession, + tenant_ref, + participant_ref, + session_ref, + ) + .unwrap() +} + +#[test] +fn current_anonymous_authority_may_manage_only_its_exact_session_resource() { + let context = anonymous_context(); + let resource = session_resource("tenant_alpha", "participant_alpha", "session_alpha"); + + assert_eq!(authorize_anonymous_session(&context, &resource, 1_500), Ok(())); +} + +#[test] +fn anonymous_authority_fails_closed_for_zero_or_expired_server_time() { + let context = anonymous_context(); + let resource = session_resource("tenant_alpha", "participant_alpha", "session_alpha"); + + assert_eq!( + authorize_anonymous_session(&context, &resource, 0), + Err(AnonymousResourceAuthorizationError::InvalidTimestamp) + ); + assert_eq!( + authorize_anonymous_session(&context, &resource, 2_000), + Err(AnonymousResourceAuthorizationError::Expired) + ); + assert_eq!( + authorize_anonymous_session(&context, &resource, 2_001), + Err(AnonymousResourceAuthorizationError::Expired) + ); +} + +#[test] +fn anonymous_authority_never_crosses_tenant_or_participant_ownership() { + let context = anonymous_context(); + let foreign_tenant = session_resource("tenant_beta", "participant_alpha", "session_alpha"); + let foreign_owner = session_resource("tenant_alpha", "participant_beta", "session_alpha"); + + assert_eq!( + authorize_anonymous_session(&context, &foreign_tenant, 1_500), + Err(AnonymousResourceAuthorizationError::CrossTenantDenied) + ); + assert_eq!( + authorize_anonymous_session(&context, &foreign_owner, 1_500), + Err(AnonymousResourceAuthorizationError::OwnerMismatch) + ); +} + +#[test] +fn anonymous_authority_is_bound_to_one_exact_assessment_session() { + let context = anonymous_context(); + let other_session = session_resource("tenant_alpha", "participant_alpha", "session_beta"); + + assert_eq!( + authorize_anonymous_session(&context, &other_session, 1_500), + Err(AnonymousResourceAuthorizationError::SessionMismatch) + ); +} + +#[test] +fn anonymous_session_proof_cannot_be_reused_for_other_participant_resources() { + let context = anonymous_context(); + let result = ResourceScope::participant_owned( + ResourceKind::Result, + "tenant_alpha", + "participant_alpha", + "result_alpha", + ) + .unwrap(); + + assert_eq!( + authorize_anonymous_session(&context, &result, 1_500), + Err(AnonymousResourceAuthorizationError::ResourceKindMismatch) + ); +} + +#[test] +fn anonymous_resource_authorization_errors_are_stable_and_safe() { + let cases = [ + ( + AnonymousResourceAuthorizationError::InvalidTimestamp, + "anonymous resource authorization requires positive server time", + ), + ( + AnonymousResourceAuthorizationError::Expired, + "anonymous session authority is expired", + ), + ( + AnonymousResourceAuthorizationError::CrossTenantDenied, + "anonymous session authority does not match the resource tenant", + ), + ( + AnonymousResourceAuthorizationError::ResourceKindMismatch, + "anonymous session authority is limited to its assessment-session resource", + ), + ( + AnonymousResourceAuthorizationError::OwnerMismatch, + "anonymous session authority does not match the resource participant", + ), + ( + AnonymousResourceAuthorizationError::SessionMismatch, + "anonymous session authority does not match the resource session", + ), + ]; + + for (error, expected) in cases { + assert_eq!(error.to_string(), expected); + } +} From 0bf3f3d82f20b94db1b8874f62849a5dbdab68b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 10:41:51 +0900 Subject: [PATCH 02/16] feat(auth): bind anonymous proof to exact session resource --- src/anonymous_authorization.rs | 97 ++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 src/anonymous_authorization.rs diff --git a/src/anonymous_authorization.rs b/src/anonymous_authorization.rs new file mode 100644 index 00000000..e37a6cc8 --- /dev/null +++ b/src/anonymous_authorization.rs @@ -0,0 +1,97 @@ +//! Fail-closed product authorization for validated anonymous assessment sessions. +//! +//! Anonymous participation is a first-class product path, but a validated anonymous-session +//! proof is intentionally narrower than an authenticated participant identity. This module binds +//! already-validated short-lived anonymous authority to exactly one participant-owned assessment +//! session. It cannot authorize result access, consent, data-rights, tenant administration, or any +//! other product resource. + +use crate::anonymous_session::AnonymousSessionContext; +use crate::authorization::{ResourceKind, ResourceScope}; +use std::error::Error; +use std::fmt::{Display, Formatter}; + +/// Fail-closed authorization error for a validated anonymous assessment session. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum AnonymousResourceAuthorizationError { + /// The server-authoritative authorization time was zero or otherwise unknown. + InvalidTimestamp, + /// The short-lived anonymous-session authority was no longer valid. + Expired, + /// The target resource belonged to another tenant. + CrossTenantDenied, + /// Anonymous-session authority was presented for a non-session resource. + ResourceKindMismatch, + /// The target session belonged to another operational participant. + OwnerMismatch, + /// The target assessment-session reference differed from the proof binding. + SessionMismatch, +} + +impl Display for AnonymousResourceAuthorizationError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::InvalidTimestamp => { + "anonymous resource authorization requires positive server time" + } + Self::Expired => "anonymous session authority is expired", + Self::CrossTenantDenied => { + "anonymous session authority does not match the resource tenant" + } + Self::ResourceKindMismatch => { + "anonymous session authority is limited to its assessment-session resource" + } + Self::OwnerMismatch => { + "anonymous session authority does not match the resource participant" + } + Self::SessionMismatch => { + "anonymous session authority does not match the resource session" + } + }) + } +} + +impl Error for AnonymousResourceAuthorizationError {} + +/// Authorize one participant-owned assessment-session resource using validated anonymous proof. +/// +/// This boundary deliberately has no generic permission parameter. A validated anonymous-session +/// proof grants only authority over its exact assessment-session resource. Adding another resource +/// or operation therefore requires a new explicit authorization contract rather than silently +/// inheriting future authenticated-participant permissions. +/// +/// The server time is checked before any resource metadata so an unknown time cannot be treated as +/// current authority. Tenant, resource kind, participant owner, and exact session identity are then +/// checked in that order. All comparisons use canonical values that were already validated by +/// [`AnonymousSessionContext`] and [`ResourceScope`] constructors. +/// +/// # Errors +/// +/// Returns [`AnonymousResourceAuthorizationError`] when server time is invalid, the proof has +/// expired, or the target resource differs from the exact tenant/participant/session binding. +pub fn authorize_anonymous_session( + actor: &AnonymousSessionContext, + resource: &ResourceScope, + now_unix_ms: u64, +) -> Result<(), AnonymousResourceAuthorizationError> { + if now_unix_ms == 0 { + return Err(AnonymousResourceAuthorizationError::InvalidTimestamp); + } + if !actor.is_valid_at(now_unix_ms) { + return Err(AnonymousResourceAuthorizationError::Expired); + } + if actor.tenant_ref() != resource.tenant_ref() { + return Err(AnonymousResourceAuthorizationError::CrossTenantDenied); + } + if resource.kind() != ResourceKind::AssessmentSession { + return Err(AnonymousResourceAuthorizationError::ResourceKindMismatch); + } + if resource.owner_participant_ref() != Some(actor.participant_ref()) { + return Err(AnonymousResourceAuthorizationError::OwnerMismatch); + } + if resource.resource_ref() != actor.session_ref() { + return Err(AnonymousResourceAuthorizationError::SessionMismatch); + } + Ok(()) +} From cddef86f24abbdea0a37a6a466a297f5845f7efd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 10:42:05 +0900 Subject: [PATCH 03/16] feat(auth): expose anonymous session authorization --- src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib.rs b/src/lib.rs index 90737f81..c8f9aa22 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ //! computation remains in `fast-mlsirm` and is consumed through versioned //! contracts rather than reimplemented here. +pub mod anonymous_authorization; pub mod anonymous_session; pub mod authorization; pub mod consent; From ed6978b0aa0698680c116b1d028727ba760034e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 10:45:42 +0900 Subject: [PATCH 04/16] style(auth): apply rustfmt to anonymous authorization tests --- tests/anonymous_resource_authorization.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/anonymous_resource_authorization.rs b/tests/anonymous_resource_authorization.rs index 80b9f0dc..fbdff947 100644 --- a/tests/anonymous_resource_authorization.rs +++ b/tests/anonymous_resource_authorization.rs @@ -17,11 +17,7 @@ fn anonymous_context() -> AnonymousSessionContext { .unwrap() } -fn session_resource( - tenant_ref: &str, - participant_ref: &str, - session_ref: &str, -) -> ResourceScope { +fn session_resource(tenant_ref: &str, participant_ref: &str, session_ref: &str) -> ResourceScope { ResourceScope::participant_owned( ResourceKind::AssessmentSession, tenant_ref, @@ -36,7 +32,10 @@ fn current_anonymous_authority_may_manage_only_its_exact_session_resource() { let context = anonymous_context(); let resource = session_resource("tenant_alpha", "participant_alpha", "session_alpha"); - assert_eq!(authorize_anonymous_session(&context, &resource, 1_500), Ok(())); + assert_eq!( + authorize_anonymous_session(&context, &resource, 1_500), + Ok(()) + ); } #[test] From d2beee728b86103645e893ee20ed73e17f60dca2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 11:34:09 +0900 Subject: [PATCH 05/16] test(auth): pin anonymous denial precedence --- tests/anonymous_resource_authorization.rs | 28 +++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/anonymous_resource_authorization.rs b/tests/anonymous_resource_authorization.rs index fbdff947..a87185b7 100644 --- a/tests/anonymous_resource_authorization.rs +++ b/tests/anonymous_resource_authorization.rs @@ -73,6 +73,34 @@ fn anonymous_authority_never_crosses_tenant_or_participant_ownership() { ); } +#[test] +fn anonymous_authorization_rejects_multiple_mismatches_in_documented_order() { + let context = anonymous_context(); + let foreign_tenant_result = ResourceScope::participant_owned( + ResourceKind::Result, + "tenant_beta", + "participant_alpha", + "result_alpha", + ) + .unwrap(); + let wrong_kind_and_owner = ResourceScope::participant_owned( + ResourceKind::Result, + "tenant_alpha", + "participant_beta", + "result_alpha", + ) + .unwrap(); + + assert_eq!( + authorize_anonymous_session(&context, &foreign_tenant_result, 1_500), + Err(AnonymousResourceAuthorizationError::CrossTenantDenied) + ); + assert_eq!( + authorize_anonymous_session(&context, &wrong_kind_and_owner, 1_500), + Err(AnonymousResourceAuthorizationError::ResourceKindMismatch) + ); +} + #[test] fn anonymous_authority_is_bound_to_one_exact_assessment_session() { let context = anonymous_context(); From 16e7283297509dc5fd50905c92ef5379f526a8e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 11:34:29 +0900 Subject: [PATCH 06/16] docs(auth): explain anonymous session authorization --- src/anonymous_authorization.rs | 58 +++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/src/anonymous_authorization.rs b/src/anonymous_authorization.rs index e37a6cc8..e5b2afce 100644 --- a/src/anonymous_authorization.rs +++ b/src/anonymous_authorization.rs @@ -1,31 +1,34 @@ -//! Fail-closed product authorization for validated anonymous assessment sessions. +//! Product authorization for an already-verified anonymous assessment session. //! -//! Anonymous participation is a first-class product path, but a validated anonymous-session -//! proof is intentionally narrower than an authenticated participant identity. This module binds -//! already-validated short-lived anonymous authority to exactly one participant-owned assessment -//! session. It cannot authorize result access, consent, data-rights, tenant administration, or any -//! other product resource. +//! An anonymous participant receives a short-lived proof when an assessment session is created. +//! Another part of the application verifies that proof and builds an [`AnonymousSessionContext`]. +//! This module does **not** read or verify the raw secret. Instead, it answers a narrower question: +//! "May this verified anonymous session act on this exact assessment-session resource right now?" +//! +//! The answer is deliberately limited. The verified session may act only on the one assessment +//! session named in its context. It cannot be reused to read results, change consent, exercise data +//! rights, administer a tenant, or access another participant's session. use crate::anonymous_session::AnonymousSessionContext; use crate::authorization::{ResourceKind, ResourceScope}; use std::error::Error; use std::fmt::{Display, Formatter}; -/// Fail-closed authorization error for a validated anonymous assessment session. +/// Fail-closed authorization error for a verified anonymous assessment session. #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[non_exhaustive] pub enum AnonymousResourceAuthorizationError { - /// The server-authoritative authorization time was zero or otherwise unknown. + /// The caller did not provide a positive time obtained from the trusted server clock. InvalidTimestamp, - /// The short-lived anonymous-session authority was no longer valid. + /// The anonymous session had reached or passed its exclusive expiry time. Expired, /// The target resource belonged to another tenant. CrossTenantDenied, - /// Anonymous-session authority was presented for a non-session resource. + /// Anonymous-session access was requested for a resource other than an assessment session. ResourceKindMismatch, /// The target session belonged to another operational participant. OwnerMismatch, - /// The target assessment-session reference differed from the proof binding. + /// The target assessment-session reference differed from the session named by the proof. SessionMismatch, } @@ -54,22 +57,33 @@ impl Display for AnonymousResourceAuthorizationError { impl Error for AnonymousResourceAuthorizationError {} -/// Authorize one participant-owned assessment-session resource using validated anonymous proof. +/// Allow a verified anonymous participant to act on one exact assessment session. +/// +/// Callers provide three values: +/// +/// - `actor`: an [`AnonymousSessionContext`] created only after the short-lived anonymous proof has +/// already been verified; +/// - `resource`: the [`ResourceScope`] for the assessment session the caller wants to use; and +/// - `now_unix_ms`: the current time from the application's trusted server clock, not a client clock. +/// +/// For example, if the verified context names tenant `tenant_alpha`, participant +/// `participant_alpha`, and session `session_alpha`, this function allows access only to the +/// `session_alpha` assessment-session resource owned by that same participant in that same tenant. +/// A result resource or `session_beta` is denied even when the same caller presents the context. /// -/// This boundary deliberately has no generic permission parameter. A validated anonymous-session -/// proof grants only authority over its exact assessment-session resource. Adding another resource -/// or operation therefore requires a new explicit authorization contract rather than silently -/// inheriting future authenticated-participant permissions. +/// References in `actor` and `resource` are already in their validated, exact spelling because +/// their constructors reject non-canonical forms. This function therefore compares the exact +/// values instead of trimming, normalizing, or guessing aliases. /// -/// The server time is checked before any resource metadata so an unknown time cannot be treated as -/// current authority. Tenant, resource kind, participant owner, and exact session identity are then -/// checked in that order. All comparisons use canonical values that were already validated by -/// [`AnonymousSessionContext`] and [`ResourceScope`] constructors. +/// Checks run in a stable fail-closed order: trusted server time, expiry, tenant, resource kind, +/// participant owner, then session identity. This order is part of the error contract used by +/// transports when more than one supplied property is wrong. /// /// # Errors /// -/// Returns [`AnonymousResourceAuthorizationError`] when server time is invalid, the proof has -/// expired, or the target resource differs from the exact tenant/participant/session binding. +/// Returns [`AnonymousResourceAuthorizationError`] when the trusted time is invalid, the verified +/// anonymous session has expired, or the requested resource differs from the exact +/// tenant/participant/session binding described above. pub fn authorize_anonymous_session( actor: &AnonymousSessionContext, resource: &ResourceScope, From 26b6e58a368de0b14d524a9f0baf3571d86d54c7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:06:54 +0000 Subject: [PATCH 07/16] feat(auth): authorize anonymous commands from loaded session Derive the assessment-session resource from the stored participant tenant and the loaded session so a transport cannot invent a matching scope and then command a different session. Co-authored-by: Seongho Bae --- CHANGELOG.md | 1 + docs/TRACEABILITY.md | 2 + src/anonymous_authorization.rs | 55 +++++ ...anonymous_session_command_authorization.rs | 198 ++++++++++++++++++ 4 files changed, 256 insertions(+) create mode 100644 tests/anonymous_session_command_authorization.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b2f801dd..4b4cf69c 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 +- Anonymous session command authorization builds the assessment-session resource from the loaded participant tenant/owner and loaded session reference, so a transport cannot invent a matching scope and then command a different stored session. - 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..164467b0 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -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** #86 anonymous-session resource authorization, plus the follow-up command boundary that derives the assessment-session resource from the loaded participant tenant/owner and loaded session, is not protected-main truth until an unchanged reviewed/check-clean head is integrated. HTTP transport remains outside this slice. + ## 5. ADR traceability by concern | Concern | Governing ADR(s) | diff --git a/src/anonymous_authorization.rs b/src/anonymous_authorization.rs index e5b2afce..4b30a407 100644 --- a/src/anonymous_authorization.rs +++ b/src/anonymous_authorization.rs @@ -8,9 +8,16 @@ //! The answer is deliberately limited. The verified session may act only on the one assessment //! session named in its context. It cannot be reused to read results, change consent, exercise data //! rights, administer a tenant, or access another participant's session. +//! +//! Transports that already loaded a participant and session should call +//! [`authorize_anonymous_session_command`]. That function builds the resource from those stored +//! records so a caller cannot invent a matching tenant/owner/session triple and then command a +//! different loaded session. use crate::anonymous_session::AnonymousSessionContext; use crate::authorization::{ResourceKind, ResourceScope}; +use crate::participant::ParticipantRecord; +use crate::session::AssessmentSession; use std::error::Error; use std::fmt::{Display, Formatter}; @@ -109,3 +116,51 @@ pub fn authorize_anonymous_session( } Ok(()) } + +/// Allow a verified anonymous participant to command one loaded assessment session. +/// +/// Callers provide four values: +/// +/// - `actor`: an [`AnonymousSessionContext`] created only after the short-lived anonymous proof has +/// already been verified; +/// - `participant`: the [`ParticipantRecord`] loaded from the product store for that command; +/// - `session`: the [`AssessmentSession`] loaded from the product store for that command; and +/// - `now_unix_ms`: the current time from the application's trusted server clock, not a client clock. +/// +/// The function builds the resource from those loaded records. It does **not** accept a +/// caller-invented tenant, owner, or session reference. For example, a proof for +/// `session_alpha` / `participant_alpha` in `tenant_alpha` is allowed only when the loaded +/// participant is that same person in that same tenant and the loaded session is `session_alpha` +/// owned by that person. A session owned by `participant_beta`, or `session_beta` owned by the +/// same person, is denied. +/// +/// # Errors +/// +/// Returns [`AnonymousResourceAuthorizationError`] when trusted time is invalid, the verified +/// anonymous session has expired, the loaded participant belongs to another tenant, the loaded +/// session belongs to another participant, or the loaded session is not the session named by the +/// proof. +pub fn authorize_anonymous_session_command( + actor: &AnonymousSessionContext, + participant: &ParticipantRecord, + session: &AssessmentSession, + now_unix_ms: u64, +) -> Result<(), AnonymousResourceAuthorizationError> { + if now_unix_ms == 0 { + return Err(AnonymousResourceAuthorizationError::InvalidTimestamp); + } + if !actor.is_valid_at(now_unix_ms) { + return Err(AnonymousResourceAuthorizationError::Expired); + } + if session.participant_ref() != participant.participant_ref() { + return Err(AnonymousResourceAuthorizationError::OwnerMismatch); + } + let resource = ResourceScope::participant_owned( + ResourceKind::AssessmentSession, + participant.tenant_ref(), + participant.participant_ref(), + session.session_ref(), + ) + .map_err(|_| AnonymousResourceAuthorizationError::SessionMismatch)?; + authorize_anonymous_session(actor, &resource, now_unix_ms) +} diff --git a/tests/anonymous_session_command_authorization.rs b/tests/anonymous_session_command_authorization.rs new file mode 100644 index 00000000..2154a773 --- /dev/null +++ b/tests/anonymous_session_command_authorization.rs @@ -0,0 +1,198 @@ +//! Contract tests for anonymous command authorization against loaded aggregates. +//! +//! A transport must load the participant and assessment session from the product store, +//! then ask this boundary whether the already-verified anonymous session may command +//! that exact loaded session. Callers do not invent the resource tenant or owner. + +use psychometrics_commons_runtime::anonymous_authorization::{ + authorize_anonymous_session_command, AnonymousResourceAuthorizationError, +}; +use psychometrics_commons_runtime::anonymous_session::AnonymousSessionContext; +use psychometrics_commons_runtime::instrument::{ + InstrumentRelease, InstrumentReleaseManifest, PublicationCommand, + PublicationEvidenceProvenance, PublicationEvidenceRecord, PublicationEvidenceStatus, +}; +use psychometrics_commons_runtime::participant::ParticipantRecord; +use psychometrics_commons_runtime::session::AssessmentSession; + +const RELEASE_DIGEST: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const EVIDENCE_DIGEST: &str = + "sha256:1111111111111111111111111111111111111111111111111111111111111111"; + +fn published_release() -> InstrumentRelease { + let manifest = InstrumentReleaseManifest::new( + "release_big_five_ko_v1", + "instrument_big_five", + "instrument_version_big_five_ko_v1", + "construct_big_five", + &["item_version_001"], + "ko-KR", + "assessment_spec_big_five_v1", + "scoring_version_big_five_v1", + "calibration_big_five_ko_v1", + Some("norm_version_big_five_ko_v1"), + "narrative_version_big_five_v1", + &["consent_service_v1"], + "intended_use_self_reflection_v1", + "limitations_nonclinical_v1", + RELEASE_DIGEST, + ) + .unwrap(); + let evidence = PublicationEvidenceRecord::new( + "publication_evidence_big_five_ko_v1", + "evidence_policy_self_reflection_v1", + "release_big_five_ko_v1", + "instrument_version_big_five_ko_v1", + &["item_version_001"], + RELEASE_DIGEST, + "ko-KR", + "intended_use_self_reflection_v1", + "assessment_spec_big_five_v1", + "scoring_version_big_five_v1", + "calibration_big_five_ko_v1", + Some("norm_version_big_five_ko_v1"), + "limitations_nonclinical_v1", + PublicationEvidenceProvenance::new( + EVIDENCE_DIGEST, + "population_general_adult_v1", + "administration_web_self_report_v1", + "measurement_model_big_five_v1", + 10_050, + None, + ) + .unwrap(), + &["rights_ipip_big_five_v1"], + &["recovery_big_five_ko_v1"], + &["approval_psychometrics_big_five_ko_v1"], + PublicationEvidenceStatus::Approved, + ) + .unwrap(); + let mut release = InstrumentRelease::new(manifest, 10_000).unwrap(); + release + .apply_command( + "publication_review_11d5b1e7", + PublicationCommand::SubmitReview, + 10_100, + ) + .unwrap(); + release.bind_publication_evidence(evidence).unwrap(); + release + .apply_command( + "publication_publish_20f6c2a8", + PublicationCommand::Publish, + 10_200, + ) + .unwrap(); + release +} + +fn participant(tenant_ref: &str, participant_ref: &str) -> ParticipantRecord { + ParticipantRecord::new_anonymous(participant_ref, tenant_ref, 1_000).unwrap() +} + +fn session(participant_ref: &str, session_ref: &str) -> AssessmentSession { + AssessmentSession::new( + session_ref, + participant_ref, + &published_release(), + "ko-KR", + 20_000, + ) + .unwrap() +} + +fn anonymous_context( + tenant_ref: &str, + participant_ref: &str, + session_ref: &str, +) -> AnonymousSessionContext { + AnonymousSessionContext::new( + tenant_ref, + participant_ref, + session_ref, + "anonymous_command_evidence_alpha", + 2_000, + ) + .unwrap() +} + +#[test] +fn current_anonymous_proof_may_command_only_its_loaded_session() { + let actor = anonymous_context("tenant_alpha", "participant_alpha", "session_alpha"); + let owner = participant("tenant_alpha", "participant_alpha"); + let loaded = session("participant_alpha", "session_alpha"); + + assert_eq!( + authorize_anonymous_session_command(&actor, &owner, &loaded, 1_500), + Ok(()) + ); +} + +#[test] +fn anonymous_command_authorization_uses_loaded_participant_tenant_not_caller_scope() { + let actor = anonymous_context("tenant_alpha", "participant_alpha", "session_alpha"); + let foreign_owner = participant("tenant_beta", "participant_alpha"); + let loaded = session("participant_alpha", "session_alpha"); + + assert_eq!( + authorize_anonymous_session_command(&actor, &foreign_owner, &loaded, 1_500), + Err(AnonymousResourceAuthorizationError::CrossTenantDenied) + ); +} + +#[test] +fn anonymous_command_authorization_rejects_a_session_owned_by_another_loaded_participant() { + let actor = anonymous_context("tenant_alpha", "participant_alpha", "session_alpha"); + let owner = participant("tenant_alpha", "participant_alpha"); + let other_persons_session = session("participant_beta", "session_alpha"); + + assert_eq!( + authorize_anonymous_session_command(&actor, &owner, &other_persons_session, 1_500), + Err(AnonymousResourceAuthorizationError::OwnerMismatch) + ); +} + +#[test] +fn anonymous_command_authorization_rejects_a_different_loaded_session_for_the_same_owner() { + let actor = anonymous_context("tenant_alpha", "participant_alpha", "session_alpha"); + let owner = participant("tenant_alpha", "participant_alpha"); + let other_session = session("participant_alpha", "session_beta"); + + assert_eq!( + authorize_anonymous_session_command(&actor, &owner, &other_session, 1_500), + Err(AnonymousResourceAuthorizationError::SessionMismatch) + ); +} + +#[test] +fn anonymous_command_authorization_fails_closed_for_zero_or_expired_server_time() { + let actor = anonymous_context("tenant_alpha", "participant_alpha", "session_alpha"); + let owner = participant("tenant_alpha", "participant_alpha"); + let loaded = session("participant_alpha", "session_alpha"); + + assert_eq!( + authorize_anonymous_session_command(&actor, &owner, &loaded, 0), + Err(AnonymousResourceAuthorizationError::InvalidTimestamp) + ); + assert_eq!( + authorize_anonymous_session_command(&actor, &owner, &loaded, 2_000), + Err(AnonymousResourceAuthorizationError::Expired) + ); +} + +#[test] +fn anonymous_command_authorization_rejects_compound_failures_in_time_then_owner_order() { + let actor = anonymous_context("tenant_alpha", "participant_alpha", "session_alpha"); + let owner = participant("tenant_alpha", "participant_alpha"); + let other_persons_session = session("participant_beta", "session_alpha"); + + assert_eq!( + authorize_anonymous_session_command(&actor, &owner, &other_persons_session, 0), + Err(AnonymousResourceAuthorizationError::InvalidTimestamp) + ); + assert_eq!( + authorize_anonymous_session_command(&actor, &owner, &other_persons_session, 2_000), + Err(AnonymousResourceAuthorizationError::Expired) + ); +} From 458d21c5c3870ae2943e5cdf71044fa97a02a8f4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:08:29 +0000 Subject: [PATCH 08/16] feat(auth): apply session commands only after anonymous authorization Keep the loaded session unchanged when the proof is expired or names a different session, and still fail closed on illegal lifecycle transitions. Co-authored-by: Seongho Bae --- CHANGELOG.md | 2 +- docs/TRACEABILITY.md | 2 +- src/anonymous_authorization.rs | 62 +++++++++- ...anonymous_session_command_authorization.rs | 115 +++++++++++++++++- 4 files changed, 176 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b4cf69c..463429cc 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 -- Anonymous session command authorization builds the assessment-session resource from the loaded participant tenant/owner and loaded session reference, so a transport cannot invent a matching scope and then command a different stored session. +- Anonymous session command authorization builds the assessment-session resource from the loaded participant tenant/owner and loaded session reference, then applies a lifecycle command only after that check, so a transport cannot invent a matching scope and then command a different stored session. - 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 164467b0..c5daddb9 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** #86 anonymous-session resource authorization, plus the follow-up command boundary that derives the assessment-session resource from the loaded participant tenant/owner and loaded session, is not protected-main truth until an unchanged reviewed/check-clean head is integrated. HTTP transport remains outside this slice. +**Active PR** #86 anonymous-session resource authorization, plus follow-up #104 that derives the assessment-session resource from the loaded participant tenant/owner and loaded session and applies a lifecycle command only after that check, is not protected-main truth until an unchanged reviewed/check-clean head is integrated. HTTP transport remains outside this slice. ## 5. ADR traceability by concern diff --git a/src/anonymous_authorization.rs b/src/anonymous_authorization.rs index 4b30a407..db03d7b0 100644 --- a/src/anonymous_authorization.rs +++ b/src/anonymous_authorization.rs @@ -17,7 +17,7 @@ use crate::anonymous_session::AnonymousSessionContext; use crate::authorization::{ResourceKind, ResourceScope}; use crate::participant::ParticipantRecord; -use crate::session::AssessmentSession; +use crate::session::{AssessmentSession, SessionCommand, SessionState, TransitionError}; use std::error::Error; use std::fmt::{Display, Formatter}; @@ -164,3 +164,63 @@ pub fn authorize_anonymous_session_command( .map_err(|_| AnonymousResourceAuthorizationError::SessionMismatch)?; authorize_anonymous_session(actor, &resource, now_unix_ms) } + +/// Fail-closed error for applying a session command after anonymous authorization. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum AnonymousSessionCommandError { + /// The verified anonymous session was not allowed to command the loaded session. + Authorization(AnonymousResourceAuthorizationError), + /// Authorization succeeded, but the lifecycle command was not legal for the current state. + Transition(TransitionError), +} + +impl Display for AnonymousSessionCommandError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Authorization(error) => Display::fmt(error, formatter), + Self::Transition(error) => Display::fmt(error, formatter), + } + } +} + +impl Error for AnonymousSessionCommandError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Authorization(error) => Some(error), + Self::Transition(error) => Some(error), + } + } +} + +/// Apply one session command only after the loaded session is authorized. +/// +/// Call this from an HTTP or messaging adapter after the short-lived anonymous proof has been +/// verified and the participant and session have been loaded from the product store. Authorization +/// runs first. If it fails, the session is left unchanged. If it succeeds, the existing session +/// lifecycle rules decide whether the command may change state. +/// +/// For example, a current proof for `session_alpha` may activate that loaded session. The same +/// proof cannot activate `session_beta`, and an expired proof cannot activate `session_alpha` even +/// though `Activate` is otherwise legal from `Created`. +/// +/// # Errors +/// +/// Returns [`AnonymousSessionCommandError::Authorization`] when the loaded records are not the +/// exact current anonymous session, or [`AnonymousSessionCommandError::Transition`] when the +/// command is not legal from the current lifecycle state. +pub fn apply_anonymous_session_command( + actor: &AnonymousSessionContext, + participant: &ParticipantRecord, + session: &mut AssessmentSession, + command_ref: &str, + sequence: u64, + command: SessionCommand, + now_unix_ms: u64, +) -> Result { + authorize_anonymous_session_command(actor, participant, session, now_unix_ms) + .map_err(AnonymousSessionCommandError::Authorization)?; + session + .apply_command(command_ref, sequence, command) + .map_err(AnonymousSessionCommandError::Transition) +} diff --git a/tests/anonymous_session_command_authorization.rs b/tests/anonymous_session_command_authorization.rs index 2154a773..6168239c 100644 --- a/tests/anonymous_session_command_authorization.rs +++ b/tests/anonymous_session_command_authorization.rs @@ -5,7 +5,8 @@ //! that exact loaded session. Callers do not invent the resource tenant or owner. use psychometrics_commons_runtime::anonymous_authorization::{ - authorize_anonymous_session_command, AnonymousResourceAuthorizationError, + apply_anonymous_session_command, authorize_anonymous_session_command, + AnonymousResourceAuthorizationError, AnonymousSessionCommandError, }; use psychometrics_commons_runtime::anonymous_session::AnonymousSessionContext; use psychometrics_commons_runtime::instrument::{ @@ -13,7 +14,9 @@ use psychometrics_commons_runtime::instrument::{ PublicationEvidenceProvenance, PublicationEvidenceRecord, PublicationEvidenceStatus, }; use psychometrics_commons_runtime::participant::ParticipantRecord; -use psychometrics_commons_runtime::session::AssessmentSession; +use psychometrics_commons_runtime::session::{ + AssessmentSession, SessionCommand, SessionState, TransitionErrorKind, +}; const RELEASE_DIGEST: &str = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; @@ -196,3 +199,111 @@ fn anonymous_command_authorization_rejects_compound_failures_in_time_then_owner_ Err(AnonymousResourceAuthorizationError::Expired) ); } + +#[test] +fn authorized_anonymous_proof_may_activate_only_its_loaded_session() { + let actor = anonymous_context("tenant_alpha", "participant_alpha", "session_alpha"); + let owner = participant("tenant_alpha", "participant_alpha"); + let mut loaded = session("participant_alpha", "session_alpha"); + + assert_eq!( + apply_anonymous_session_command( + &actor, + &owner, + &mut loaded, + "command_activate_alpha", + 1, + SessionCommand::Activate, + 1_500, + ), + Ok(SessionState::Active) + ); + assert_eq!(loaded.state(), SessionState::Active); +} + +#[test] +fn unauthorized_anonymous_command_does_not_mutate_the_loaded_session() { + let actor = anonymous_context("tenant_alpha", "participant_alpha", "session_alpha"); + let owner = participant("tenant_alpha", "participant_alpha"); + let mut other_session = session("participant_alpha", "session_beta"); + + assert_eq!( + apply_anonymous_session_command( + &actor, + &owner, + &mut other_session, + "command_activate_beta", + 1, + SessionCommand::Activate, + 1_500, + ), + Err(AnonymousSessionCommandError::Authorization( + AnonymousResourceAuthorizationError::SessionMismatch + )) + ); + assert_eq!(other_session.state(), SessionState::Created); +} + +#[test] +fn expired_anonymous_proof_cannot_apply_an_otherwise_legal_session_command() { + let actor = anonymous_context("tenant_alpha", "participant_alpha", "session_alpha"); + let owner = participant("tenant_alpha", "participant_alpha"); + let mut loaded = session("participant_alpha", "session_alpha"); + + assert_eq!( + apply_anonymous_session_command( + &actor, + &owner, + &mut loaded, + "command_activate_expired", + 1, + SessionCommand::Activate, + 2_000, + ), + Err(AnonymousSessionCommandError::Authorization( + AnonymousResourceAuthorizationError::Expired + )) + ); + assert_eq!(loaded.state(), SessionState::Created); +} + +#[test] +fn authorized_anonymous_command_still_fails_closed_on_illegal_lifecycle_transition() { + let actor = anonymous_context("tenant_alpha", "participant_alpha", "session_alpha"); + let owner = participant("tenant_alpha", "participant_alpha"); + let mut loaded = session("participant_alpha", "session_alpha"); + + let error = apply_anonymous_session_command( + &actor, + &owner, + &mut loaded, + "command_complete_too_early", + 1, + SessionCommand::Complete, + 1_500, + ) + .expect_err("Created sessions cannot complete"); + match error { + AnonymousSessionCommandError::Transition(transition) => { + assert_eq!(transition.state(), SessionState::Created); + assert_eq!(transition.command(), SessionCommand::Complete); + assert_eq!(transition.kind(), TransitionErrorKind::InvalidTransition); + } + other => panic!("expected lifecycle rejection, got {other:?}"), + } + assert_eq!(loaded.state(), SessionState::Created); + assert!(error.to_string().contains("Complete")); + assert!(std::error::Error::source(&error).is_some()); +} + +#[test] +fn anonymous_session_command_errors_preserve_safe_authorization_text() { + let authorization = AnonymousSessionCommandError::Authorization( + AnonymousResourceAuthorizationError::SessionMismatch, + ); + assert_eq!( + authorization.to_string(), + "anonymous session authority does not match the resource session" + ); + assert!(std::error::Error::source(&authorization).is_some()); +} From 6f1701fc79a0422d82e1000ac2fbdeb9e22ddc43 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:26:05 +0000 Subject: [PATCH 09/16] fix(auth): classify anonymous commands from loaded records Compare the verified actor to the loaded participant tenant and session instead of rebuilding a ResourceScope. Tenant mismatch is reported before ownership so a foreign-tenant inconsistent pair cannot hide as OwnerMismatch. Co-authored-by: Seongho Bae --- CHANGELOG.md | 2 +- docs/TRACEABILITY.md | 2 +- ...se-identity-and-anonymous-participation.md | 8 ++ docs/architecture/SECURITY_AND_DATA.md | 2 +- docs/architecture/UML.md | 6 +- src/anonymous_authorization.rs | 35 +++--- ...anonymous_session_command_authorization.rs | 113 ++++++++++++++++-- 7 files changed, 140 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 463429cc..f5d63189 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 -- Anonymous session command authorization builds the assessment-session resource from the loaded participant tenant/owner and loaded session reference, then applies a lifecycle command only after that check, so a transport cannot invent a matching scope and then command a different stored session. +- Anonymous session command authorization compares the verified actor to the loaded participant tenant/owner and loaded session reference, then applies a lifecycle command only after that check. The command entry point does not accept a caller-built resource scope. The lower-level exact-resource check remains available for callers that already hold a stored `ResourceScope`. - 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 c5daddb9..5b94227b 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** #86 anonymous-session resource authorization, plus follow-up #104 that derives the assessment-session resource from the loaded participant tenant/owner and loaded session and applies a lifecycle command only after that check, is not protected-main truth until an unchanged reviewed/check-clean head is integrated. HTTP transport remains outside this slice. +**Active PR** #86 anonymous-session resource authorization, plus follow-up #104 and its successor that compare the verified actor to the loaded participant tenant/owner and loaded session and apply a lifecycle command only after that check, is not protected-main truth until an unchanged reviewed/check-clean head is integrated. HTTP transport remains outside this slice. Tenant still lives on `assessment_participant`; persisting and loading that row is the next Active-PR slice on this successor so a transport cannot reconstruct the participant from the proof. ## 5. ADR traceability by concern diff --git a/docs/adr/0003-keyverse-identity-and-anonymous-participation.md b/docs/adr/0003-keyverse-identity-and-anonymous-participation.md index 05a2c795..b39c8aa3 100644 --- a/docs/adr/0003-keyverse-identity-and-anonymous-participation.md +++ b/docs/adr/0003-keyverse-identity-and-anonymous-participation.md @@ -37,6 +37,10 @@ The mapping between operational and research identities is stored in a restricte Keyverse claims establish authenticated subject and coarse scopes. Psychometrics Commons performs resource-level decisions for instrument administration, result ownership, research roles, data export, deletion, and release approval. A Keyverse administrator is not automatically a Psychometrics Commons research data steward. +Anonymous session commands are a product-owned gate after the short-lived proof has already been verified. Transports that loaded `assessment_participant` and `assessment_session` must call `authorize_anonymous_session_command` / `apply_anonymous_session_command`. Those functions compare the verified actor to the loaded tenant, participant, and session references. They do not accept a caller-built `ResourceScope`. Fail-closed classification order is trusted server time, exclusive expiry, loaded-participant tenant, loaded session/participant ownership, actor participant, then session identity (National Institute of Standards and Technology, 2025). + +The lower-level `authorize_anonymous_session(actor, resource, now)` check remains for callers that already hold a stored assessment-session `ResourceScope`. It is not sufficient by itself for a command against a different loaded session. + ## Invariants 1. Core anonymous assessment does not require a Keyverse account. @@ -74,3 +78,7 @@ The product does not rely on blanket PII masking that destroys operational utili ## Reversal conditions Revisit if Keyverse cannot meet a deployment's residency or federation requirements. A replacement must remain OIDC-compatible and preserve subject-mapping semantics without moving credentials into the product database. + +## References + +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 diff --git a/docs/architecture/SECURITY_AND_DATA.md b/docs/architecture/SECURITY_AND_DATA.md index c37236a1..89eb33ba 100644 --- a/docs/architecture/SECURITY_AND_DATA.md +++ b/docs/architecture/SECURITY_AND_DATA.md @@ -100,7 +100,7 @@ sharing_token_audience/expiry if used Rules: -- Tenant context for state-changing requests is derived from authenticated authorization, not an untrusted body field or implicit default. +- Tenant context for state-changing requests is derived from authenticated authorization or, for an anonymous session command, from the loaded `assessment_participant` row. It is not taken from an untrusted body field, a caller-invented `ResourceScope`, or an implicit default. - Public opaque identifiers are identifiers, not authorization capabilities. - Research steward, instrument publisher, participant result owner, and identity administrator are distinct authorities. - A sharing link, if introduced, must be revocable, scoped to an exact resource/audience, expire by default, and not reveal raw responses unless explicitly permitted by the participant and product policy. diff --git a/docs/architecture/UML.md b/docs/architecture/UML.md index 13988939..e791ce9f 100644 --- a/docs/architecture/UML.md +++ b/docs/architecture/UML.md @@ -304,8 +304,10 @@ sequenceDiagram A-->>C: accepted sequence end - C->>A: complete session - A->>DB: atomically state=Completed + freeze ResponseSnapshot + outbox scoring request + C->>A: complete session + A->>DB: load assessment_participant + assessment_session + A->>A: authorize anonymous command from loaded records + A->>DB: atomically state=Completed + freeze ResponseSnapshot + outbox scoring request A-->>C: completion accepted / scoring pending W->>DB: claim scoring work diff --git a/src/anonymous_authorization.rs b/src/anonymous_authorization.rs index db03d7b0..8928f115 100644 --- a/src/anonymous_authorization.rs +++ b/src/anonymous_authorization.rs @@ -10,9 +10,9 @@ //! rights, administer a tenant, or access another participant's session. //! //! Transports that already loaded a participant and session should call -//! [`authorize_anonymous_session_command`]. That function builds the resource from those stored -//! records so a caller cannot invent a matching tenant/owner/session triple and then command a -//! different loaded session. +//! [`authorize_anonymous_session_command`]. That function compares the verified actor to those +//! stored records so a caller cannot invent a matching tenant/owner/session triple and then +//! command a different loaded session. use crate::anonymous_session::AnonymousSessionContext; use crate::authorization::{ResourceKind, ResourceScope}; @@ -127,13 +127,19 @@ pub fn authorize_anonymous_session( /// - `session`: the [`AssessmentSession`] loaded from the product store for that command; and /// - `now_unix_ms`: the current time from the application's trusted server clock, not a client clock. /// -/// The function builds the resource from those loaded records. It does **not** accept a -/// caller-invented tenant, owner, or session reference. For example, a proof for +/// The function compares the actor to those loaded records. It does **not** accept a +/// caller-invented tenant, owner, or session reference, and it does not build a +/// [`ResourceScope`] that a transport could invent. For example, a proof for /// `session_alpha` / `participant_alpha` in `tenant_alpha` is allowed only when the loaded /// participant is that same person in that same tenant and the loaded session is `session_alpha` /// owned by that person. A session owned by `participant_beta`, or `session_beta` owned by the /// same person, is denied. /// +/// Checks run in a stable fail-closed order: trusted server time, expiry, loaded-participant +/// tenant, loaded session/participant ownership, actor participant, then session identity. +/// Tenant is classified before ownership so a foreign-tenant row that also disagrees on +/// participant identity is reported as [`AnonymousResourceAuthorizationError::CrossTenantDenied`]. +/// /// # Errors /// /// Returns [`AnonymousResourceAuthorizationError`] when trusted time is invalid, the verified @@ -152,17 +158,18 @@ pub fn authorize_anonymous_session_command( if !actor.is_valid_at(now_unix_ms) { return Err(AnonymousResourceAuthorizationError::Expired); } - if session.participant_ref() != participant.participant_ref() { + if actor.tenant_ref() != participant.tenant_ref() { + return Err(AnonymousResourceAuthorizationError::CrossTenantDenied); + } + if session.participant_ref() != participant.participant_ref() + || actor.participant_ref() != participant.participant_ref() + { return Err(AnonymousResourceAuthorizationError::OwnerMismatch); } - let resource = ResourceScope::participant_owned( - ResourceKind::AssessmentSession, - participant.tenant_ref(), - participant.participant_ref(), - session.session_ref(), - ) - .map_err(|_| AnonymousResourceAuthorizationError::SessionMismatch)?; - authorize_anonymous_session(actor, &resource, now_unix_ms) + if actor.session_ref() != session.session_ref() { + return Err(AnonymousResourceAuthorizationError::SessionMismatch); + } + Ok(()) } /// Fail-closed error for applying a session command after anonymous authorization. diff --git a/tests/anonymous_session_command_authorization.rs b/tests/anonymous_session_command_authorization.rs index 6168239c..10d468a3 100644 --- a/tests/anonymous_session_command_authorization.rs +++ b/tests/anonymous_session_command_authorization.rs @@ -200,6 +200,31 @@ fn anonymous_command_authorization_rejects_compound_failures_in_time_then_owner_ ); } +#[test] +fn anonymous_command_authorization_rejects_actor_when_loaded_participant_and_session_agree() { + let actor = anonymous_context("tenant_alpha", "participant_alpha", "session_alpha"); + let other_owner = participant("tenant_alpha", "participant_beta"); + let other_persons_session = session("participant_beta", "session_alpha"); + + assert_eq!( + authorize_anonymous_session_command(&actor, &other_owner, &other_persons_session, 1_500), + Err(AnonymousResourceAuthorizationError::OwnerMismatch) + ); +} + +#[test] +fn anonymous_command_authorization_rejects_compound_foreign_tenant_and_inconsistent_loaded_pair_as_cross_tenant( +) { + let actor = anonymous_context("tenant_alpha", "participant_alpha", "session_alpha"); + let foreign_owner = participant("tenant_beta", "participant_alpha"); + let other_persons_session = session("participant_beta", "session_alpha"); + + assert_eq!( + authorize_anonymous_session_command(&actor, &foreign_owner, &other_persons_session, 1_500), + Err(AnonymousResourceAuthorizationError::CrossTenantDenied) + ); +} + #[test] fn authorized_anonymous_proof_may_activate_only_its_loaded_session() { let actor = anonymous_context("tenant_alpha", "participant_alpha", "session_alpha"); @@ -244,6 +269,52 @@ fn unauthorized_anonymous_command_does_not_mutate_the_loaded_session() { assert_eq!(other_session.state(), SessionState::Created); } +#[test] +fn cross_tenant_anonymous_command_does_not_mutate_the_loaded_session() { + let actor = anonymous_context("tenant_alpha", "participant_alpha", "session_alpha"); + let foreign_owner = participant("tenant_beta", "participant_alpha"); + let mut loaded = session("participant_alpha", "session_alpha"); + + assert_eq!( + apply_anonymous_session_command( + &actor, + &foreign_owner, + &mut loaded, + "command_activate_foreign_tenant", + 1, + SessionCommand::Activate, + 1_500, + ), + Err(AnonymousSessionCommandError::Authorization( + AnonymousResourceAuthorizationError::CrossTenantDenied + )) + ); + assert_eq!(loaded.state(), SessionState::Created); +} + +#[test] +fn owner_mismatch_anonymous_command_does_not_mutate_the_loaded_session() { + let actor = anonymous_context("tenant_alpha", "participant_alpha", "session_alpha"); + let owner = participant("tenant_alpha", "participant_alpha"); + let mut other_persons_session = session("participant_beta", "session_alpha"); + + assert_eq!( + apply_anonymous_session_command( + &actor, + &owner, + &mut other_persons_session, + "command_activate_foreign_owner", + 1, + SessionCommand::Activate, + 1_500, + ), + Err(AnonymousSessionCommandError::Authorization( + AnonymousResourceAuthorizationError::OwnerMismatch + )) + ); + assert_eq!(other_persons_session.state(), SessionState::Created); +} + #[test] fn expired_anonymous_proof_cannot_apply_an_otherwise_legal_session_command() { let actor = anonymous_context("tenant_alpha", "participant_alpha", "session_alpha"); @@ -297,13 +368,37 @@ fn authorized_anonymous_command_still_fails_closed_on_illegal_lifecycle_transiti } #[test] -fn anonymous_session_command_errors_preserve_safe_authorization_text() { - let authorization = AnonymousSessionCommandError::Authorization( - AnonymousResourceAuthorizationError::SessionMismatch, - ); - assert_eq!( - authorization.to_string(), - "anonymous session authority does not match the resource session" - ); - assert!(std::error::Error::source(&authorization).is_some()); +fn anonymous_session_command_authorization_errors_display_and_source_all_variants() { + let cases = [ + ( + AnonymousResourceAuthorizationError::InvalidTimestamp, + "anonymous resource authorization requires positive server time", + ), + ( + AnonymousResourceAuthorizationError::Expired, + "anonymous session authority is expired", + ), + ( + AnonymousResourceAuthorizationError::CrossTenantDenied, + "anonymous session authority does not match the resource tenant", + ), + ( + AnonymousResourceAuthorizationError::ResourceKindMismatch, + "anonymous session authority is limited to its assessment-session resource", + ), + ( + AnonymousResourceAuthorizationError::OwnerMismatch, + "anonymous session authority does not match the resource participant", + ), + ( + AnonymousResourceAuthorizationError::SessionMismatch, + "anonymous session authority does not match the resource session", + ), + ]; + + for (inner, expected) in cases { + let error = AnonymousSessionCommandError::Authorization(inner); + assert_eq!(error.to_string(), expected); + assert!(std::error::Error::source(&error).is_some()); + } } From e438aa97a50e0b90e127eebf84fd14c6c94ecb19 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:29:15 +0000 Subject: [PATCH 10/16] feat(participant): persist anonymous assessment identity Store tenant, participant reference, anonymous status, and creation time so command authorization can load the participant instead of rebuilding it from the proof. Exact replay is idempotent; tenant or time rebinding fails closed; linked participants stay out of this slice. Co-authored-by: Seongho Bae --- CHANGELOG.md | 1 + docs/TRACEABILITY.md | 2 +- docs/architecture/AS_BUILT_SCHEMA.md | 4 + docs/architecture/ERD.md | 1 + migrations/0021_assessment_participant.sql | 55 +++ src/lib.rs | 1 + src/postgres_participant.rs | 244 ++++++++++ tests/postgres_participant_error_contract.rs | 57 +++ tests/postgres_participant_persistence.rs | 426 ++++++++++++++++++ ...postgres_participant_schema_constraints.rs | 129 ++++++ 10 files changed, 919 insertions(+), 1 deletion(-) create mode 100644 migrations/0021_assessment_participant.sql create mode 100644 src/postgres_participant.rs create mode 100644 tests/postgres_participant_error_contract.rs create mode 100644 tests/postgres_participant_persistence.rs create mode 100644 tests/postgres_participant_schema_constraints.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f5d63189..d855aba1 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 persistence for anonymous `assessment_participant` identity: exact tenant/participant replay is idempotent, rebinding tenant or creation time fails closed, load requires the stored tenant, and linked participants are rejected so account-link evidence cannot be dropped. - Anonymous session command authorization compares the verified actor to the loaded participant tenant/owner and loaded session reference, then applies a lifecycle command only after that check. The command entry point does not accept a caller-built resource scope. The lower-level exact-resource check remains available for callers that already hold a stored `ResourceScope`. - 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. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 5b94227b..2d5d3438 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** #86 anonymous-session resource authorization, plus follow-up #104 and its successor that compare the verified actor to the loaded participant tenant/owner and loaded session and apply a lifecycle command only after that check, is not protected-main truth until an unchanged reviewed/check-clean head is integrated. HTTP transport remains outside this slice. Tenant still lives on `assessment_participant`; persisting and loading that row is the next Active-PR slice on this successor so a transport cannot reconstruct the participant from the proof. +**Active PR** #86 anonymous-session resource authorization, plus follow-up #104 and its successor that compare the verified actor to the loaded participant tenant/owner and loaded session and apply a lifecycle command only after that check, is not protected-main truth until an unchanged reviewed/check-clean head is integrated. The same successor persists and reloads anonymous `assessment_participant` rows so tenant is taken from the store rather than reconstructed from the proof. HTTP transport remains outside this slice. ## 5. ADR traceability by concern diff --git a/docs/architecture/AS_BUILT_SCHEMA.md b/docs/architecture/AS_BUILT_SCHEMA.md index 8c2c3510..8d2e11da 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 assessment-participant physical schema + +This successor to #104 persists anonymous `assessment_participant` identity in `migrations/0021_assessment_participant.sql` and `src/postgres_participant.rs`. The slice is **Active PR**, not protected-main truth. It stores opaque `participant_ref`, `tenant_ref`, anonymous `participant_status`, and `created_at_unix_ms`. Exact replay is idempotent. Rebinding tenant or creation time fails closed. Load requires both stored tenant and participant references. Linked participants are rejected. Account-link history, HTTP transport, and session persistence remain 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..f95c324f 100644 --- a/docs/architecture/ERD.md +++ b/docs/architecture/ERD.md @@ -426,6 +426,7 @@ The target ERD deliberately includes several logical entities that are not yet p - `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. - `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. +- `assessment_participant` anonymous identity persistence is carried by this Active PR (`migrations/0021_assessment_participant.sql` / `src/postgres_participant.rs`): tenant, participant reference, anonymous status, and creation time. It is not protected-main truth until integrated. Account-link columns remain out of scope. - `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. - `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. diff --git a/migrations/0021_assessment_participant.sql b/migrations/0021_assessment_participant.sql new file mode 100644 index 00000000..8d008e21 --- /dev/null +++ b/migrations/0021_assessment_participant.sql @@ -0,0 +1,55 @@ +CREATE TABLE IF NOT EXISTS assessment_participant ( + participant_ref TEXT CONSTRAINT assessment_participant_participant_ref_not_null NOT NULL + CONSTRAINT assessment_participant_participant_ref_format_check CHECK ( + participant_ref = btrim(participant_ref) + AND participant_ref <> '' + AND NOT ( + participant_ref ~ '[[:digit:]]' + AND participant_ref ~ '^[[:digit:]+,.eE-]+$' + ) + ), + tenant_ref TEXT CONSTRAINT assessment_participant_tenant_ref_not_null NOT NULL + CONSTRAINT assessment_participant_tenant_ref_format_check CHECK ( + tenant_ref = btrim(tenant_ref) + AND tenant_ref <> '' + AND NOT ( + tenant_ref ~ '[[:digit:]]' + AND tenant_ref ~ '^[[:digit:]+,.eE-]+$' + ) + ), + participant_status TEXT CONSTRAINT assessment_participant_status_not_null NOT NULL + CONSTRAINT assessment_participant_status_value_check CHECK ( + participant_status = 'anonymous' + ), + created_at_unix_ms BIGINT CONSTRAINT assessment_participant_created_at_unix_not_null NOT NULL + CONSTRAINT assessment_participant_created_at_unix_positive_check CHECK ( + created_at_unix_ms > 0 + ), + created_at TIMESTAMPTZ CONSTRAINT assessment_participant_created_at_not_null NOT NULL + DEFAULT clock_timestamp(), + CONSTRAINT assessment_participant_pkey PRIMARY KEY (participant_ref) +); + +CREATE OR REPLACE FUNCTION reject_assessment_participant_mutation() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION 'assessment participant evidence is immutable' + USING ERRCODE = '55000'; +END; +$$; + +DROP TRIGGER IF EXISTS assessment_participant_immutable_guard + ON assessment_participant; +CREATE TRIGGER assessment_participant_immutable_guard + BEFORE UPDATE OR DELETE ON assessment_participant + FOR EACH ROW + EXECUTE FUNCTION reject_assessment_participant_mutation(); + +DROP TRIGGER IF EXISTS assessment_participant_truncate_guard + ON assessment_participant; +CREATE TRIGGER assessment_participant_truncate_guard + BEFORE TRUNCATE ON assessment_participant + FOR EACH STATEMENT + EXECUTE FUNCTION reject_assessment_participant_mutation(); diff --git a/src/lib.rs b/src/lib.rs index b8d2b8f9..a38dcd82 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,6 +28,7 @@ pub mod postgres_inbox_consumption; pub mod postgres_instrument_release; pub mod postgres_integration; pub mod postgres_item_delivery; +pub mod postgres_participant; pub mod postgres_response_snapshot; pub mod postgres_result_snapshot; pub mod postgres_scoring_job; diff --git a/src/postgres_participant.rs b/src/postgres_participant.rs new file mode 100644 index 00000000..5c9d3308 --- /dev/null +++ b/src/postgres_participant.rs @@ -0,0 +1,244 @@ +//! `PostgreSQL` 18 persistence for anonymous assessment participants. +//! +//! This adapter stores the product-owned participant identity that later +//! anonymous session commands must load. Tenant lives on this row, not on the +//! session aggregate. Identity-link history remains a later slice. 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::participant::ParticipantRecord; +use crate::reference::normalized_reference; +use postgres::{GenericClient, Transaction}; +use std::error::Error; +use std::fmt::{Display, Formatter}; + +const ASSESSMENT_PARTICIPANT_MIGRATION: &str = + include_str!("../migrations/0021_assessment_participant.sql"); + +/// Outcome of persisting one anonymous assessment participant. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ParticipantPersistenceDisposition { + /// A new anonymous participant row was inserted. + Inserted, + /// The same immutable participant identity already existed. + Duplicate, +} + +/// Fail-closed error for durable assessment-participant persistence. +#[derive(Debug)] +#[non_exhaustive] +pub enum ParticipantPersistenceError { + /// A participant or tenant identity was blank or numeric-like. + InvalidReference, + /// Participant identity was replayed with different immutable evidence. + ConflictingReplay, + /// A timestamp cannot be represented by the bounded database column. + InvalidTimestamp, + /// Assessment-participant persistence requires `PostgreSQL` `READ COMMITTED` isolation. + UnsupportedIsolationLevel, + /// The participant currently carries account-link evidence this slice does not store. + IdentityLinkOutOfScope, + /// No participant row exists for the requested tenant and participant reference. + NotFound, + /// `PostgreSQL` rejected or could not execute the persistence operation. + Database(postgres::Error), +} + +impl Display for ParticipantPersistenceError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::InvalidReference => { + "assessment participant persistence references must be opaque values" + } + Self::ConflictingReplay => { + "assessment participant identity was replayed with conflicting evidence" + } + Self::InvalidTimestamp => { + "assessment participant timestamp exceeds the PostgreSQL bigint range" + } + Self::UnsupportedIsolationLevel => { + "assessment participant persistence requires read committed isolation" + } + Self::IdentityLinkOutOfScope => { + "assessment participant persistence stores anonymous identity only" + } + Self::NotFound => "assessment participant was not found for the requested tenant", + Self::Database(_) => "PostgreSQL assessment-participant persistence failed", + }) + } +} + +impl Error for ParticipantPersistenceError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Database(error) => Some(error), + Self::InvalidReference + | Self::ConflictingReplay + | Self::InvalidTimestamp + | Self::UnsupportedIsolationLevel + | Self::IdentityLinkOutOfScope + | Self::NotFound => None, + } + } +} + +impl From for ParticipantPersistenceError { + fn from(error: postgres::Error) -> Self { + Self::Database(error) + } +} + +/// Apply the idempotent assessment-participant migration to a `PostgreSQL` connection. +/// +/// # Errors +/// +/// Returns the `PostgreSQL` error if the migration cannot be applied. +pub fn apply_assessment_participant_migration( + client: &mut impl GenericClient, +) -> Result<(), postgres::Error> { + client.batch_execute(ASSESSMENT_PARTICIPANT_MIGRATION) +} + +/// Persist one anonymous participant identity. +/// +/// Exact replay of the same participant reference, tenant, anonymous status, and +/// creation time is idempotent. Rebinding that reference to another tenant or +/// creation time fails closed. Linked participants are rejected so this slice +/// cannot silently drop account-link evidence. +/// +/// # Errors +/// +/// Returns [`ParticipantPersistenceError`] for unsupported isolation, a linked +/// participant, conflicting replay, an invalid timestamp, or a database failure. +pub fn persist_assessment_participant( + transaction: &mut Transaction<'_>, + participant: &ParticipantRecord, +) -> Result { + require_read_committed(transaction)?; + if participant.linked_subject_ref().is_some() || !participant.link_history().is_empty() { + return Err(ParticipantPersistenceError::IdentityLinkOutOfScope); + } + let participant_ref = required_reference(participant.participant_ref())?; + let tenant_ref = required_reference(participant.tenant_ref())?; + let created_at = postgres_timestamp(participant.created_at_unix_ms())?; + let inserted = transaction.execute( + "INSERT INTO assessment_participant (\ + participant_ref, tenant_ref, participant_status, created_at_unix_ms\ + ) VALUES ($1, $2, 'anonymous', $3) \ + ON CONFLICT (participant_ref) DO NOTHING", + &[&participant_ref, &tenant_ref, &created_at], + )?; + if inserted == 1 { + return Ok(ParticipantPersistenceDisposition::Inserted); + } + classify_existing_participant(transaction, participant_ref, tenant_ref, created_at) +} + +/// Load one anonymous participant by exact tenant and participant reference. +/// +/// The tenant comes from the stored row. A proof that names another tenant +/// cannot reconstruct this participant. +/// +/// # Errors +/// +/// Returns [`ParticipantPersistenceError::InvalidReference`] for a blank or +/// numeric-like identity, [`ParticipantPersistenceError::NotFound`] when no row +/// matches both references, or a database failure. +pub fn load_assessment_participant( + client: &mut impl GenericClient, + tenant_ref: &str, + participant_ref: &str, +) -> Result { + let tenant_ref = required_reference(tenant_ref)?; + let participant_ref = required_reference(participant_ref)?; + let row = client.query_opt( + "SELECT participant_ref, tenant_ref, created_at_unix_ms \ + FROM assessment_participant \ + WHERE tenant_ref = $1 AND participant_ref = $2 \ + AND participant_status = 'anonymous'", + &[&tenant_ref, &participant_ref], + )?; + let Some(row) = row else { + return Err(ParticipantPersistenceError::NotFound); + }; + let stored_participant_ref: String = row.get(0); + let stored_tenant_ref: String = row.get(1); + let created_at: i64 = row.get(2); + let created_at = + u64::try_from(created_at).map_err(|_| ParticipantPersistenceError::InvalidTimestamp)?; + ParticipantRecord::new_anonymous(&stored_participant_ref, &stored_tenant_ref, created_at) + .map_err(|_| ParticipantPersistenceError::InvalidTimestamp) +} + +fn classify_existing_participant( + transaction: &mut Transaction<'_>, + participant_ref: &str, + tenant_ref: &str, + created_at: i64, +) -> Result { + let row = transaction.query_one( + "SELECT tenant_ref, created_at_unix_ms, participant_status \ + FROM assessment_participant \ + WHERE participant_ref = $1", + &[&participant_ref], + )?; + let stored_tenant: String = row.get(0); + let stored_created_at: i64 = row.get(1); + let stored_status: String = row.get(2); + if stored_tenant == tenant_ref + && stored_created_at == created_at + && stored_status == "anonymous" + { + Ok(ParticipantPersistenceDisposition::Duplicate) + } else { + Err(ParticipantPersistenceError::ConflictingReplay) + } +} + +fn required_reference(reference: &str) -> Result<&str, ParticipantPersistenceError> { + normalized_reference(reference).ok_or(ParticipantPersistenceError::InvalidReference) +} + +fn postgres_timestamp(timestamp: u64) -> Result { + i64::try_from(timestamp).map_err(|_| ParticipantPersistenceError::InvalidTimestamp) +} + +fn require_read_committed( + transaction: &mut Transaction<'_>, +) -> Result<(), ParticipantPersistenceError> { + let row = transaction.query_one("SHOW transaction_isolation", &[])?; + let isolation: String = row.get(0); + if isolation == "read committed" { + Ok(()) + } else { + Err(ParticipantPersistenceError::UnsupportedIsolationLevel) + } +} + +#[cfg(test)] +mod reference_guard_tests { + use super::{postgres_timestamp, required_reference, ParticipantPersistenceError}; + + #[test] + fn blank_numeric_and_overflow_values_are_classified() { + assert!(matches!( + required_reference(" "), + Err(ParticipantPersistenceError::InvalidReference) + )); + assert!(matches!( + required_reference("12"), + Err(ParticipantPersistenceError::InvalidReference) + )); + assert_eq!( + required_reference("participant_persist_unit").unwrap(), + "participant_persist_unit" + ); + assert!(matches!( + postgres_timestamp(u64::MAX), + Err(ParticipantPersistenceError::InvalidTimestamp) + )); + assert_eq!(postgres_timestamp(70_000).unwrap(), 70_000); + } +} diff --git a/tests/postgres_participant_error_contract.rs b/tests/postgres_participant_error_contract.rs new file mode 100644 index 00000000..1e1a072e --- /dev/null +++ b/tests/postgres_participant_error_contract.rs @@ -0,0 +1,57 @@ +//! Stable operator-facing error contracts for assessment-participant persistence. + +use postgres::{Client, NoTls}; +use psychometrics_commons_runtime::postgres_participant::ParticipantPersistenceError; + +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 [ + ( + ParticipantPersistenceError::InvalidReference, + "assessment participant persistence references must be opaque values", + ), + ( + ParticipantPersistenceError::ConflictingReplay, + "assessment participant identity was replayed with conflicting evidence", + ), + ( + ParticipantPersistenceError::InvalidTimestamp, + "assessment participant timestamp exceeds the PostgreSQL bigint range", + ), + ( + ParticipantPersistenceError::UnsupportedIsolationLevel, + "assessment participant persistence requires read committed isolation", + ), + ( + ParticipantPersistenceError::IdentityLinkOutOfScope, + "assessment participant persistence stores anonymous identity only", + ), + ( + ParticipantPersistenceError::NotFound, + "assessment participant was not found for the requested tenant", + ), + ] { + 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 assessment_participant_error_contract_missing_relation", + &[], + ) + .unwrap_err(); + let error = ParticipantPersistenceError::from(database_error); + assert_eq!( + error.to_string(), + "PostgreSQL assessment-participant persistence failed" + ); + assert!(std::error::Error::source(&error).is_some()); +} diff --git a/tests/postgres_participant_persistence.rs b/tests/postgres_participant_persistence.rs new file mode 100644 index 00000000..d05a7de9 --- /dev/null +++ b/tests/postgres_participant_persistence.rs @@ -0,0 +1,426 @@ +//! Real `PostgreSQL` contract for durable anonymous assessment participants. +//! +//! A transport must persist the participant row, then load it by tenant and +//! participant reference, before anonymous command authorization. Reconstructing +//! the participant from the proof would make the tenant check tautological. + +use postgres::{Client, IsolationLevel, NoTls}; +use psychometrics_commons_runtime::anonymous_authorization::authorize_anonymous_session_command; +use psychometrics_commons_runtime::anonymous_session::AnonymousSessionContext; +use psychometrics_commons_runtime::instrument::{ + InstrumentRelease, InstrumentReleaseManifest, PublicationCommand, + PublicationEvidenceProvenance, PublicationEvidenceRecord, PublicationEvidenceStatus, +}; +use psychometrics_commons_runtime::participant::ParticipantRecord; +use psychometrics_commons_runtime::postgres_participant::{ + apply_assessment_participant_migration, load_assessment_participant, + persist_assessment_participant, ParticipantPersistenceDisposition, ParticipantPersistenceError, +}; +use psychometrics_commons_runtime::session::AssessmentSession; +use std::sync::{Mutex, MutexGuard}; + +const RELEASE_DIGEST: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const EVIDENCE_DIGEST: &str = + "sha256:1111111111111111111111111111111111111111111111111111111111111111"; + +static PARTICIPANT_TEST_LOCK: Mutex<()> = Mutex::new(()); + +fn participant_test_guard() -> MutexGuard<'static, ()> { + PARTICIPANT_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 assessment_participant_persistence_test;\ + SET search_path TO assessment_participant_persistence_test;", + ) + .unwrap(); + client +} + +fn reset_participant_table(client: &mut Client) { + client + .batch_execute( + "DROP TABLE IF EXISTS assessment_participant_persistence_test.assessment_participant;", + ) + .unwrap(); +} + +fn persist_ok( + client: &mut Client, + participant: &ParticipantRecord, +) -> ParticipantPersistenceDisposition { + let mut transaction = client.transaction().unwrap(); + let disposition = persist_assessment_participant(&mut transaction, participant).unwrap(); + transaction.commit().unwrap(); + disposition +} + +fn persist_err( + client: &mut Client, + participant: &ParticipantRecord, +) -> ParticipantPersistenceError { + let mut transaction = client.transaction().unwrap(); + let error = persist_assessment_participant(&mut transaction, participant).unwrap_err(); + transaction.rollback().unwrap(); + error +} + +fn published_release() -> InstrumentRelease { + let manifest = InstrumentReleaseManifest::new( + "release_big_five_ko_v1", + "instrument_big_five", + "instrument_version_big_five_ko_v1", + "construct_big_five", + &["item_version_001"], + "ko-KR", + "assessment_spec_big_five_v1", + "scoring_version_big_five_v1", + "calibration_big_five_ko_v1", + Some("norm_version_big_five_ko_v1"), + "narrative_version_big_five_v1", + &["consent_service_v1"], + "intended_use_self_reflection_v1", + "limitations_nonclinical_v1", + RELEASE_DIGEST, + ) + .unwrap(); + let evidence = PublicationEvidenceRecord::new( + "publication_evidence_big_five_ko_v1", + "evidence_policy_self_reflection_v1", + "release_big_five_ko_v1", + "instrument_version_big_five_ko_v1", + &["item_version_001"], + RELEASE_DIGEST, + "ko-KR", + "intended_use_self_reflection_v1", + "assessment_spec_big_five_v1", + "scoring_version_big_five_v1", + "calibration_big_five_ko_v1", + Some("norm_version_big_five_ko_v1"), + "limitations_nonclinical_v1", + PublicationEvidenceProvenance::new( + EVIDENCE_DIGEST, + "population_general_adult_v1", + "administration_web_self_report_v1", + "measurement_model_big_five_v1", + 10_050, + None, + ) + .unwrap(), + &["rights_ipip_big_five_v1"], + &["recovery_big_five_ko_v1"], + &["approval_psychometrics_big_five_ko_v1"], + PublicationEvidenceStatus::Approved, + ) + .unwrap(); + let mut release = InstrumentRelease::new(manifest, 10_000).unwrap(); + release + .apply_command( + "publication_review_11d5b1e7", + PublicationCommand::SubmitReview, + 10_100, + ) + .unwrap(); + release.bind_publication_evidence(evidence).unwrap(); + release + .apply_command( + "publication_publish_20f6c2a8", + PublicationCommand::Publish, + 10_200, + ) + .unwrap(); + release +} + +#[test] +fn anonymous_participant_persist_is_exactly_idempotent_and_reloadable() { + let _guard = participant_test_guard(); + let mut client = test_client(); + reset_participant_table(&mut client); + apply_assessment_participant_migration(&mut client).unwrap(); + + let participant = + ParticipantRecord::new_anonymous("participant_persist_alpha", "tenant_alpha", 1_000) + .unwrap(); + assert_eq!( + persist_ok(&mut client, &participant), + ParticipantPersistenceDisposition::Inserted + ); + assert_eq!( + persist_ok(&mut client, &participant), + ParticipantPersistenceDisposition::Duplicate + ); + + let loaded = + load_assessment_participant(&mut client, "tenant_alpha", "participant_persist_alpha") + .unwrap(); + assert_eq!(loaded.participant_ref(), "participant_persist_alpha"); + assert_eq!(loaded.tenant_ref(), "tenant_alpha"); + assert_eq!(loaded.created_at_unix_ms(), 1_000); + assert!(loaded.linked_subject_ref().is_none()); +} + +#[test] +fn conflicting_tenant_or_creation_time_replay_fails_closed() { + let _guard = participant_test_guard(); + let mut client = test_client(); + reset_participant_table(&mut client); + apply_assessment_participant_migration(&mut client).unwrap(); + + let original = + ParticipantRecord::new_anonymous("participant_persist_beta", "tenant_alpha", 2_000) + .unwrap(); + persist_ok(&mut client, &original); + + let foreign_tenant = + ParticipantRecord::new_anonymous("participant_persist_beta", "tenant_beta", 2_000).unwrap(); + assert!(matches!( + persist_err(&mut client, &foreign_tenant), + ParticipantPersistenceError::ConflictingReplay + )); + + let moved_clock = + ParticipantRecord::new_anonymous("participant_persist_beta", "tenant_alpha", 3_000) + .unwrap(); + assert!(matches!( + persist_err(&mut client, &moved_clock), + ParticipantPersistenceError::ConflictingReplay + )); +} + +#[test] +fn load_requires_the_stored_tenant_and_does_not_leak_a_foreign_row() { + let _guard = participant_test_guard(); + let mut client = test_client(); + reset_participant_table(&mut client); + apply_assessment_participant_migration(&mut client).unwrap(); + + let participant = + ParticipantRecord::new_anonymous("participant_persist_gamma", "tenant_alpha", 4_000) + .unwrap(); + persist_ok(&mut client, &participant); + + assert!(matches!( + load_assessment_participant(&mut client, "tenant_beta", "participant_persist_gamma"), + Err(ParticipantPersistenceError::NotFound) + )); + assert!(matches!( + load_assessment_participant(&mut client, "tenant_alpha", "participant_missing"), + Err(ParticipantPersistenceError::NotFound) + )); +} + +#[test] +fn loaded_persisted_participant_authorizes_only_its_stored_tenant() { + let _guard = participant_test_guard(); + let mut client = test_client(); + reset_participant_table(&mut client); + apply_assessment_participant_migration(&mut client).unwrap(); + + let stored = + ParticipantRecord::new_anonymous("participant_persist_delta", "tenant_alpha", 5_000) + .unwrap(); + persist_ok(&mut client, &stored); + let loaded = + load_assessment_participant(&mut client, "tenant_alpha", "participant_persist_delta") + .unwrap(); + let session = AssessmentSession::new( + "session_persist_delta", + loaded.participant_ref(), + &published_release(), + "ko-KR", + 20_000, + ) + .unwrap(); + let actor = AnonymousSessionContext::new( + "tenant_alpha", + "participant_persist_delta", + "session_persist_delta", + "anonymous_persist_evidence_delta", + 6_000, + ) + .unwrap(); + + assert_eq!( + authorize_anonymous_session_command(&actor, &loaded, &session, 5_500), + Ok(()) + ); + assert!(matches!( + load_assessment_participant(&mut client, actor.tenant_ref(), "participant_other"), + Err(ParticipantPersistenceError::NotFound) + )); +} + +#[test] +fn linked_participant_persistence_is_out_of_scope_for_this_slice() { + let _guard = participant_test_guard(); + let mut client = test_client(); + reset_participant_table(&mut client); + apply_assessment_participant_migration(&mut client).unwrap(); + + let mut linked = + ParticipantRecord::new_anonymous("participant_persist_epsilon", "tenant_alpha", 7_000) + .unwrap(); + linked + .link_account( + "link_event_persist_epsilon", + "issuer_keyverse", + "subject_epsilon", + "anonymous_proof_epsilon", + "authenticated_proof_epsilon", + 7_100, + ) + .unwrap(); + assert!(matches!( + persist_err(&mut client, &linked), + ParticipantPersistenceError::IdentityLinkOutOfScope + )); + assert!(matches!( + load_assessment_participant(&mut client, "tenant_alpha", "participant_persist_epsilon"), + Err(ParticipantPersistenceError::NotFound) + )); +} + +#[test] +fn overflow_timestamp_and_invalid_load_references_fail_closed() { + let _guard = participant_test_guard(); + let mut client = test_client(); + reset_participant_table(&mut client); + apply_assessment_participant_migration(&mut client).unwrap(); + + let overflow = + ParticipantRecord::new_anonymous("participant_persist_zeta", "tenant_alpha", u64::MAX) + .unwrap(); + assert!(matches!( + persist_err(&mut client, &overflow), + ParticipantPersistenceError::InvalidTimestamp + )); + assert!(matches!( + load_assessment_participant(&mut client, " ", "participant_persist_zeta"), + Err(ParticipantPersistenceError::InvalidReference) + )); + assert!(matches!( + load_assessment_participant(&mut client, "tenant_alpha", "12"), + Err(ParticipantPersistenceError::InvalidReference) + )); +} + +#[test] +fn assessment_participant_persistence_requires_read_committed() { + let _guard = participant_test_guard(); + let mut client = test_client(); + reset_participant_table(&mut client); + apply_assessment_participant_migration(&mut client).unwrap(); + + let participant = + ParticipantRecord::new_anonymous("participant_persist_eta", "tenant_alpha", 8_000).unwrap(); + let mut transaction = client + .build_transaction() + .isolation_level(IsolationLevel::Serializable) + .start() + .unwrap(); + assert!(matches!( + persist_assessment_participant(&mut transaction, &participant), + Err(ParticipantPersistenceError::UnsupportedIsolationLevel) + )); + transaction.rollback().unwrap(); +} + +#[test] +fn stored_status_mismatch_is_conflicting_replay() { + let _guard = participant_test_guard(); + let mut client = test_client(); + reset_participant_table(&mut client); + apply_assessment_participant_migration(&mut client).unwrap(); + let participant = + ParticipantRecord::new_anonymous("participant_persist_status", "tenant_alpha", 8_500) + .unwrap(); + persist_ok(&mut client, &participant); + client + .batch_execute( + "DROP TRIGGER IF EXISTS assessment_participant_immutable_guard \ + ON assessment_participant;\ + ALTER TABLE assessment_participant \ + DROP CONSTRAINT assessment_participant_status_value_check;\ + UPDATE assessment_participant SET participant_status = 'corrupted' \ + WHERE participant_ref = 'participant_persist_status';", + ) + .unwrap(); + assert!(matches!( + persist_err(&mut client, &participant), + ParticipantPersistenceError::ConflictingReplay + )); +} + +#[test] +fn corrupt_created_at_values_fail_closed_on_load() { + let _guard = participant_test_guard(); + let mut client = test_client(); + reset_participant_table(&mut client); + apply_assessment_participant_migration(&mut client).unwrap(); + client + .batch_execute( + "DROP TRIGGER IF EXISTS assessment_participant_immutable_guard \ + ON assessment_participant;\ + DROP TRIGGER IF EXISTS assessment_participant_truncate_guard \ + ON assessment_participant;\ + ALTER TABLE assessment_participant \ + DROP CONSTRAINT assessment_participant_created_at_unix_positive_check;", + ) + .unwrap(); + client + .execute( + "INSERT INTO assessment_participant (\ + participant_ref, tenant_ref, participant_status, created_at_unix_ms\ + ) VALUES ('participant_persist_negative', 'tenant_alpha', 'anonymous', -1)", + &[], + ) + .unwrap(); + client + .execute( + "INSERT INTO assessment_participant (\ + participant_ref, tenant_ref, participant_status, created_at_unix_ms\ + ) VALUES ('participant_persist_zero', 'tenant_alpha', 'anonymous', 0)", + &[], + ) + .unwrap(); + + assert!(matches!( + load_assessment_participant(&mut client, "tenant_alpha", "participant_persist_negative"), + Err(ParticipantPersistenceError::InvalidTimestamp) + )); + assert!(matches!( + load_assessment_participant(&mut client, "tenant_alpha", "participant_persist_zero"), + Err(ParticipantPersistenceError::InvalidTimestamp) + )); +} + +#[test] +fn missing_assessment_participant_relation_is_a_database_failure() { + let _guard = participant_test_guard(); + let mut client = test_client(); + reset_participant_table(&mut client); + + let participant = + ParticipantRecord::new_anonymous("participant_persist_theta", "tenant_alpha", 9_000) + .unwrap(); + let mut transaction = client.transaction().unwrap(); + assert!(matches!( + persist_assessment_participant(&mut transaction, &participant), + Err(ParticipantPersistenceError::Database(_)) + )); + transaction.rollback().unwrap(); + assert!(matches!( + load_assessment_participant(&mut client, "tenant_alpha", "participant_persist_theta"), + Err(ParticipantPersistenceError::Database(_)) + )); +} diff --git a/tests/postgres_participant_schema_constraints.rs b/tests/postgres_participant_schema_constraints.rs new file mode 100644 index 00000000..9b040028 --- /dev/null +++ b/tests/postgres_participant_schema_constraints.rs @@ -0,0 +1,129 @@ +//! Real `PostgreSQL` bounds for durable assessment-participant rows. + +use postgres::{Client, NoTls}; +use psychometrics_commons_runtime::postgres_participant::apply_assessment_participant_migration; +use std::sync::{Mutex, MutexGuard}; + +static PARTICIPANT_SCHEMA_LOCK: Mutex<()> = Mutex::new(()); + +fn schema_test_guard() -> MutexGuard<'static, ()> { + PARTICIPANT_SCHEMA_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 assessment_participant_schema_test;\ + SET search_path TO assessment_participant_schema_test;", + ) + .unwrap(); + client +} + +fn reset_schema(client: &mut Client) { + client + .batch_execute( + "DROP TABLE IF EXISTS assessment_participant_schema_test.assessment_participant;", + ) + .unwrap(); +} + +fn constraint_name(error: &postgres::Error) -> String { + error + .as_db_error() + .and_then(postgres::error::DbError::constraint) + .unwrap_or_default() + .to_owned() +} + +#[test] +fn schema_rejects_numeric_identity_zero_time_invalid_status_and_mutation() { + let _guard = schema_test_guard(); + let mut client = test_client(); + reset_schema(&mut client); + apply_assessment_participant_migration(&mut client).unwrap(); + apply_assessment_participant_migration(&mut client).unwrap(); + + let numeric = client + .execute( + "INSERT INTO assessment_participant (\ + participant_ref, tenant_ref, participant_status, created_at_unix_ms\ + ) VALUES ('12', 'tenant_alpha', 'anonymous', 1000)", + &[], + ) + .unwrap_err(); + assert_eq!( + constraint_name(&numeric), + "assessment_participant_participant_ref_format_check" + ); + + let padded_tenant = client + .execute( + "INSERT INTO assessment_participant (\ + participant_ref, tenant_ref, participant_status, created_at_unix_ms\ + ) VALUES ('participant_schema_alpha', ' tenant_alpha', 'anonymous', 1000)", + &[], + ) + .unwrap_err(); + assert_eq!( + constraint_name(&padded_tenant), + "assessment_participant_tenant_ref_format_check" + ); + + let zero_time = client + .execute( + "INSERT INTO assessment_participant (\ + participant_ref, tenant_ref, participant_status, created_at_unix_ms\ + ) VALUES ('participant_schema_alpha', 'tenant_alpha', 'anonymous', 0)", + &[], + ) + .unwrap_err(); + assert_eq!( + constraint_name(&zero_time), + "assessment_participant_created_at_unix_positive_check" + ); + + let unknown_status = client + .execute( + "INSERT INTO assessment_participant (\ + participant_ref, tenant_ref, participant_status, created_at_unix_ms\ + ) VALUES ('participant_schema_alpha', 'tenant_alpha', 'linked', 1000)", + &[], + ) + .unwrap_err(); + assert_eq!( + constraint_name(&unknown_status), + "assessment_participant_status_value_check" + ); + + client + .execute( + "INSERT INTO assessment_participant (\ + participant_ref, tenant_ref, participant_status, created_at_unix_ms\ + ) VALUES ('participant_schema_alpha', 'tenant_alpha', 'anonymous', 1000)", + &[], + ) + .unwrap(); + + let update = client + .execute( + "UPDATE assessment_participant SET created_at_unix_ms = 2000 \ + WHERE participant_ref = 'participant_schema_alpha'", + &[], + ) + .unwrap_err(); + assert_eq!( + update + .as_db_error() + .expect("immutable participant evidence must fail at the database boundary") + .code() + .code(), + "55000" + ); +} From 53ae1187fc8cf838b6a6bd11e491f26982996c90 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:30:14 +0000 Subject: [PATCH 11/16] docs(traceability): name Active PR #118 for participant persist Record the opened successor so architecture views do not leave the assessment-participant slice unlabeled. Co-authored-by: Seongho Bae --- docs/TRACEABILITY.md | 2 +- docs/architecture/AS_BUILT_SCHEMA.md | 2 +- docs/architecture/ERD.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2d5d3438..621eed0e 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** #86 anonymous-session resource authorization, plus follow-up #104 and its successor that compare the verified actor to the loaded participant tenant/owner and loaded session and apply a lifecycle command only after that check, is not protected-main truth until an unchanged reviewed/check-clean head is integrated. The same successor persists and reloads anonymous `assessment_participant` rows so tenant is taken from the store rather than reconstructed from the proof. HTTP transport remains outside this slice. +**Active PR** #86 anonymous-session resource authorization, plus follow-up #104 and successor #118 that compare the verified actor to the loaded participant tenant/owner and loaded session and apply a lifecycle command only after that check, is not protected-main truth until an unchanged reviewed/check-clean head is integrated. #118 also persists and reloads anonymous `assessment_participant` rows so tenant is taken from the store rather than reconstructed from the proof. HTTP transport remains outside this slice. ## 5. ADR traceability by concern diff --git a/docs/architecture/AS_BUILT_SCHEMA.md b/docs/architecture/AS_BUILT_SCHEMA.md index 8d2e11da..a0a64dcb 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 assessment-participant physical schema -This successor to #104 persists anonymous `assessment_participant` identity in `migrations/0021_assessment_participant.sql` and `src/postgres_participant.rs`. The slice is **Active PR**, not protected-main truth. It stores opaque `participant_ref`, `tenant_ref`, anonymous `participant_status`, and `created_at_unix_ms`. Exact replay is idempotent. Rebinding tenant or creation time fails closed. Load requires both stored tenant and participant references. Linked participants are rejected. Account-link history, HTTP transport, and session persistence remain outside this slice. +PR #118 persists anonymous `assessment_participant` identity in `migrations/0021_assessment_participant.sql` and `src/postgres_participant.rs`. The slice is **Active PR**, not protected-main truth. It stores opaque `participant_ref`, `tenant_ref`, anonymous `participant_status`, and `created_at_unix_ms`. Exact replay is idempotent. Rebinding tenant or creation time fails closed. Load requires both stored tenant and participant references. Linked participants are rejected. Account-link history, HTTP transport, and session persistence remain outside this slice. ## Logical-to-physical mapping rule diff --git a/docs/architecture/ERD.md b/docs/architecture/ERD.md index f95c324f..0a4b6569 100644 --- a/docs/architecture/ERD.md +++ b/docs/architecture/ERD.md @@ -426,7 +426,7 @@ The target ERD deliberately includes several logical entities that are not yet p - `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. - `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. -- `assessment_participant` anonymous identity persistence is carried by this Active PR (`migrations/0021_assessment_participant.sql` / `src/postgres_participant.rs`): tenant, participant reference, anonymous status, and creation time. It is not protected-main truth until integrated. Account-link columns remain out of scope. +- `assessment_participant` anonymous identity persistence is carried by Active PR #118 (`migrations/0021_assessment_participant.sql` / `src/postgres_participant.rs`): tenant, participant reference, anonymous status, and creation time. It is not protected-main truth until integrated. Account-link columns remain out of scope. - `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. - `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. From 708fb5cf1f231b634b328c9905b0c8d81d24bfe8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:31:40 +0000 Subject: [PATCH 12/16] revert(participant): leave persist/reload on Active PR #114 #114 already owns assessment_participant plus append-only identity-link history on migration 0021. This successor keeps the #104 command-auth contract fix and does not open a colliding anonymous-only persist slice. Co-authored-by: Seongho Bae --- .gitignore | 1 + CHANGELOG.md | 3 +- docs/TRACEABILITY.md | 2 +- docs/architecture/AS_BUILT_SCHEMA.md | 4 - docs/architecture/ERD.md | 3 +- migrations/0021_assessment_participant.sql | 55 --- src/lib.rs | 1 - src/postgres_participant.rs | 244 ---------- tests/postgres_participant_error_contract.rs | 57 --- tests/postgres_participant_persistence.rs | 426 ------------------ ...postgres_participant_schema_constraints.rs | 129 ------ 11 files changed, 4 insertions(+), 921 deletions(-) create mode 100644 .gitignore delete mode 100644 migrations/0021_assessment_participant.sql delete mode 100644 src/postgres_participant.rs delete mode 100644 tests/postgres_participant_error_contract.rs delete mode 100644 tests/postgres_participant_persistence.rs delete mode 100644 tests/postgres_participant_schema_constraints.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..b83d2226 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d855aba1..080ca331 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,7 @@ All notable product and architecture changes are recorded here. Releases use imm ## Unreleased ### Added -- PostgreSQL persistence for anonymous `assessment_participant` identity: exact tenant/participant replay is idempotent, rebinding tenant or creation time fails closed, load requires the stored tenant, and linked participants are rejected so account-link evidence cannot be dropped. -- Anonymous session command authorization compares the verified actor to the loaded participant tenant/owner and loaded session reference, then applies a lifecycle command only after that check. The command entry point does not accept a caller-built resource scope. The lower-level exact-resource check remains available for callers that already hold a stored `ResourceScope`. +- Anonymous session command authorization compares the verified actor to the loaded participant tenant/owner and loaded session reference, then applies a lifecycle command only after that check. The command entry point does not accept a caller-built resource scope. The lower-level exact-resource check remains available for callers that already hold a stored `ResourceScope`. Participant persist/reload remains Active PR #114. - 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 621eed0e..8a3a2f49 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** #86 anonymous-session resource authorization, plus follow-up #104 and successor #118 that compare the verified actor to the loaded participant tenant/owner and loaded session and apply a lifecycle command only after that check, is not protected-main truth until an unchanged reviewed/check-clean head is integrated. #118 also persists and reloads anonymous `assessment_participant` rows so tenant is taken from the store rather than reconstructed from the proof. HTTP transport remains outside this slice. +**Active PR** #86 anonymous-session resource authorization, plus follow-up #104 and successor #118 that compare the verified actor to the loaded participant tenant/owner and loaded session and apply a lifecycle command only after that check, is not protected-main truth until an unchanged reviewed/check-clean head is integrated. Persist/reload of `assessment_participant` and append-only identity-link history remains Active PR #114. HTTP transport remains outside this slice. ## 5. ADR traceability by concern diff --git a/docs/architecture/AS_BUILT_SCHEMA.md b/docs/architecture/AS_BUILT_SCHEMA.md index a0a64dcb..8c2c3510 100644 --- a/docs/architecture/AS_BUILT_SCHEMA.md +++ b/docs/architecture/AS_BUILT_SCHEMA.md @@ -58,10 +58,6 @@ 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 assessment-participant physical schema - -PR #118 persists anonymous `assessment_participant` identity in `migrations/0021_assessment_participant.sql` and `src/postgres_participant.rs`. The slice is **Active PR**, not protected-main truth. It stores opaque `participant_ref`, `tenant_ref`, anonymous `participant_status`, and `created_at_unix_ms`. Exact replay is idempotent. Rebinding tenant or creation time fails closed. Load requires both stored tenant and participant references. Linked participants are rejected. Account-link history, HTTP transport, and session persistence remain 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 0a4b6569..2be14ec1 100644 --- a/docs/architecture/ERD.md +++ b/docs/architecture/ERD.md @@ -426,8 +426,7 @@ The target ERD deliberately includes several logical entities that are not yet p - `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. - `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. -- `assessment_participant` anonymous identity persistence is carried by Active PR #118 (`migrations/0021_assessment_participant.sql` / `src/postgres_participant.rs`): tenant, participant reference, anonymous status, and creation time. It is not protected-main truth until integrated. Account-link columns remain out of scope. -- `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. +- `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. Active PR #114 persists `assessment_participant` plus append-only link history; it is not protected-main truth until integrated. - `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. - `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. diff --git a/migrations/0021_assessment_participant.sql b/migrations/0021_assessment_participant.sql deleted file mode 100644 index 8d008e21..00000000 --- a/migrations/0021_assessment_participant.sql +++ /dev/null @@ -1,55 +0,0 @@ -CREATE TABLE IF NOT EXISTS assessment_participant ( - participant_ref TEXT CONSTRAINT assessment_participant_participant_ref_not_null NOT NULL - CONSTRAINT assessment_participant_participant_ref_format_check CHECK ( - participant_ref = btrim(participant_ref) - AND participant_ref <> '' - AND NOT ( - participant_ref ~ '[[:digit:]]' - AND participant_ref ~ '^[[:digit:]+,.eE-]+$' - ) - ), - tenant_ref TEXT CONSTRAINT assessment_participant_tenant_ref_not_null NOT NULL - CONSTRAINT assessment_participant_tenant_ref_format_check CHECK ( - tenant_ref = btrim(tenant_ref) - AND tenant_ref <> '' - AND NOT ( - tenant_ref ~ '[[:digit:]]' - AND tenant_ref ~ '^[[:digit:]+,.eE-]+$' - ) - ), - participant_status TEXT CONSTRAINT assessment_participant_status_not_null NOT NULL - CONSTRAINT assessment_participant_status_value_check CHECK ( - participant_status = 'anonymous' - ), - created_at_unix_ms BIGINT CONSTRAINT assessment_participant_created_at_unix_not_null NOT NULL - CONSTRAINT assessment_participant_created_at_unix_positive_check CHECK ( - created_at_unix_ms > 0 - ), - created_at TIMESTAMPTZ CONSTRAINT assessment_participant_created_at_not_null NOT NULL - DEFAULT clock_timestamp(), - CONSTRAINT assessment_participant_pkey PRIMARY KEY (participant_ref) -); - -CREATE OR REPLACE FUNCTION reject_assessment_participant_mutation() -RETURNS trigger -LANGUAGE plpgsql -AS $$ -BEGIN - RAISE EXCEPTION 'assessment participant evidence is immutable' - USING ERRCODE = '55000'; -END; -$$; - -DROP TRIGGER IF EXISTS assessment_participant_immutable_guard - ON assessment_participant; -CREATE TRIGGER assessment_participant_immutable_guard - BEFORE UPDATE OR DELETE ON assessment_participant - FOR EACH ROW - EXECUTE FUNCTION reject_assessment_participant_mutation(); - -DROP TRIGGER IF EXISTS assessment_participant_truncate_guard - ON assessment_participant; -CREATE TRIGGER assessment_participant_truncate_guard - BEFORE TRUNCATE ON assessment_participant - FOR EACH STATEMENT - EXECUTE FUNCTION reject_assessment_participant_mutation(); diff --git a/src/lib.rs b/src/lib.rs index a38dcd82..b8d2b8f9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,7 +28,6 @@ pub mod postgres_inbox_consumption; pub mod postgres_instrument_release; pub mod postgres_integration; pub mod postgres_item_delivery; -pub mod postgres_participant; pub mod postgres_response_snapshot; pub mod postgres_result_snapshot; pub mod postgres_scoring_job; diff --git a/src/postgres_participant.rs b/src/postgres_participant.rs deleted file mode 100644 index 5c9d3308..00000000 --- a/src/postgres_participant.rs +++ /dev/null @@ -1,244 +0,0 @@ -//! `PostgreSQL` 18 persistence for anonymous assessment participants. -//! -//! This adapter stores the product-owned participant identity that later -//! anonymous session commands must load. Tenant lives on this row, not on the -//! session aggregate. Identity-link history remains a later slice. 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::participant::ParticipantRecord; -use crate::reference::normalized_reference; -use postgres::{GenericClient, Transaction}; -use std::error::Error; -use std::fmt::{Display, Formatter}; - -const ASSESSMENT_PARTICIPANT_MIGRATION: &str = - include_str!("../migrations/0021_assessment_participant.sql"); - -/// Outcome of persisting one anonymous assessment participant. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum ParticipantPersistenceDisposition { - /// A new anonymous participant row was inserted. - Inserted, - /// The same immutable participant identity already existed. - Duplicate, -} - -/// Fail-closed error for durable assessment-participant persistence. -#[derive(Debug)] -#[non_exhaustive] -pub enum ParticipantPersistenceError { - /// A participant or tenant identity was blank or numeric-like. - InvalidReference, - /// Participant identity was replayed with different immutable evidence. - ConflictingReplay, - /// A timestamp cannot be represented by the bounded database column. - InvalidTimestamp, - /// Assessment-participant persistence requires `PostgreSQL` `READ COMMITTED` isolation. - UnsupportedIsolationLevel, - /// The participant currently carries account-link evidence this slice does not store. - IdentityLinkOutOfScope, - /// No participant row exists for the requested tenant and participant reference. - NotFound, - /// `PostgreSQL` rejected or could not execute the persistence operation. - Database(postgres::Error), -} - -impl Display for ParticipantPersistenceError { - fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { - formatter.write_str(match self { - Self::InvalidReference => { - "assessment participant persistence references must be opaque values" - } - Self::ConflictingReplay => { - "assessment participant identity was replayed with conflicting evidence" - } - Self::InvalidTimestamp => { - "assessment participant timestamp exceeds the PostgreSQL bigint range" - } - Self::UnsupportedIsolationLevel => { - "assessment participant persistence requires read committed isolation" - } - Self::IdentityLinkOutOfScope => { - "assessment participant persistence stores anonymous identity only" - } - Self::NotFound => "assessment participant was not found for the requested tenant", - Self::Database(_) => "PostgreSQL assessment-participant persistence failed", - }) - } -} - -impl Error for ParticipantPersistenceError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::Database(error) => Some(error), - Self::InvalidReference - | Self::ConflictingReplay - | Self::InvalidTimestamp - | Self::UnsupportedIsolationLevel - | Self::IdentityLinkOutOfScope - | Self::NotFound => None, - } - } -} - -impl From for ParticipantPersistenceError { - fn from(error: postgres::Error) -> Self { - Self::Database(error) - } -} - -/// Apply the idempotent assessment-participant migration to a `PostgreSQL` connection. -/// -/// # Errors -/// -/// Returns the `PostgreSQL` error if the migration cannot be applied. -pub fn apply_assessment_participant_migration( - client: &mut impl GenericClient, -) -> Result<(), postgres::Error> { - client.batch_execute(ASSESSMENT_PARTICIPANT_MIGRATION) -} - -/// Persist one anonymous participant identity. -/// -/// Exact replay of the same participant reference, tenant, anonymous status, and -/// creation time is idempotent. Rebinding that reference to another tenant or -/// creation time fails closed. Linked participants are rejected so this slice -/// cannot silently drop account-link evidence. -/// -/// # Errors -/// -/// Returns [`ParticipantPersistenceError`] for unsupported isolation, a linked -/// participant, conflicting replay, an invalid timestamp, or a database failure. -pub fn persist_assessment_participant( - transaction: &mut Transaction<'_>, - participant: &ParticipantRecord, -) -> Result { - require_read_committed(transaction)?; - if participant.linked_subject_ref().is_some() || !participant.link_history().is_empty() { - return Err(ParticipantPersistenceError::IdentityLinkOutOfScope); - } - let participant_ref = required_reference(participant.participant_ref())?; - let tenant_ref = required_reference(participant.tenant_ref())?; - let created_at = postgres_timestamp(participant.created_at_unix_ms())?; - let inserted = transaction.execute( - "INSERT INTO assessment_participant (\ - participant_ref, tenant_ref, participant_status, created_at_unix_ms\ - ) VALUES ($1, $2, 'anonymous', $3) \ - ON CONFLICT (participant_ref) DO NOTHING", - &[&participant_ref, &tenant_ref, &created_at], - )?; - if inserted == 1 { - return Ok(ParticipantPersistenceDisposition::Inserted); - } - classify_existing_participant(transaction, participant_ref, tenant_ref, created_at) -} - -/// Load one anonymous participant by exact tenant and participant reference. -/// -/// The tenant comes from the stored row. A proof that names another tenant -/// cannot reconstruct this participant. -/// -/// # Errors -/// -/// Returns [`ParticipantPersistenceError::InvalidReference`] for a blank or -/// numeric-like identity, [`ParticipantPersistenceError::NotFound`] when no row -/// matches both references, or a database failure. -pub fn load_assessment_participant( - client: &mut impl GenericClient, - tenant_ref: &str, - participant_ref: &str, -) -> Result { - let tenant_ref = required_reference(tenant_ref)?; - let participant_ref = required_reference(participant_ref)?; - let row = client.query_opt( - "SELECT participant_ref, tenant_ref, created_at_unix_ms \ - FROM assessment_participant \ - WHERE tenant_ref = $1 AND participant_ref = $2 \ - AND participant_status = 'anonymous'", - &[&tenant_ref, &participant_ref], - )?; - let Some(row) = row else { - return Err(ParticipantPersistenceError::NotFound); - }; - let stored_participant_ref: String = row.get(0); - let stored_tenant_ref: String = row.get(1); - let created_at: i64 = row.get(2); - let created_at = - u64::try_from(created_at).map_err(|_| ParticipantPersistenceError::InvalidTimestamp)?; - ParticipantRecord::new_anonymous(&stored_participant_ref, &stored_tenant_ref, created_at) - .map_err(|_| ParticipantPersistenceError::InvalidTimestamp) -} - -fn classify_existing_participant( - transaction: &mut Transaction<'_>, - participant_ref: &str, - tenant_ref: &str, - created_at: i64, -) -> Result { - let row = transaction.query_one( - "SELECT tenant_ref, created_at_unix_ms, participant_status \ - FROM assessment_participant \ - WHERE participant_ref = $1", - &[&participant_ref], - )?; - let stored_tenant: String = row.get(0); - let stored_created_at: i64 = row.get(1); - let stored_status: String = row.get(2); - if stored_tenant == tenant_ref - && stored_created_at == created_at - && stored_status == "anonymous" - { - Ok(ParticipantPersistenceDisposition::Duplicate) - } else { - Err(ParticipantPersistenceError::ConflictingReplay) - } -} - -fn required_reference(reference: &str) -> Result<&str, ParticipantPersistenceError> { - normalized_reference(reference).ok_or(ParticipantPersistenceError::InvalidReference) -} - -fn postgres_timestamp(timestamp: u64) -> Result { - i64::try_from(timestamp).map_err(|_| ParticipantPersistenceError::InvalidTimestamp) -} - -fn require_read_committed( - transaction: &mut Transaction<'_>, -) -> Result<(), ParticipantPersistenceError> { - let row = transaction.query_one("SHOW transaction_isolation", &[])?; - let isolation: String = row.get(0); - if isolation == "read committed" { - Ok(()) - } else { - Err(ParticipantPersistenceError::UnsupportedIsolationLevel) - } -} - -#[cfg(test)] -mod reference_guard_tests { - use super::{postgres_timestamp, required_reference, ParticipantPersistenceError}; - - #[test] - fn blank_numeric_and_overflow_values_are_classified() { - assert!(matches!( - required_reference(" "), - Err(ParticipantPersistenceError::InvalidReference) - )); - assert!(matches!( - required_reference("12"), - Err(ParticipantPersistenceError::InvalidReference) - )); - assert_eq!( - required_reference("participant_persist_unit").unwrap(), - "participant_persist_unit" - ); - assert!(matches!( - postgres_timestamp(u64::MAX), - Err(ParticipantPersistenceError::InvalidTimestamp) - )); - assert_eq!(postgres_timestamp(70_000).unwrap(), 70_000); - } -} diff --git a/tests/postgres_participant_error_contract.rs b/tests/postgres_participant_error_contract.rs deleted file mode 100644 index 1e1a072e..00000000 --- a/tests/postgres_participant_error_contract.rs +++ /dev/null @@ -1,57 +0,0 @@ -//! Stable operator-facing error contracts for assessment-participant persistence. - -use postgres::{Client, NoTls}; -use psychometrics_commons_runtime::postgres_participant::ParticipantPersistenceError; - -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 [ - ( - ParticipantPersistenceError::InvalidReference, - "assessment participant persistence references must be opaque values", - ), - ( - ParticipantPersistenceError::ConflictingReplay, - "assessment participant identity was replayed with conflicting evidence", - ), - ( - ParticipantPersistenceError::InvalidTimestamp, - "assessment participant timestamp exceeds the PostgreSQL bigint range", - ), - ( - ParticipantPersistenceError::UnsupportedIsolationLevel, - "assessment participant persistence requires read committed isolation", - ), - ( - ParticipantPersistenceError::IdentityLinkOutOfScope, - "assessment participant persistence stores anonymous identity only", - ), - ( - ParticipantPersistenceError::NotFound, - "assessment participant was not found for the requested tenant", - ), - ] { - 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 assessment_participant_error_contract_missing_relation", - &[], - ) - .unwrap_err(); - let error = ParticipantPersistenceError::from(database_error); - assert_eq!( - error.to_string(), - "PostgreSQL assessment-participant persistence failed" - ); - assert!(std::error::Error::source(&error).is_some()); -} diff --git a/tests/postgres_participant_persistence.rs b/tests/postgres_participant_persistence.rs deleted file mode 100644 index d05a7de9..00000000 --- a/tests/postgres_participant_persistence.rs +++ /dev/null @@ -1,426 +0,0 @@ -//! Real `PostgreSQL` contract for durable anonymous assessment participants. -//! -//! A transport must persist the participant row, then load it by tenant and -//! participant reference, before anonymous command authorization. Reconstructing -//! the participant from the proof would make the tenant check tautological. - -use postgres::{Client, IsolationLevel, NoTls}; -use psychometrics_commons_runtime::anonymous_authorization::authorize_anonymous_session_command; -use psychometrics_commons_runtime::anonymous_session::AnonymousSessionContext; -use psychometrics_commons_runtime::instrument::{ - InstrumentRelease, InstrumentReleaseManifest, PublicationCommand, - PublicationEvidenceProvenance, PublicationEvidenceRecord, PublicationEvidenceStatus, -}; -use psychometrics_commons_runtime::participant::ParticipantRecord; -use psychometrics_commons_runtime::postgres_participant::{ - apply_assessment_participant_migration, load_assessment_participant, - persist_assessment_participant, ParticipantPersistenceDisposition, ParticipantPersistenceError, -}; -use psychometrics_commons_runtime::session::AssessmentSession; -use std::sync::{Mutex, MutexGuard}; - -const RELEASE_DIGEST: &str = - "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; -const EVIDENCE_DIGEST: &str = - "sha256:1111111111111111111111111111111111111111111111111111111111111111"; - -static PARTICIPANT_TEST_LOCK: Mutex<()> = Mutex::new(()); - -fn participant_test_guard() -> MutexGuard<'static, ()> { - PARTICIPANT_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 assessment_participant_persistence_test;\ - SET search_path TO assessment_participant_persistence_test;", - ) - .unwrap(); - client -} - -fn reset_participant_table(client: &mut Client) { - client - .batch_execute( - "DROP TABLE IF EXISTS assessment_participant_persistence_test.assessment_participant;", - ) - .unwrap(); -} - -fn persist_ok( - client: &mut Client, - participant: &ParticipantRecord, -) -> ParticipantPersistenceDisposition { - let mut transaction = client.transaction().unwrap(); - let disposition = persist_assessment_participant(&mut transaction, participant).unwrap(); - transaction.commit().unwrap(); - disposition -} - -fn persist_err( - client: &mut Client, - participant: &ParticipantRecord, -) -> ParticipantPersistenceError { - let mut transaction = client.transaction().unwrap(); - let error = persist_assessment_participant(&mut transaction, participant).unwrap_err(); - transaction.rollback().unwrap(); - error -} - -fn published_release() -> InstrumentRelease { - let manifest = InstrumentReleaseManifest::new( - "release_big_five_ko_v1", - "instrument_big_five", - "instrument_version_big_five_ko_v1", - "construct_big_five", - &["item_version_001"], - "ko-KR", - "assessment_spec_big_five_v1", - "scoring_version_big_five_v1", - "calibration_big_five_ko_v1", - Some("norm_version_big_five_ko_v1"), - "narrative_version_big_five_v1", - &["consent_service_v1"], - "intended_use_self_reflection_v1", - "limitations_nonclinical_v1", - RELEASE_DIGEST, - ) - .unwrap(); - let evidence = PublicationEvidenceRecord::new( - "publication_evidence_big_five_ko_v1", - "evidence_policy_self_reflection_v1", - "release_big_five_ko_v1", - "instrument_version_big_five_ko_v1", - &["item_version_001"], - RELEASE_DIGEST, - "ko-KR", - "intended_use_self_reflection_v1", - "assessment_spec_big_five_v1", - "scoring_version_big_five_v1", - "calibration_big_five_ko_v1", - Some("norm_version_big_five_ko_v1"), - "limitations_nonclinical_v1", - PublicationEvidenceProvenance::new( - EVIDENCE_DIGEST, - "population_general_adult_v1", - "administration_web_self_report_v1", - "measurement_model_big_five_v1", - 10_050, - None, - ) - .unwrap(), - &["rights_ipip_big_five_v1"], - &["recovery_big_five_ko_v1"], - &["approval_psychometrics_big_five_ko_v1"], - PublicationEvidenceStatus::Approved, - ) - .unwrap(); - let mut release = InstrumentRelease::new(manifest, 10_000).unwrap(); - release - .apply_command( - "publication_review_11d5b1e7", - PublicationCommand::SubmitReview, - 10_100, - ) - .unwrap(); - release.bind_publication_evidence(evidence).unwrap(); - release - .apply_command( - "publication_publish_20f6c2a8", - PublicationCommand::Publish, - 10_200, - ) - .unwrap(); - release -} - -#[test] -fn anonymous_participant_persist_is_exactly_idempotent_and_reloadable() { - let _guard = participant_test_guard(); - let mut client = test_client(); - reset_participant_table(&mut client); - apply_assessment_participant_migration(&mut client).unwrap(); - - let participant = - ParticipantRecord::new_anonymous("participant_persist_alpha", "tenant_alpha", 1_000) - .unwrap(); - assert_eq!( - persist_ok(&mut client, &participant), - ParticipantPersistenceDisposition::Inserted - ); - assert_eq!( - persist_ok(&mut client, &participant), - ParticipantPersistenceDisposition::Duplicate - ); - - let loaded = - load_assessment_participant(&mut client, "tenant_alpha", "participant_persist_alpha") - .unwrap(); - assert_eq!(loaded.participant_ref(), "participant_persist_alpha"); - assert_eq!(loaded.tenant_ref(), "tenant_alpha"); - assert_eq!(loaded.created_at_unix_ms(), 1_000); - assert!(loaded.linked_subject_ref().is_none()); -} - -#[test] -fn conflicting_tenant_or_creation_time_replay_fails_closed() { - let _guard = participant_test_guard(); - let mut client = test_client(); - reset_participant_table(&mut client); - apply_assessment_participant_migration(&mut client).unwrap(); - - let original = - ParticipantRecord::new_anonymous("participant_persist_beta", "tenant_alpha", 2_000) - .unwrap(); - persist_ok(&mut client, &original); - - let foreign_tenant = - ParticipantRecord::new_anonymous("participant_persist_beta", "tenant_beta", 2_000).unwrap(); - assert!(matches!( - persist_err(&mut client, &foreign_tenant), - ParticipantPersistenceError::ConflictingReplay - )); - - let moved_clock = - ParticipantRecord::new_anonymous("participant_persist_beta", "tenant_alpha", 3_000) - .unwrap(); - assert!(matches!( - persist_err(&mut client, &moved_clock), - ParticipantPersistenceError::ConflictingReplay - )); -} - -#[test] -fn load_requires_the_stored_tenant_and_does_not_leak_a_foreign_row() { - let _guard = participant_test_guard(); - let mut client = test_client(); - reset_participant_table(&mut client); - apply_assessment_participant_migration(&mut client).unwrap(); - - let participant = - ParticipantRecord::new_anonymous("participant_persist_gamma", "tenant_alpha", 4_000) - .unwrap(); - persist_ok(&mut client, &participant); - - assert!(matches!( - load_assessment_participant(&mut client, "tenant_beta", "participant_persist_gamma"), - Err(ParticipantPersistenceError::NotFound) - )); - assert!(matches!( - load_assessment_participant(&mut client, "tenant_alpha", "participant_missing"), - Err(ParticipantPersistenceError::NotFound) - )); -} - -#[test] -fn loaded_persisted_participant_authorizes_only_its_stored_tenant() { - let _guard = participant_test_guard(); - let mut client = test_client(); - reset_participant_table(&mut client); - apply_assessment_participant_migration(&mut client).unwrap(); - - let stored = - ParticipantRecord::new_anonymous("participant_persist_delta", "tenant_alpha", 5_000) - .unwrap(); - persist_ok(&mut client, &stored); - let loaded = - load_assessment_participant(&mut client, "tenant_alpha", "participant_persist_delta") - .unwrap(); - let session = AssessmentSession::new( - "session_persist_delta", - loaded.participant_ref(), - &published_release(), - "ko-KR", - 20_000, - ) - .unwrap(); - let actor = AnonymousSessionContext::new( - "tenant_alpha", - "participant_persist_delta", - "session_persist_delta", - "anonymous_persist_evidence_delta", - 6_000, - ) - .unwrap(); - - assert_eq!( - authorize_anonymous_session_command(&actor, &loaded, &session, 5_500), - Ok(()) - ); - assert!(matches!( - load_assessment_participant(&mut client, actor.tenant_ref(), "participant_other"), - Err(ParticipantPersistenceError::NotFound) - )); -} - -#[test] -fn linked_participant_persistence_is_out_of_scope_for_this_slice() { - let _guard = participant_test_guard(); - let mut client = test_client(); - reset_participant_table(&mut client); - apply_assessment_participant_migration(&mut client).unwrap(); - - let mut linked = - ParticipantRecord::new_anonymous("participant_persist_epsilon", "tenant_alpha", 7_000) - .unwrap(); - linked - .link_account( - "link_event_persist_epsilon", - "issuer_keyverse", - "subject_epsilon", - "anonymous_proof_epsilon", - "authenticated_proof_epsilon", - 7_100, - ) - .unwrap(); - assert!(matches!( - persist_err(&mut client, &linked), - ParticipantPersistenceError::IdentityLinkOutOfScope - )); - assert!(matches!( - load_assessment_participant(&mut client, "tenant_alpha", "participant_persist_epsilon"), - Err(ParticipantPersistenceError::NotFound) - )); -} - -#[test] -fn overflow_timestamp_and_invalid_load_references_fail_closed() { - let _guard = participant_test_guard(); - let mut client = test_client(); - reset_participant_table(&mut client); - apply_assessment_participant_migration(&mut client).unwrap(); - - let overflow = - ParticipantRecord::new_anonymous("participant_persist_zeta", "tenant_alpha", u64::MAX) - .unwrap(); - assert!(matches!( - persist_err(&mut client, &overflow), - ParticipantPersistenceError::InvalidTimestamp - )); - assert!(matches!( - load_assessment_participant(&mut client, " ", "participant_persist_zeta"), - Err(ParticipantPersistenceError::InvalidReference) - )); - assert!(matches!( - load_assessment_participant(&mut client, "tenant_alpha", "12"), - Err(ParticipantPersistenceError::InvalidReference) - )); -} - -#[test] -fn assessment_participant_persistence_requires_read_committed() { - let _guard = participant_test_guard(); - let mut client = test_client(); - reset_participant_table(&mut client); - apply_assessment_participant_migration(&mut client).unwrap(); - - let participant = - ParticipantRecord::new_anonymous("participant_persist_eta", "tenant_alpha", 8_000).unwrap(); - let mut transaction = client - .build_transaction() - .isolation_level(IsolationLevel::Serializable) - .start() - .unwrap(); - assert!(matches!( - persist_assessment_participant(&mut transaction, &participant), - Err(ParticipantPersistenceError::UnsupportedIsolationLevel) - )); - transaction.rollback().unwrap(); -} - -#[test] -fn stored_status_mismatch_is_conflicting_replay() { - let _guard = participant_test_guard(); - let mut client = test_client(); - reset_participant_table(&mut client); - apply_assessment_participant_migration(&mut client).unwrap(); - let participant = - ParticipantRecord::new_anonymous("participant_persist_status", "tenant_alpha", 8_500) - .unwrap(); - persist_ok(&mut client, &participant); - client - .batch_execute( - "DROP TRIGGER IF EXISTS assessment_participant_immutable_guard \ - ON assessment_participant;\ - ALTER TABLE assessment_participant \ - DROP CONSTRAINT assessment_participant_status_value_check;\ - UPDATE assessment_participant SET participant_status = 'corrupted' \ - WHERE participant_ref = 'participant_persist_status';", - ) - .unwrap(); - assert!(matches!( - persist_err(&mut client, &participant), - ParticipantPersistenceError::ConflictingReplay - )); -} - -#[test] -fn corrupt_created_at_values_fail_closed_on_load() { - let _guard = participant_test_guard(); - let mut client = test_client(); - reset_participant_table(&mut client); - apply_assessment_participant_migration(&mut client).unwrap(); - client - .batch_execute( - "DROP TRIGGER IF EXISTS assessment_participant_immutable_guard \ - ON assessment_participant;\ - DROP TRIGGER IF EXISTS assessment_participant_truncate_guard \ - ON assessment_participant;\ - ALTER TABLE assessment_participant \ - DROP CONSTRAINT assessment_participant_created_at_unix_positive_check;", - ) - .unwrap(); - client - .execute( - "INSERT INTO assessment_participant (\ - participant_ref, tenant_ref, participant_status, created_at_unix_ms\ - ) VALUES ('participant_persist_negative', 'tenant_alpha', 'anonymous', -1)", - &[], - ) - .unwrap(); - client - .execute( - "INSERT INTO assessment_participant (\ - participant_ref, tenant_ref, participant_status, created_at_unix_ms\ - ) VALUES ('participant_persist_zero', 'tenant_alpha', 'anonymous', 0)", - &[], - ) - .unwrap(); - - assert!(matches!( - load_assessment_participant(&mut client, "tenant_alpha", "participant_persist_negative"), - Err(ParticipantPersistenceError::InvalidTimestamp) - )); - assert!(matches!( - load_assessment_participant(&mut client, "tenant_alpha", "participant_persist_zero"), - Err(ParticipantPersistenceError::InvalidTimestamp) - )); -} - -#[test] -fn missing_assessment_participant_relation_is_a_database_failure() { - let _guard = participant_test_guard(); - let mut client = test_client(); - reset_participant_table(&mut client); - - let participant = - ParticipantRecord::new_anonymous("participant_persist_theta", "tenant_alpha", 9_000) - .unwrap(); - let mut transaction = client.transaction().unwrap(); - assert!(matches!( - persist_assessment_participant(&mut transaction, &participant), - Err(ParticipantPersistenceError::Database(_)) - )); - transaction.rollback().unwrap(); - assert!(matches!( - load_assessment_participant(&mut client, "tenant_alpha", "participant_persist_theta"), - Err(ParticipantPersistenceError::Database(_)) - )); -} diff --git a/tests/postgres_participant_schema_constraints.rs b/tests/postgres_participant_schema_constraints.rs deleted file mode 100644 index 9b040028..00000000 --- a/tests/postgres_participant_schema_constraints.rs +++ /dev/null @@ -1,129 +0,0 @@ -//! Real `PostgreSQL` bounds for durable assessment-participant rows. - -use postgres::{Client, NoTls}; -use psychometrics_commons_runtime::postgres_participant::apply_assessment_participant_migration; -use std::sync::{Mutex, MutexGuard}; - -static PARTICIPANT_SCHEMA_LOCK: Mutex<()> = Mutex::new(()); - -fn schema_test_guard() -> MutexGuard<'static, ()> { - PARTICIPANT_SCHEMA_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 assessment_participant_schema_test;\ - SET search_path TO assessment_participant_schema_test;", - ) - .unwrap(); - client -} - -fn reset_schema(client: &mut Client) { - client - .batch_execute( - "DROP TABLE IF EXISTS assessment_participant_schema_test.assessment_participant;", - ) - .unwrap(); -} - -fn constraint_name(error: &postgres::Error) -> String { - error - .as_db_error() - .and_then(postgres::error::DbError::constraint) - .unwrap_or_default() - .to_owned() -} - -#[test] -fn schema_rejects_numeric_identity_zero_time_invalid_status_and_mutation() { - let _guard = schema_test_guard(); - let mut client = test_client(); - reset_schema(&mut client); - apply_assessment_participant_migration(&mut client).unwrap(); - apply_assessment_participant_migration(&mut client).unwrap(); - - let numeric = client - .execute( - "INSERT INTO assessment_participant (\ - participant_ref, tenant_ref, participant_status, created_at_unix_ms\ - ) VALUES ('12', 'tenant_alpha', 'anonymous', 1000)", - &[], - ) - .unwrap_err(); - assert_eq!( - constraint_name(&numeric), - "assessment_participant_participant_ref_format_check" - ); - - let padded_tenant = client - .execute( - "INSERT INTO assessment_participant (\ - participant_ref, tenant_ref, participant_status, created_at_unix_ms\ - ) VALUES ('participant_schema_alpha', ' tenant_alpha', 'anonymous', 1000)", - &[], - ) - .unwrap_err(); - assert_eq!( - constraint_name(&padded_tenant), - "assessment_participant_tenant_ref_format_check" - ); - - let zero_time = client - .execute( - "INSERT INTO assessment_participant (\ - participant_ref, tenant_ref, participant_status, created_at_unix_ms\ - ) VALUES ('participant_schema_alpha', 'tenant_alpha', 'anonymous', 0)", - &[], - ) - .unwrap_err(); - assert_eq!( - constraint_name(&zero_time), - "assessment_participant_created_at_unix_positive_check" - ); - - let unknown_status = client - .execute( - "INSERT INTO assessment_participant (\ - participant_ref, tenant_ref, participant_status, created_at_unix_ms\ - ) VALUES ('participant_schema_alpha', 'tenant_alpha', 'linked', 1000)", - &[], - ) - .unwrap_err(); - assert_eq!( - constraint_name(&unknown_status), - "assessment_participant_status_value_check" - ); - - client - .execute( - "INSERT INTO assessment_participant (\ - participant_ref, tenant_ref, participant_status, created_at_unix_ms\ - ) VALUES ('participant_schema_alpha', 'tenant_alpha', 'anonymous', 1000)", - &[], - ) - .unwrap(); - - let update = client - .execute( - "UPDATE assessment_participant SET created_at_unix_ms = 2000 \ - WHERE participant_ref = 'participant_schema_alpha'", - &[], - ) - .unwrap_err(); - assert_eq!( - update - .as_db_error() - .expect("immutable participant evidence must fail at the database boundary") - .code() - .code(), - "55000" - ); -} From 937dabf9003f5f6a285b18d949bbfb489280a19c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:48:04 +0000 Subject: [PATCH 13/16] fix(auth): tell the truth about supplied command records Command authorization compares the verified actor to supplied participant and session values. It does not accept a ResourceScope and does not claim those aggregates were store-loaded. Align rustdoc, ADR-0003, SECURITY_AND_DATA, UML Activate, TRACEABILITY, and CHANGELOG. Use a session created after publication and before exclusive proof expiry. Co-authored-by: Seongho Bae --- CHANGELOG.md | 2 +- docs/TRACEABILITY.md | 2 +- ...se-identity-and-anonymous-participation.md | 7 +- docs/architecture/SECURITY_AND_DATA.md | 2 +- docs/architecture/UML.md | 14 ++-- src/anonymous_authorization.rs | 35 +++++----- ...anonymous_session_command_authorization.rs | 69 ++++++++++++++----- 7 files changed, 87 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 080ca331..0652b353 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 -- Anonymous session command authorization compares the verified actor to the loaded participant tenant/owner and loaded session reference, then applies a lifecycle command only after that check. The command entry point does not accept a caller-built resource scope. The lower-level exact-resource check remains available for callers that already hold a stored `ResourceScope`. Participant persist/reload remains Active PR #114. +- Anonymous session command authorization compares the verified actor to the supplied participant tenant/owner and session reference, then applies a lifecycle command only after that check. The command entry point does not accept a caller-built resource scope and does not claim those aggregates were store-loaded. The lower-level exact-resource check remains available for callers that already hold a stored `ResourceScope`. Participant persist/reload remains Active PR #114. - 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 8a3a2f49..8fdbf929 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** #86 anonymous-session resource authorization, plus follow-up #104 and successor #118 that compare the verified actor to the loaded participant tenant/owner and loaded session and apply a lifecycle command only after that check, is not protected-main truth until an unchanged reviewed/check-clean head is integrated. Persist/reload of `assessment_participant` and append-only identity-link history remains Active PR #114. HTTP transport remains outside this slice. +**Active PR** #86 anonymous-session resource authorization, plus follow-up #104 and successor #118 that compare the verified actor to the supplied participant tenant/owner and session and apply a lifecycle command only after that check, is not protected-main truth until an unchanged reviewed/check-clean head is integrated. The command entry point does not accept a caller-built `ResourceScope` and does not claim the aggregates were store-loaded. Persist/reload of `assessment_participant` and append-only identity-link history remains Active PR #114. HTTP transport remains outside this slice. ## 5. ADR traceability by concern diff --git a/docs/adr/0003-keyverse-identity-and-anonymous-participation.md b/docs/adr/0003-keyverse-identity-and-anonymous-participation.md index b39c8aa3..372dd3ea 100644 --- a/docs/adr/0003-keyverse-identity-and-anonymous-participation.md +++ b/docs/adr/0003-keyverse-identity-and-anonymous-participation.md @@ -37,7 +37,11 @@ The mapping between operational and research identities is stored in a restricte Keyverse claims establish authenticated subject and coarse scopes. Psychometrics Commons performs resource-level decisions for instrument administration, result ownership, research roles, data export, deletion, and release approval. A Keyverse administrator is not automatically a Psychometrics Commons research data steward. -Anonymous session commands are a product-owned gate after the short-lived proof has already been verified. Transports that loaded `assessment_participant` and `assessment_session` must call `authorize_anonymous_session_command` / `apply_anonymous_session_command`. Those functions compare the verified actor to the loaded tenant, participant, and session references. They do not accept a caller-built `ResourceScope`. Fail-closed classification order is trusted server time, exclusive expiry, loaded-participant tenant, loaded session/participant ownership, actor participant, then session identity (National Institute of Standards and Technology, 2025). +Anonymous session commands are a product-owned gate after the short-lived proof has already been verified. This slice is an as-built library: `authorize_anonymous_session_command` / `apply_anonymous_session_command` compare the verified actor to the supplied `ParticipantRecord` and `AssessmentSession`. They do not accept a caller-built `ResourceScope`. They do not prove those records were loaded from the store. Persist/reload of `assessment_participant` remains Active PR #114. HTTP transport remains Target. + +Fail-closed classification order is a product contract: trusted server time, exclusive expiry, supplied-participant tenant, session/participant ownership, actor participant, then session identity. Named tests: `anonymous_command_authorization_fails_closed_for_zero_or_expired_server_time`, `anonymous_command_authorization_rejects_compound_foreign_tenant_and_inconsistent_loaded_pair_as_cross_tenant`, and `anonymous_command_authorization_rejects_actor_when_loaded_participant_and_session_agree`. + +Trusted server time and exclusive authenticator validity follow NIST SP 800-63-4 (Temoshok et al., 2025). That publication does not specify the tenant-then-owner-then-session error order. The lower-level `authorize_anonymous_session(actor, resource, now)` check remains for callers that already hold a stored assessment-session `ResourceScope`. It is not sufficient by itself for a command against a different loaded session. @@ -65,6 +69,7 @@ The product does not rely on blanket PII masking that destroys operational utili - token validation and audience-confusion tests; - cross-tenant authorization tests; +- anonymous command-path tests that classify tenant before ownership and leave the session unmutated on authorization failure; - anonymous-to-account linking replay and conflict tests; - research-release joinability tests; - account deletion/export end-to-end tests. diff --git a/docs/architecture/SECURITY_AND_DATA.md b/docs/architecture/SECURITY_AND_DATA.md index 89eb33ba..38154c80 100644 --- a/docs/architecture/SECURITY_AND_DATA.md +++ b/docs/architecture/SECURITY_AND_DATA.md @@ -100,7 +100,7 @@ sharing_token_audience/expiry if used Rules: -- Tenant context for state-changing requests is derived from authenticated authorization or, for an anonymous session command, from the loaded `assessment_participant` row. It is not taken from an untrusted body field, a caller-invented `ResourceScope`, or an implicit default. +- Tenant context for state-changing requests is derived from authenticated authorization or, for an anonymous session command, from the `ParticipantRecord` argument supplied after a store load. Active PR #114 persists the `assessment_participant` row. Tenant is not taken from an untrusted body field, a caller-invented `ResourceScope`, or an implicit default. - Public opaque identifiers are identifiers, not authorization capabilities. - Research steward, instrument publisher, participant result owner, and identity administrator are distinct authorities. - A sharing link, if introduced, must be revocable, scoped to an exact resource/audience, expire by default, and not reveal raw responses unless explicitly permitted by the participant and product policy. diff --git a/docs/architecture/UML.md b/docs/architecture/UML.md index e791ce9f..af0002ec 100644 --- a/docs/architecture/UML.md +++ b/docs/architecture/UML.md @@ -295,6 +295,12 @@ sequenceDiagram DB-->>A: session_ref + pinned instrument version A-->>C: session resource + item-delivery contract + C->>A: activate session + A->>DB: load assessment_participant + assessment_session + A->>A: authorize anonymous command from loaded records + A->>DB: atomically state=Active + A-->>C: activation accepted + loop each presented item / response A->>DB: append ItemDeliveryEvent(sequence, item version, payload digest) P->>C: answer presented item @@ -304,10 +310,10 @@ sequenceDiagram A-->>C: accepted sequence end - C->>A: complete session - A->>DB: load assessment_participant + assessment_session - A->>A: authorize anonymous command from loaded records - A->>DB: atomically state=Completed + freeze ResponseSnapshot + outbox scoring request + C->>A: complete session + A->>DB: load assessment_participant + assessment_session + A->>A: authorize anonymous command from loaded records + A->>DB: atomically state=Completed + freeze ResponseSnapshot + outbox scoring request A-->>C: completion accepted / scoring pending W->>DB: claim scoring work diff --git a/src/anonymous_authorization.rs b/src/anonymous_authorization.rs index 8928f115..6dc4879e 100644 --- a/src/anonymous_authorization.rs +++ b/src/anonymous_authorization.rs @@ -10,9 +10,9 @@ //! rights, administer a tenant, or access another participant's session. //! //! Transports that already loaded a participant and session should call -//! [`authorize_anonymous_session_command`]. That function compares the verified actor to those -//! stored records so a caller cannot invent a matching tenant/owner/session triple and then -//! command a different loaded session. +//! [`authorize_anonymous_session_command`]. That function compares the verified actor to the +//! supplied records so a matching invented [`ResourceScope`] cannot authorize a different +//! loaded session. It does not prove the records came from the product store. use crate::anonymous_session::AnonymousSessionContext; use crate::authorization::{ResourceKind, ResourceScope}; @@ -127,25 +127,26 @@ pub fn authorize_anonymous_session( /// - `session`: the [`AssessmentSession`] loaded from the product store for that command; and /// - `now_unix_ms`: the current time from the application's trusted server clock, not a client clock. /// -/// The function compares the actor to those loaded records. It does **not** accept a -/// caller-invented tenant, owner, or session reference, and it does not build a -/// [`ResourceScope`] that a transport could invent. For example, a proof for -/// `session_alpha` / `participant_alpha` in `tenant_alpha` is allowed only when the loaded -/// participant is that same person in that same tenant and the loaded session is `session_alpha` -/// owned by that person. A session owned by `participant_beta`, or `session_beta` owned by the -/// same person, is denied. -/// -/// Checks run in a stable fail-closed order: trusted server time, expiry, loaded-participant -/// tenant, loaded session/participant ownership, actor participant, then session identity. -/// Tenant is classified before ownership so a foreign-tenant row that also disagrees on +/// The function compares the actor to those supplied records. It does **not** accept a +/// caller-built [`ResourceScope`]. It does not prove the records were loaded from the product +/// store; a transport can still construct both aggregates from the proof. Persist/reload of +/// `assessment_participant` remains Active PR #114. For example, a proof for `session_alpha` / +/// `participant_alpha` in `tenant_alpha` is allowed only when the supplied participant is that +/// same person in that same tenant and the supplied session is `session_alpha` owned by that +/// person. A session owned by `participant_beta`, or `session_beta` owned by the same person, +/// is denied. +/// +/// Checks run in a stable fail-closed order: trusted server time, expiry, supplied-participant +/// tenant, session/participant ownership, actor participant, then session identity. +/// Tenant is classified before ownership so a foreign-tenant record that also disagrees on /// participant identity is reported as [`AnonymousResourceAuthorizationError::CrossTenantDenied`]. /// /// # Errors /// /// Returns [`AnonymousResourceAuthorizationError`] when trusted time is invalid, the verified -/// anonymous session has expired, the loaded participant belongs to another tenant, the loaded -/// session belongs to another participant, or the loaded session is not the session named by the -/// proof. +/// anonymous session has expired, the supplied participant belongs to another tenant, the +/// supplied session belongs to another participant, or the supplied session is not the session +/// named by the proof. pub fn authorize_anonymous_session_command( actor: &AnonymousSessionContext, participant: &ParticipantRecord, diff --git a/tests/anonymous_session_command_authorization.rs b/tests/anonymous_session_command_authorization.rs index 10d468a3..52328759 100644 --- a/tests/anonymous_session_command_authorization.rs +++ b/tests/anonymous_session_command_authorization.rs @@ -1,8 +1,9 @@ -//! Contract tests for anonymous command authorization against loaded aggregates. +//! Contract tests for anonymous command authorization against supplied aggregates. //! //! A transport must load the participant and assessment session from the product store, //! then ask this boundary whether the already-verified anonymous session may command -//! that exact loaded session. Callers do not invent the resource tenant or owner. +//! that exact session. These tests pass supplied records; the type system does not +//! prove they were loaded. Persist/reload remains Active PR #114. use psychometrics_commons_runtime::anonymous_authorization::{ apply_anonymous_session_command, authorize_anonymous_session_command, @@ -22,6 +23,12 @@ const RELEASE_DIGEST: &str = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; const EVIDENCE_DIGEST: &str = "sha256:1111111111111111111111111111111111111111111111111111111111111111"; +/// Session starts after the Big Five Korean release is published at 10_200. +const SESSION_CREATED_AT_UNIX_MS: u64 = 10_300; +/// Trusted now sits after session creation and before exclusive proof expiry. +const COMMAND_NOW_UNIX_MS: u64 = 11_000; +/// Exclusive proof expiry: valid at 11_999, expired at 12_000. +const PROOF_VALID_UNTIL_UNIX_MS: u64 = 12_000; fn published_release() -> InstrumentRelease { let manifest = InstrumentReleaseManifest::new( @@ -100,7 +107,7 @@ fn session(participant_ref: &str, session_ref: &str) -> AssessmentSession { participant_ref, &published_release(), "ko-KR", - 20_000, + SESSION_CREATED_AT_UNIX_MS, ) .unwrap() } @@ -115,7 +122,7 @@ fn anonymous_context( participant_ref, session_ref, "anonymous_command_evidence_alpha", - 2_000, + PROOF_VALID_UNTIL_UNIX_MS, ) .unwrap() } @@ -127,7 +134,7 @@ fn current_anonymous_proof_may_command_only_its_loaded_session() { let loaded = session("participant_alpha", "session_alpha"); assert_eq!( - authorize_anonymous_session_command(&actor, &owner, &loaded, 1_500), + authorize_anonymous_session_command(&actor, &owner, &loaded, COMMAND_NOW_UNIX_MS), Ok(()) ); } @@ -139,7 +146,7 @@ fn anonymous_command_authorization_uses_loaded_participant_tenant_not_caller_sco let loaded = session("participant_alpha", "session_alpha"); assert_eq!( - authorize_anonymous_session_command(&actor, &foreign_owner, &loaded, 1_500), + authorize_anonymous_session_command(&actor, &foreign_owner, &loaded, COMMAND_NOW_UNIX_MS), Err(AnonymousResourceAuthorizationError::CrossTenantDenied) ); } @@ -151,7 +158,12 @@ fn anonymous_command_authorization_rejects_a_session_owned_by_another_loaded_par let other_persons_session = session("participant_beta", "session_alpha"); assert_eq!( - authorize_anonymous_session_command(&actor, &owner, &other_persons_session, 1_500), + authorize_anonymous_session_command( + &actor, + &owner, + &other_persons_session, + COMMAND_NOW_UNIX_MS + ), Err(AnonymousResourceAuthorizationError::OwnerMismatch) ); } @@ -163,7 +175,7 @@ fn anonymous_command_authorization_rejects_a_different_loaded_session_for_the_sa let other_session = session("participant_alpha", "session_beta"); assert_eq!( - authorize_anonymous_session_command(&actor, &owner, &other_session, 1_500), + authorize_anonymous_session_command(&actor, &owner, &other_session, COMMAND_NOW_UNIX_MS), Err(AnonymousResourceAuthorizationError::SessionMismatch) ); } @@ -179,7 +191,11 @@ fn anonymous_command_authorization_fails_closed_for_zero_or_expired_server_time( Err(AnonymousResourceAuthorizationError::InvalidTimestamp) ); assert_eq!( - authorize_anonymous_session_command(&actor, &owner, &loaded, 2_000), + authorize_anonymous_session_command(&actor, &owner, &loaded, PROOF_VALID_UNTIL_UNIX_MS), + Err(AnonymousResourceAuthorizationError::Expired) + ); + assert_eq!( + authorize_anonymous_session_command(&actor, &owner, &loaded, PROOF_VALID_UNTIL_UNIX_MS + 1), Err(AnonymousResourceAuthorizationError::Expired) ); } @@ -195,7 +211,12 @@ fn anonymous_command_authorization_rejects_compound_failures_in_time_then_owner_ Err(AnonymousResourceAuthorizationError::InvalidTimestamp) ); assert_eq!( - authorize_anonymous_session_command(&actor, &owner, &other_persons_session, 2_000), + authorize_anonymous_session_command( + &actor, + &owner, + &other_persons_session, + PROOF_VALID_UNTIL_UNIX_MS + ), Err(AnonymousResourceAuthorizationError::Expired) ); } @@ -207,7 +228,12 @@ fn anonymous_command_authorization_rejects_actor_when_loaded_participant_and_ses let other_persons_session = session("participant_beta", "session_alpha"); assert_eq!( - authorize_anonymous_session_command(&actor, &other_owner, &other_persons_session, 1_500), + authorize_anonymous_session_command( + &actor, + &other_owner, + &other_persons_session, + COMMAND_NOW_UNIX_MS + ), Err(AnonymousResourceAuthorizationError::OwnerMismatch) ); } @@ -220,7 +246,12 @@ fn anonymous_command_authorization_rejects_compound_foreign_tenant_and_inconsist let other_persons_session = session("participant_beta", "session_alpha"); assert_eq!( - authorize_anonymous_session_command(&actor, &foreign_owner, &other_persons_session, 1_500), + authorize_anonymous_session_command( + &actor, + &foreign_owner, + &other_persons_session, + COMMAND_NOW_UNIX_MS + ), Err(AnonymousResourceAuthorizationError::CrossTenantDenied) ); } @@ -239,7 +270,7 @@ fn authorized_anonymous_proof_may_activate_only_its_loaded_session() { "command_activate_alpha", 1, SessionCommand::Activate, - 1_500, + COMMAND_NOW_UNIX_MS, ), Ok(SessionState::Active) ); @@ -260,7 +291,7 @@ fn unauthorized_anonymous_command_does_not_mutate_the_loaded_session() { "command_activate_beta", 1, SessionCommand::Activate, - 1_500, + COMMAND_NOW_UNIX_MS, ), Err(AnonymousSessionCommandError::Authorization( AnonymousResourceAuthorizationError::SessionMismatch @@ -283,7 +314,7 @@ fn cross_tenant_anonymous_command_does_not_mutate_the_loaded_session() { "command_activate_foreign_tenant", 1, SessionCommand::Activate, - 1_500, + COMMAND_NOW_UNIX_MS, ), Err(AnonymousSessionCommandError::Authorization( AnonymousResourceAuthorizationError::CrossTenantDenied @@ -306,7 +337,7 @@ fn owner_mismatch_anonymous_command_does_not_mutate_the_loaded_session() { "command_activate_foreign_owner", 1, SessionCommand::Activate, - 1_500, + COMMAND_NOW_UNIX_MS, ), Err(AnonymousSessionCommandError::Authorization( AnonymousResourceAuthorizationError::OwnerMismatch @@ -329,7 +360,7 @@ fn expired_anonymous_proof_cannot_apply_an_otherwise_legal_session_command() { "command_activate_expired", 1, SessionCommand::Activate, - 2_000, + PROOF_VALID_UNTIL_UNIX_MS, ), Err(AnonymousSessionCommandError::Authorization( AnonymousResourceAuthorizationError::Expired @@ -351,7 +382,7 @@ fn authorized_anonymous_command_still_fails_closed_on_illegal_lifecycle_transiti "command_complete_too_early", 1, SessionCommand::Complete, - 1_500, + COMMAND_NOW_UNIX_MS, ) .expect_err("Created sessions cannot complete"); match error { @@ -368,7 +399,7 @@ fn authorized_anonymous_command_still_fails_closed_on_illegal_lifecycle_transiti } #[test] -fn anonymous_session_command_authorization_errors_display_and_source_all_variants() { +fn anonymous_session_command_authorization_errors_display_and_source_authorization_variants() { let cases = [ ( AnonymousResourceAuthorizationError::InvalidTimestamp, From 80ceb23f54e93a4620a803c983d6c17669d9725c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:48:45 +0000 Subject: [PATCH 14/16] fix(auth): satisfy clippy doc-markdown on command timeline Backtick the publication and exclusive-expiry instants in the command authorization fixtures, and name honesty successor #135 in TRACEABILITY. Co-authored-by: Seongho Bae --- docs/TRACEABILITY.md | 2 +- tests/anonymous_session_command_authorization.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 8fdbf929..c70c7ad5 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** #86 anonymous-session resource authorization, plus follow-up #104 and successor #118 that compare the verified actor to the supplied participant tenant/owner and session and apply a lifecycle command only after that check, is not protected-main truth until an unchanged reviewed/check-clean head is integrated. The command entry point does not accept a caller-built `ResourceScope` and does not claim the aggregates were store-loaded. Persist/reload of `assessment_participant` and append-only identity-link history remains Active PR #114. HTTP transport remains outside this slice. +**Active PR** #86 anonymous-session resource authorization, plus follow-up #104, #118, and honesty successor #135 that compare the verified actor to the supplied participant tenant/owner and session and apply a lifecycle command only after that check, is not protected-main truth until an unchanged reviewed/check-clean head is integrated. The command entry point does not accept a caller-built `ResourceScope` and does not claim the aggregates were store-loaded. Persist/reload of `assessment_participant` and append-only identity-link history remains Active PR #114. HTTP transport remains outside this slice. ## 5. ADR traceability by concern diff --git a/tests/anonymous_session_command_authorization.rs b/tests/anonymous_session_command_authorization.rs index 52328759..683bf1d9 100644 --- a/tests/anonymous_session_command_authorization.rs +++ b/tests/anonymous_session_command_authorization.rs @@ -23,11 +23,11 @@ const RELEASE_DIGEST: &str = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; const EVIDENCE_DIGEST: &str = "sha256:1111111111111111111111111111111111111111111111111111111111111111"; -/// Session starts after the Big Five Korean release is published at 10_200. +/// Session starts after the Big Five Korean release is published at `10_200`. const SESSION_CREATED_AT_UNIX_MS: u64 = 10_300; /// Trusted now sits after session creation and before exclusive proof expiry. const COMMAND_NOW_UNIX_MS: u64 = 11_000; -/// Exclusive proof expiry: valid at 11_999, expired at 12_000. +/// Exclusive proof expiry: valid at `11_999`, expired at `12_000`. const PROOF_VALID_UNTIL_UNIX_MS: u64 = 12_000; fn published_release() -> InstrumentRelease { From 9df20eade378a9ed999cb005f57e798bd64d8f38 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:52:56 +0000 Subject: [PATCH 15/16] fix(auth): stop claiming command records were store-loaded #135 still said apply_anonymous_session_command ran after a store load and named superseded #114 as the persist landing. The gate compares supplied records only; persist/reload remains Active PR #133. Co-authored-by: Seongho Bae --- CHANGELOG.md | 2 +- docs/TRACEABILITY.md | 2 +- ...se-identity-and-anonymous-participation.md | 2 +- docs/architecture/ERD.md | 2 +- docs/architecture/SECURITY_AND_DATA.md | 2 +- docs/architecture/UML.md | 2 + src/anonymous_authorization.rs | 23 ++++--- ...anonymous_session_command_authorization.rs | 2 +- tests/documentation_architecture_contract.rs | 67 +++++++++++++++++++ 9 files changed, 87 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0652b353..30655971 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 -- Anonymous session command authorization compares the verified actor to the supplied participant tenant/owner and session reference, then applies a lifecycle command only after that check. The command entry point does not accept a caller-built resource scope and does not claim those aggregates were store-loaded. The lower-level exact-resource check remains available for callers that already hold a stored `ResourceScope`. Participant persist/reload remains Active PR #114. +- Anonymous session command authorization compares the verified actor to the supplied participant tenant/owner and session reference, then applies a lifecycle command only after that check. The command entry point does not accept a caller-built resource scope and does not claim those aggregates were store-loaded. The lower-level exact-resource check remains available for callers that already hold a stored `ResourceScope`. Participant persist/reload remains Active PR #133. - 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 c70c7ad5..65745dd5 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** #86 anonymous-session resource authorization, plus follow-up #104, #118, and honesty successor #135 that compare the verified actor to the supplied participant tenant/owner and session and apply a lifecycle command only after that check, is not protected-main truth until an unchanged reviewed/check-clean head is integrated. The command entry point does not accept a caller-built `ResourceScope` and does not claim the aggregates were store-loaded. Persist/reload of `assessment_participant` and append-only identity-link history remains Active PR #114. HTTP transport remains outside this slice. +**Active PR** #86 anonymous-session resource authorization, plus follow-up #104, #118, #135, and this honesty successor that compare the verified actor to the supplied participant tenant/owner and session and apply a lifecycle command only after that check, is not protected-main truth until an unchanged reviewed/check-clean head is integrated. The command entry point does not accept a caller-built `ResourceScope` and does not claim the aggregates were store-loaded. Persist/reload of `assessment_participant` and append-only identity-link history remains Active PR #133. HTTP transport remains outside this slice. ## 5. ADR traceability by concern diff --git a/docs/adr/0003-keyverse-identity-and-anonymous-participation.md b/docs/adr/0003-keyverse-identity-and-anonymous-participation.md index 372dd3ea..1595043a 100644 --- a/docs/adr/0003-keyverse-identity-and-anonymous-participation.md +++ b/docs/adr/0003-keyverse-identity-and-anonymous-participation.md @@ -37,7 +37,7 @@ The mapping between operational and research identities is stored in a restricte Keyverse claims establish authenticated subject and coarse scopes. Psychometrics Commons performs resource-level decisions for instrument administration, result ownership, research roles, data export, deletion, and release approval. A Keyverse administrator is not automatically a Psychometrics Commons research data steward. -Anonymous session commands are a product-owned gate after the short-lived proof has already been verified. This slice is an as-built library: `authorize_anonymous_session_command` / `apply_anonymous_session_command` compare the verified actor to the supplied `ParticipantRecord` and `AssessmentSession`. They do not accept a caller-built `ResourceScope`. They do not prove those records were loaded from the store. Persist/reload of `assessment_participant` remains Active PR #114. HTTP transport remains Target. +Anonymous session commands are a product-owned gate after the short-lived proof has already been verified. This slice is an as-built library: `authorize_anonymous_session_command` / `apply_anonymous_session_command` compare the verified actor to the supplied `ParticipantRecord` and `AssessmentSession`. They do not accept a caller-built `ResourceScope`. They do not prove those records were loaded from the store. Persist/reload of `assessment_participant` remains Active PR #133. HTTP transport remains Target. Fail-closed classification order is a product contract: trusted server time, exclusive expiry, supplied-participant tenant, session/participant ownership, actor participant, then session identity. Named tests: `anonymous_command_authorization_fails_closed_for_zero_or_expired_server_time`, `anonymous_command_authorization_rejects_compound_foreign_tenant_and_inconsistent_loaded_pair_as_cross_tenant`, and `anonymous_command_authorization_rejects_actor_when_loaded_participant_and_session_agree`. diff --git a/docs/architecture/ERD.md b/docs/architecture/ERD.md index 2be14ec1..d7b8cc8c 100644 --- a/docs/architecture/ERD.md +++ b/docs/architecture/ERD.md @@ -426,7 +426,7 @@ The target ERD deliberately includes several logical entities that are not yet p - `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. - `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. Active PR #114 persists `assessment_participant` plus append-only link history; it is not protected-main truth until integrated. +- `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. Active PR #133 persists `assessment_participant` plus append-only link history; it is not protected-main truth until integrated. Prefer that head over #114 or #124. - `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. - `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. diff --git a/docs/architecture/SECURITY_AND_DATA.md b/docs/architecture/SECURITY_AND_DATA.md index 38154c80..4c986c50 100644 --- a/docs/architecture/SECURITY_AND_DATA.md +++ b/docs/architecture/SECURITY_AND_DATA.md @@ -100,7 +100,7 @@ sharing_token_audience/expiry if used Rules: -- Tenant context for state-changing requests is derived from authenticated authorization or, for an anonymous session command, from the `ParticipantRecord` argument supplied after a store load. Active PR #114 persists the `assessment_participant` row. Tenant is not taken from an untrusted body field, a caller-invented `ResourceScope`, or an implicit default. +- Tenant context for state-changing requests is derived from authenticated authorization or, for an anonymous session command, from the supplied `ParticipantRecord` argument. The command gate does not prove those records were store-loaded. Active PR #133 persists the `assessment_participant` row and append-only identity-link history; prefer that head over #114 or #124. Tenant is not taken from an untrusted body field, a caller-invented `ResourceScope`, or an implicit default. - Public opaque identifiers are identifiers, not authorization capabilities. - Research steward, instrument publisher, participant result owner, and identity administrator are distinct authorities. - A sharing link, if introduced, must be revocable, scoped to an exact resource/audience, expire by default, and not reveal raw responses unless explicitly permitted by the participant and product policy. diff --git a/docs/architecture/UML.md b/docs/architecture/UML.md index af0002ec..35bb0946 100644 --- a/docs/architecture/UML.md +++ b/docs/architecture/UML.md @@ -278,6 +278,8 @@ Export cannot complete with a deletion-retention exception. Exact terminal repla ## 6. Anonymous assessment happy-path sequence +This sequence is target transport. The as-built command gate compares supplied records and does not perform the load. + ```mermaid sequenceDiagram autonumber diff --git a/src/anonymous_authorization.rs b/src/anonymous_authorization.rs index 6dc4879e..264ac5b4 100644 --- a/src/anonymous_authorization.rs +++ b/src/anonymous_authorization.rs @@ -117,20 +117,20 @@ pub fn authorize_anonymous_session( Ok(()) } -/// Allow a verified anonymous participant to command one loaded assessment session. +/// Allow a verified anonymous participant to command one supplied assessment session. /// /// Callers provide four values: /// /// - `actor`: an [`AnonymousSessionContext`] created only after the short-lived anonymous proof has /// already been verified; -/// - `participant`: the [`ParticipantRecord`] loaded from the product store for that command; -/// - `session`: the [`AssessmentSession`] loaded from the product store for that command; and +/// - `participant`: the [`ParticipantRecord`] the caller supplies for that command; +/// - `session`: the [`AssessmentSession`] the caller supplies for that command; and /// - `now_unix_ms`: the current time from the application's trusted server clock, not a client clock. /// /// The function compares the actor to those supplied records. It does **not** accept a /// caller-built [`ResourceScope`]. It does not prove the records were loaded from the product /// store; a transport can still construct both aggregates from the proof. Persist/reload of -/// `assessment_participant` remains Active PR #114. For example, a proof for `session_alpha` / +/// `assessment_participant` remains Active PR #133. For example, a proof for `session_alpha` / /// `participant_alpha` in `tenant_alpha` is allowed only when the supplied participant is that /// same person in that same tenant and the supplied session is `session_alpha` owned by that /// person. A session owned by `participant_beta`, or `session_beta` owned by the same person, @@ -177,7 +177,7 @@ pub fn authorize_anonymous_session_command( #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[non_exhaustive] pub enum AnonymousSessionCommandError { - /// The verified anonymous session was not allowed to command the loaded session. + /// The verified anonymous session was not allowed to command the supplied session. Authorization(AnonymousResourceAuthorizationError), /// Authorization succeeded, but the lifecycle command was not legal for the current state. Transition(TransitionError), @@ -201,20 +201,21 @@ impl Error for AnonymousSessionCommandError { } } -/// Apply one session command only after the loaded session is authorized. +/// Apply one session command only after the supplied session is authorized. /// /// Call this from an HTTP or messaging adapter after the short-lived anonymous proof has been -/// verified and the participant and session have been loaded from the product store. Authorization -/// runs first. If it fails, the session is left unchanged. If it succeeds, the existing session -/// lifecycle rules decide whether the command may change state. +/// verified. Pass the participant and session records the caller holds. This function does not +/// prove the records were loaded from the product store. Authorization runs first. If it fails, +/// the session is left unchanged. If it succeeds, the existing session lifecycle rules decide +/// whether the command may change state. /// -/// For example, a current proof for `session_alpha` may activate that loaded session. The same +/// For example, a current proof for `session_alpha` may activate that supplied session. The same /// proof cannot activate `session_beta`, and an expired proof cannot activate `session_alpha` even /// though `Activate` is otherwise legal from `Created`. /// /// # Errors /// -/// Returns [`AnonymousSessionCommandError::Authorization`] when the loaded records are not the +/// Returns [`AnonymousSessionCommandError::Authorization`] when the supplied records are not the /// exact current anonymous session, or [`AnonymousSessionCommandError::Transition`] when the /// command is not legal from the current lifecycle state. pub fn apply_anonymous_session_command( diff --git a/tests/anonymous_session_command_authorization.rs b/tests/anonymous_session_command_authorization.rs index 683bf1d9..318cc9dd 100644 --- a/tests/anonymous_session_command_authorization.rs +++ b/tests/anonymous_session_command_authorization.rs @@ -3,7 +3,7 @@ //! A transport must load the participant and assessment session from the product store, //! then ask this boundary whether the already-verified anonymous session may command //! that exact session. These tests pass supplied records; the type system does not -//! prove they were loaded. Persist/reload remains Active PR #114. +//! prove they were loaded. Persist/reload remains Active PR #133. use psychometrics_commons_runtime::anonymous_authorization::{ apply_anonymous_session_command, authorize_anonymous_session_command, diff --git a/tests/documentation_architecture_contract.rs b/tests/documentation_architecture_contract.rs index 7b5c6fad..6845f3e1 100644 --- a/tests/documentation_architecture_contract.rs +++ b/tests/documentation_architecture_contract.rs @@ -336,6 +336,73 @@ fn erd_covers_current_delivery_identity_and_longitudinal_boundaries() { } } +#[test] +fn anonymous_command_docs_do_not_claim_store_load() { + let root = repository_root(); + let authorization = read_required(&root.join("src/anonymous_authorization.rs")); + let security = read_required(&root.join("docs/architecture/SECURITY_AND_DATA.md")); + let changelog = read_required(&root.join("CHANGELOG.md")); + let traceability = read_required(&root.join("docs/TRACEABILITY.md")); + let adr = + read_required(&root.join("docs/adr/0003-keyverse-identity-and-anonymous-participation.md")); + let erd = read_required(&root.join("docs/architecture/ERD.md")); + let command_tests = + read_required(&root.join("tests/anonymous_session_command_authorization.rs")); + let uml = read_required(&root.join("docs/architecture/UML.md")); + + assert!( + !authorization.contains("have been loaded from the product store"), + "apply_anonymous_session_command rustdoc must not claim the caller already loaded records" + ); + assert!( + !authorization.contains("ParticipantRecord`] loaded from the product store"), + "authorize_anonymous_session_command rustdoc must not label the participant argument as store-loaded" + ); + assert!( + authorization.contains("does not prove the records were loaded"), + "command authorization rustdoc must say the gate does not prove store load" + ); + assert!( + !security.contains("supplied after a store load"), + "SECURITY_AND_DATA must not claim the command gate observed a store load" + ); + assert!( + security.contains("does not prove those records were store-loaded"), + "SECURITY_AND_DATA must say the command gate does not prove store load" + ); + assert!( + uml.contains( + "as-built command gate compares supplied records and does not perform the load" + ), + "UML happy-path must distinguish target store load from the as-built command gate" + ); + + for (label, document) in [ + ("CHANGELOG.md", changelog.as_str()), + ("docs/TRACEABILITY.md", traceability.as_str()), + ( + "docs/adr/0003-keyverse-identity-and-anonymous-participation.md", + adr.as_str(), + ), + ("docs/architecture/ERD.md", erd.as_str()), + ("docs/architecture/SECURITY_AND_DATA.md", security.as_str()), + ( + "tests/anonymous_session_command_authorization.rs", + command_tests.as_str(), + ), + ("src/anonymous_authorization.rs", authorization.as_str()), + ] { + assert!( + !document.contains("remains Active PR #114"), + "{label} must not name superseded #114 as the current participant persist landing" + ); + assert!( + document.contains("#133"), + "{label} must name Active PR #133 as the current participant persist landing" + ); + } +} + #[test] fn uml_covers_identity_longitudinal_and_workbench_behavior() { let uml = read_required(&repository_root().join("docs/architecture/UML.md")); From 858b628f52f374be012864595a32b42b1220b3e6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:53:29 +0000 Subject: [PATCH 16/16] docs(traceability): name honesty successor Active PR #144 Point command-authorization honesty at the opened successor so reviewers do not treat #135 as the landing head. Co-authored-by: Seongho Bae --- docs/TRACEABILITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 65745dd5..927cc327 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** #86 anonymous-session resource authorization, plus follow-up #104, #118, #135, and this honesty successor that compare the verified actor to the supplied participant tenant/owner and session and apply a lifecycle command only after that check, is not protected-main truth until an unchanged reviewed/check-clean head is integrated. The command entry point does not accept a caller-built `ResourceScope` and does not claim the aggregates were store-loaded. Persist/reload of `assessment_participant` and append-only identity-link history remains Active PR #133. HTTP transport remains outside this slice. +**Active PR** #86 anonymous-session resource authorization, plus follow-up #104, #118, #135, and honesty successor #144 that compare the verified actor to the supplied participant tenant/owner and session and apply a lifecycle command only after that check, is not protected-main truth until an unchanged reviewed/check-clean head is integrated. The command entry point does not accept a caller-built `ResourceScope` and does not claim the aggregates were store-loaded. Persist/reload of `assessment_participant` and append-only identity-link history remains Active PR #133. HTTP transport remains outside this slice. ## 5. ADR traceability by concern