fix(longitudinal): re-check consent and bind tenant - #199
Conversation
A Seoul clinic participant can join a 14-day mood diary only after granting longitudinal observation consent. Work and home membership stay distinct, research refusal does not block personal enrollment, and pause/resume/withdraw keep the enrollment evidence. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
Collection now fails closed after a later longitudinal revoke, even if the enrollment is still Enrolled or was resumed. Enroll takes the tenant-owned participant record so a second clinic cannot borrow the Seoul participant under another tenant. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
There was a problem hiding this comment.
Collection still trusts a caller-chosen snapshot
#199 correctly stops collection when the caller passes a new revoked snapshot, and enroll correctly binds ParticipantRecord tenant. That is not enough for the next persist/HTTP/Gyeot caller.
ConsentSnapshot is an immutable enroll-time view. After revoke it remains Granted. authorize_collection(&snapshot) only inspects that view, so:
enrolled.authorize_collection(&enroll_time_snapshot) // Ok(()) after revokeEnrollmentState::Enrolled still documents that Gyeot may collect. Resume after revoke returns Enrolled. Any caller that gates on state() or reloads consent_snapshot_ref recreates the #184 bug.
Collection also never re-binds tenant. The live Gyeot path stays tenant-unbound.
Required before this slice is the landing vehicle
authorize_collection(&ParticipantRecord, &ConsentLedger)— current ledger head, not enroll-time snapshot.CrossTenantDeniedwhen the caller record tenant ≠ enrollment tenant.- Document
Enrolledas membership only;consent_snapshot_refas enroll evidence only. - Operator copy: grant or restore consent before enrollment or collection.
- Tests: enroll-time snapshot still looks Granted after revoke and must not authorize; other-clinic record fails closed while consent remains granted.
Do not merge #184. Do not merge this head as the collection gate. Do not add persistence onto this snapshot-only API. Independent last-push approval and exact-head checks are still required.
A successor branch implements the ledger + tenant collection gate.
Sent by Cursor Automation: Fix Issues
| #[derive(Clone, Copy, Debug, Eq, PartialEq)] | ||
| #[non_exhaustive] | ||
| pub enum EnrollmentState { | ||
| /// The participant is enrolled and Gyeot may collect observations. |
There was a problem hiding this comment.
Enrolled still says Gyeot may collect. resume after revoke returns this state. Document membership only: collection requires authorize_collection on the current ledger and tenant-owned record.
| Self::ParticipantMismatch => { | ||
| "use the consent snapshot that belongs to this participant" | ||
| } | ||
| Self::LongitudinalConsentRequired => { |
There was a problem hiding this comment.
This copy is reused after enroll + revoke. Tell the operator to grant or restore longitudinal consent before enrollment or collection.
| /// Returns [`LongitudinalEnrollmentError`] when the snapshot belongs to | ||
| /// another participant, the enrollment is paused or withdrawn, or | ||
| /// longitudinal observation consent is missing or revoked. | ||
| pub fn authorize_collection( |
There was a problem hiding this comment.
Caller-supplied ConsentSnapshot is not a current-consent gate. After revoke, the enroll-time snapshot stays Granted. Take &ParticipantRecord + &ConsentLedger, fail closed unless the latest LongitudinalObservation event is Granted, and return CrossTenantDenied when the record tenant does not own this enrollment.
| fn revoke_after_enroll_stops_collection_even_after_resume() { | ||
| let (mut ledger, snapshot) = granted_longitudinal_ledger(); | ||
| let enrolled = enroll_seoul(&snapshot); | ||
| assert_eq!(enrolled.authorize_collection(&snapshot), Ok(())); |
There was a problem hiding this comment.
This only proves a new revoked snapshot fails. Add the realistic case: enroll-time snapshot remains Granted after revoke, and collection must still fail when the current ledger is consulted. Also bind tenant at collection time.
|
Closing as a proven superseded predecessor. Fresh compare shows #199 head |


Why
#184 enrolled a Seoul clinic participant after a longitudinal grant, but Gyeot collection stayed authorized from
EnrollmentStatealone. A later revoke, or resume after that revoke, still allowed collection. Tenant was an unbound caller string, so a second clinic could enroll the same participant.TDD
RED
tests/longitudinal_enrollment_contract.rsrevoked after enroll, resumed, and requiredauthorize_collectionto fail closed. AParticipantRecordfrom another tenant was rejected.GREEN replaces
can_accept_observationswithauthorize_collection(&ConsentSnapshot)and binds enroll to&ParticipantRecord.Local GREEN:
cargo test --test longitudinal_enrollment_contractcargo test --test documentation_architecture_contract --test consent_identity_safetycargo clippy --all-targets -- -D warningsScope
ParticipantRecord.enrollment_membership_contextso work/home are not flattened onto the enrollment row.Out of scope
Operator next action
Review the revoke-after-enroll and cross-tenant contracts. Prefer this head over #184. Do not merge #184. Do not self-approve. Independent last-push approval and exact-head checks are required.