feat(identity): persist append-only account-link history - #114
feat(identity): persist append-only account-link history#114cursor[bot] wants to merge 3 commits into
Conversation
A buyer who links an anonymous assessment to a Keyverse account must still see that link after process restart. Persist assessment_participant plus append-only link and link-end evidence, reload through the domain lifecycle, and fail closed on conflicting replay or a subject already bound to another participant. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
Keep TRACEABILITY, ADR-0020, ERD, and as-built schema pointing at the opened persist/reload vehicle instead of an unnamed Active PR. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
seonghobae
left a comment
There was a problem hiding this comment.
Verdict
REQUEST_CHANGES is warranted. Do not approve. Do not merge. Head 2eb0b63acce6764513c6c615db3d6650b50731f1 (draft, mergeable_state blocked). CodeRabbit CLI was not installed in this environment; this review is from the files at /workspace.
Tenant-isolated load, conflicting event-identity replay, and one-current-subject-via-unique-index are directionally right. The persist function still cannot save a complete unlink+relink aggregate in one transaction. The only relink test never asks it to.
Blocking defects
-
src/postgres_participant_identity_link.rs:128-137— persist applies all links before all ends.
insert_current_projectionruns on every newly inserted link. For aParticipantRecordthat already has[L1, L2]and[E1 ends L1], persist inserts current(L1), then current(L2), hitscurrent_participant_identity_link_pkey, and returnsConflictingReplay(classify_current_unique_violation, line 463). The same failure happens when jumping from an already-persisted L1 to the full relinked record in one call: L1 is a duplicate, L2 tries to insert current while current still points at L1, E1 is never reached.
Incremental persist (link, then end, then relink) works because the end deletes current before the next link. That is not the documented aggregate API, and it is not how the #85 in-memory lifecycle will be saved.
Fix: persist in the same order load already uses (lines 172–194): for each link, persist the link, then persist matching ends. Add a one-shot persist+reload of the full relinked record, then exact replay →Duplicate. -
tests/postgres_participant_identity_link.rs:211-250— relink coverage is incremental only.
The test commits L1, then E1, then L2. CI will stay green while the aggregate contract is broken. That is a tautological gap relative to the claimed “persist one participant and its append-only identity-link history” contract.
Non-blocking residual risks
- Load race (
load_participant_identity_history~162–171): noFOR SHAREon the participant row. Under READ COMMITTED,load_link_eventsthenload_link_end_eventscan see a newly committed end without its link and returnCorruptHistory. Lock the header or use one snapshot. - Subject reuse after unlink is untested. Unique
(tenant_ref, identity_issuer, identity_subject_ref)is only on the current projection. After DELETE, the same subject should be allowed on another participant. No test proves it. - Schema:
participant_identity_link_endFKs do not requirelinked_event_refto belong to the sameparticipant_ref.current_participant_identity_linkdenormalizes issuer/subject/tenant (ADR-allowed projection). Historytenant_refduplicatesassessment_participant.tenant_ref. Names are two-or-more-wordsnake_case. No RLS; consistent with sibling migrations. - Security/privacy: stores opaque issuer/subject/proof references, not Keyverse tokens or proof bytes. Load of the wrong tenant returns
None(no existence distinguish). Persist of the sameparticipant_refunder another tenant returnsConflictingReplay— a write-side existence oracle for an opaque ref. Operational vs research identity stays separate. No blanket masking. - ADR/traceability: Implementation status and TRACEABILITY correctly mark this as Active PR, not protected-main. ADR-0020 §Validation still says persistence remains target. Logical ERD still has
link_stateon one row; physical split into link + end + current is honest in AS_BUILT.CREATE TABLE IF NOT EXISTS assessment_participantcan no-op against a later session-persist migration with different columns. - Unit Display tests (
postgres_participant_identity_link.rs:515-544) only reassert the same strings. The PostgreSQL tests are otherwise realistic, but they never run here withoutTEST_DATABASE_URL— CI must stay required. - Persist leaves the caller transaction dirty on error; callers must rollback. Tests do. HTTP does not exist yet.
Next buyer-facing product gap
Persist-by-participant_ref is this slice. Production still has no path that uses it.
Next slice: a hosted account-link command that (1) keeps dual-proof authorization in-process (#85), (2) calls this persist inside one READ COMMITTED transaction, and (3) resolves a returning Keyverse login through current_participant_identity_link (tenant_ref, identity_issuer, identity_subject_ref) → stable participant_ref. Without that lookup, a buyer who loses the anonymous session token has a stored link they cannot find.
After that: operator unlink/relink/recovery commands, live Keyverse token verification, and backup/restore evidence that history and the current projection reconcile. Do not fold those into a persist-order repair.
Do not self-approve. Do not merge until the one-shot relink persist works, exact-head checks are green, and an independent last-push approval exists.
| for event in participant.link_history() { | ||
| if persist_one_link(transaction, participant_ref, tenant_ref, event)? { | ||
| inserted_any = true; | ||
| } | ||
| } | ||
| for event in participant.link_end_history() { | ||
| if persist_one_link_end(transaction, participant_ref, event)? { | ||
| inserted_any = true; | ||
| } | ||
| } |
There was a problem hiding this comment.
Blocking: this applies every link (and inserts current_participant_identity_link) before any end. A complete in-memory unlink+relink aggregate — link_history = [L1, L2], link_end_history = [E1 ends L1] — therefore inserts current(L1), then current(L2), hits current_participant_identity_link_pkey, and is returned as ConflictingReplay.
That is the natural persist_participant_identity_history(tx, &record) contract after the #85 in-memory lifecycle, and also the jump from an already-persisted L1 to the full relinked record. Incremental persist (link, then end, then relink) hides it.
Persist in the same order load already uses: for each link, persist the link, then persist matching ends. Classify a current-row PK clash as an internal projection error only after that order is correct.
| let mut participant = linked_participant(); | ||
| persist_ok(&mut client, &participant); | ||
|
|
||
| participant | ||
| .record_link_end( | ||
| "link_end_event_identity_alpha", | ||
| "unlink_evidence_identity_alpha", | ||
| 10_200, | ||
| ) | ||
| .unwrap(); | ||
| assert_eq!( | ||
| persist_ok(&mut client, &participant), | ||
| IdentityLinkPersistenceDisposition::Inserted | ||
| ); | ||
|
|
||
| let unlinked = load_ok( | ||
| &mut client, | ||
| participant.participant_ref(), | ||
| participant.tenant_ref(), | ||
| ); | ||
| assert_eq!(unlinked.participant_ref(), "participant_identity_alpha"); | ||
| assert!(unlinked.linked_subject_ref().is_none()); | ||
| assert_eq!(unlinked.link_history().len(), 1); | ||
| assert_eq!(unlinked.link_end_history().len(), 1); | ||
| assert_eq!( | ||
| unlinked.link_end_history()[0].linked_event_ref(), | ||
| "link_event_identity_alpha" | ||
| ); | ||
|
|
||
| participant | ||
| .link_account( | ||
| "link_event_identity_gamma", | ||
| "keyverse_issuer_gamma", | ||
| "keyverse_subject_gamma", | ||
| "anonymous_proof_identity_gamma", | ||
| "authenticated_proof_identity_gamma", | ||
| 10_300, | ||
| ) | ||
| .unwrap(); | ||
| persist_ok(&mut client, &participant); |
There was a problem hiding this comment.
This is not a one-shot aggregate persist. L1 is committed, then E1, then L2. That path never exercises persist_participant_identity_history against a ParticipantRecord that already contains unlink+relink history, so the links-before-ends defect stays green.
Add a test that builds the full anonymous→link→end→relink record first, persists it once, reloads, and asserts participant_ref / two links / one end / current subject. Also persist that same loaded record again and expect Duplicate.
Still missing: same issuer-scoped subject can bind to another participant after unlink (the unique current-row constraint only holds while the projection exists).
| let Some(created_at_unix_ms) = | ||
| load_participant_header(transaction, participant_ref, tenant_ref)? | ||
| else { | ||
| return Ok(None); | ||
| }; | ||
| let mut record = | ||
| ParticipantRecord::new_anonymous(participant_ref, tenant_ref, created_at_unix_ms) | ||
| .map_err(|_| IdentityLinkPersistenceError::CorruptHistory)?; | ||
| let links = load_link_events(transaction, participant_ref)?; | ||
| let ends = load_link_end_events(transaction, participant_ref)?; |
There was a problem hiding this comment.
Load never takes FOR SHARE on assessment_participant. Writers lock that row, but a READ COMMITTED load can run load_link_events before a concurrent persist commits and load_link_end_events after. A newly committed link+end then looks like an orphan end and becomes CorruptHistory on a valid participant.
Lock the header row before reading history, or load links and ends in one snapshot. Retry would recover; a returning buyer should not see a false corrupt-history failure.
| CONSTRAINT participant_identity_link_end_participant_fk FOREIGN KEY (participant_ref) | ||
| REFERENCES assessment_participant (participant_ref), | ||
| CONSTRAINT participant_identity_link_end_linked_event_fk FOREIGN KEY (linked_event_ref) | ||
| REFERENCES participant_identity_link (identity_link_ref), | ||
| CONSTRAINT participant_identity_link_end_linked_event_unique UNIQUE (linked_event_ref) |
There was a problem hiding this comment.
Non-blocking schema gap: these are two independent FKs. Nothing requires linked_event_ref to belong to the same participant_ref. A composite FK (participant_ref, linked_event_ref) → participant_identity_link (participant_ref, identity_link_ref) would make that fail-closed in the database. Add a unique on participant_identity_link (participant_ref, identity_link_ref) if needed to support it.
There was a problem hiding this comment.
Review
Do not merge #114 at 2eb0b63. GitHub rejected REQUEST_CHANGES on this bot-authored PR from this automation identity, so this is a blocking COMMENT with the same verdict.
Persist cannot save a complete unlink+relink ParticipantRecord in one transaction. The only relink test never asks it to.
persist_participant_identity_history inserts every link_history row (and therefore every current_participant_identity_link row) before any link_end_history row. Load already applies each link, then its matching ends. Persist must use that same lifecycle order. Incremental commit of L1, then E1, then L2 stays green while a restart that writes the in-memory #85 aggregate fails closed on current_participant_identity_link_pkey.
Repair on this run:
- RED: persist the full anonymous→link→end→relink record once, reload, then exact replay →
Duplicate. - GREEN: for each link, persist the link, then persist matching ends.
- Cover subject reuse after unlink, and
FOR SHAREthe participant header on load.
Do not fold HTTP, live Keyverse verification, or backup/restore into that repair. After the successor lands, the next buyer gap is a current-subject lookup (tenant_ref, identity_issuer, identity_subject_ref) → stable participant_ref so a returning account can find the stored link.
Do not self-approve. Do not merge until the one-shot relink persist works, exact-head checks are green, and an independent last-push approval exists.
Sent by Cursor Automation: Fix Issues
| for event in participant.link_history() { | ||
| if persist_one_link(transaction, participant_ref, tenant_ref, event)? { | ||
| inserted_any = true; | ||
| } | ||
| } | ||
| for event in participant.link_end_history() { | ||
| if persist_one_link_end(transaction, participant_ref, event)? { | ||
| inserted_any = true; | ||
| } | ||
| } |
There was a problem hiding this comment.
These two loops apply every link (and insert current_participant_identity_link) before any end. A ParticipantRecord that already has [L1, L2] and [E1 ends L1] inserts current(L1), then current(L2), hits current_participant_identity_link_pkey, and returns ConflictingReplay before E1 runs.
Load already does the correct order at lines 172–194: persist each link, then its matching ends. Use that order here so a restart can write the in-memory unlink+relink aggregate in one READ COMMITTED transaction.
| let mut participant = linked_participant(); | ||
| persist_ok(&mut client, &participant); | ||
|
|
||
| participant | ||
| .record_link_end( | ||
| "link_end_event_identity_alpha", | ||
| "unlink_evidence_identity_alpha", | ||
| 10_200, | ||
| ) | ||
| .unwrap(); | ||
| assert_eq!( | ||
| persist_ok(&mut client, &participant), | ||
| IdentityLinkPersistenceDisposition::Inserted | ||
| ); | ||
|
|
||
| let unlinked = load_ok( | ||
| &mut client, | ||
| participant.participant_ref(), | ||
| participant.tenant_ref(), | ||
| ); | ||
| assert_eq!(unlinked.participant_ref(), "participant_identity_alpha"); | ||
| assert!(unlinked.linked_subject_ref().is_none()); | ||
| assert_eq!(unlinked.link_history().len(), 1); | ||
| assert_eq!(unlinked.link_end_history().len(), 1); | ||
| assert_eq!( | ||
| unlinked.link_end_history()[0].linked_event_ref(), | ||
| "link_event_identity_alpha" | ||
| ); | ||
|
|
||
| participant | ||
| .link_account( | ||
| "link_event_identity_gamma", | ||
| "keyverse_issuer_gamma", | ||
| "keyverse_subject_gamma", | ||
| "anonymous_proof_identity_gamma", | ||
| "authenticated_proof_identity_gamma", | ||
| 10_300, | ||
| ) | ||
| .unwrap(); | ||
| persist_ok(&mut client, &participant); |
There was a problem hiding this comment.
This test commits L1, then E1, then L2. That path deletes the current projection before the next link, so it never exercises the documented aggregate persist. Build the full anonymous→link→end→relink record first, persist once, reload, then exact-replay to Duplicate.
There was a problem hiding this comment.
Coordination — migration prefix
Do not land migrations/0021_participant_identity_link.sql as 0021.
Scoring-job readiness already claims:
0021_scoring_job_health_indexes.sqlon #1130022_scoring_job_expired_lease_health_indexes.sqlon successor #131
Renumber the identity-link migration to 0023_participant_identity_link.sql (or later) before this draft leaves draft. Directory-order recovery cannot apply two files that share one numeric prefix.
Do not duplicate the identity-link persist work on another branch. Keep the REQUEST_CHANGES items on this head; only the prefix must move.
Sent by Cursor Automation: Fix Issues
There was a problem hiding this comment.
Do not merge this head. Identity-link persist landing is now #147 (431eeca on cursor/bc-3286fd2e-32dd-4741-a21f-ada045db3ab9-1d81). This branch still looks up and uniqueness-checks only the derived current projection, so a missing projection hides a returning login and allows subject rebind.
Prefer #147 over #133, #124, and this PR. Do not self-approve.
Sent by Cursor Automation: Fix Issues
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 <seonghobae@users.noreply.github.com>
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 <seonghobae@users.noreply.github.com>
* test(auth): bind anonymous proof to exact session resource * feat(auth): bind anonymous proof to exact session resource * feat(auth): expose anonymous session authorization * style(auth): apply rustfmt to anonymous authorization tests * test(auth): pin anonymous denial precedence * docs(auth): explain anonymous session authorization * 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 <seonghobae@users.noreply.github.com> * 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 <seonghobae@users.noreply.github.com> * 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 <seonghobae@users.noreply.github.com> * 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 <seonghobae@users.noreply.github.com> * 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 <seonghobae@users.noreply.github.com> * revert(participant): leave persist/reload on Active PR #114 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 <seonghobae@users.noreply.github.com> * 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 <seonghobae@users.noreply.github.com> * 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 <seonghobae@users.noreply.github.com> * fix(auth): stop claiming command records were store-loaded 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 <seonghobae@users.noreply.github.com> * 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 <seonghobae@users.noreply.github.com> * fix(auth): name persist landing #147 and drop leftover load claims The command gate still compares supplied records. Docs and the architecture contract now name Active PR #147 for persist/reload, forbid the leftover #133 pointer, and stop saying the gate authorized from loaded records. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com> * docs(traceability): name honesty successor Active PR #159 Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com> * fix(auth): name persist landing #158 instead of superseded #147 Identity persist/reload landing moved to #158. Keep the command gate honest about supplied records and stop pinning the leftover #147 pointer. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com> * fix(auth): drop closed #158 persist landing and loaded names Name persist/reload as Target, forbid the closed pointer, and call command-test records supplied. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com> * docs(traceability): name honesty successor Active PR #225 Record the opened successor so architecture views do not leave the command-auth honesty slice labeled as closed #159. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com> * fix(auth): keep anonymous authorization after rebase onto main Preserve account-link and anonymous-credential modules beside the session-command authorization entry point so this honesty head stays mergeable without claiming participant persist on this branch. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com> * docs(traceability): drop merged Active PR leftovers after rebase After rebasing onto 0c695b9, exclusive outbox leases, observation clocks/membership, and claim-next scoring-job poll are protected-main truth. Keep #225 as the Active PR for supplied-record anonymous command authorization. Persist/reload of assessment_participant remains Target. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com> --------- Co-authored-by: Seongho Bae <me@seonghobae.me> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>


Superseded
This append-only identity-link persistence slice is fully contained in the verified successor chain #124 → #133 → #147 → #160. Do not merge this predecessor.
Fresh ancestry evidence before closure:
2eb0b63acce6764513c6c615db3d6650b50731f1fb0ca5b3da71ffc93768db44484de9ede9f6d6b9is ahead of fix(identity): restore current projection on exact replay #147 by 4 commits, behind by 0.#160 carries the durable history/reload behavior and adds the hosted dual-proof persist/recover command boundary. #160 remains Draft and must still satisfy exact-head CI/security/review gates. Closing this PR does not promote any successor behavior to protected-main truth.