Skip to content

feat(identity): persist append-only account-link history - #114

Closed
cursor[bot] wants to merge 3 commits into
mainfrom
cursor/bc-1ae7b405-b15d-4c2a-9586-6377f0051022-f428
Closed

feat(identity): persist append-only account-link history#114
cursor[bot] wants to merge 3 commits into
mainfrom
cursor/bc-1ae7b405-b15d-4c2a-9586-6377f0051022-f428

Conversation

@cursor

@cursor cursor Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

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:

#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.

cursoragent and others added 2 commits August 16, 2026 15:25
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>
@cursor
cursor Bot requested a review from seonghobae August 16, 2026 15:26
The Active PR #114 naming commit stored an empty ADR-0020. Restore the
accepted decision, including the #114 persistence status and APA 7
references, so identity-link governance is not silently deleted.

Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
cursor Bot pushed a commit that referenced this pull request Aug 16, 2026
#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 <seonghobae@users.noreply.github.com>

@seonghobae seonghobae left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. src/postgres_participant_identity_link.rs:128-137 — persist applies all links before all ends.
    insert_current_projection runs on every newly inserted link. For a ParticipantRecord that already has [L1, L2] and [E1 ends L1], persist inserts current(L1), then current(L2), hits current_participant_identity_link_pkey, and returns ConflictingReplay (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.

  2. 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): no FOR SHARE on the participant row. Under READ COMMITTED, load_link_events then load_link_end_events can see a newly committed end without its link and return CorruptHistory. 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_end FKs do not require linked_event_ref to belong to the same participant_ref. current_participant_identity_link denormalizes issuer/subject/tenant (ADR-allowed projection). History tenant_ref duplicates assessment_participant.tenant_ref. Names are two-or-more-word snake_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 same participant_ref under another tenant returns ConflictingReplay — 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_state on one row; physical split into link + end + current is honest in AS_BUILT. CREATE TABLE IF NOT EXISTS assessment_participant can 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 without TEST_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.

Comment on lines +128 to +137
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;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +211 to +250
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment on lines +162 to +171
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)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +127 to +131
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. RED: persist the full anonymous→link→end→relink record once, reload, then exact replay → Duplicate.
  2. GREEN: for each link, persist the link, then persist matching ends.
  3. Cover subject reuse after unlink, and FOR SHARE the 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.

Open in Web View Automation 

Sent by Cursor Automation: Fix Issues

Comment on lines +128 to +137
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;
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +211 to +250
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);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

cursor Bot pushed a commit that referenced this pull request Aug 16, 2026
Renumber the identity-link migration so it does not collide with #113 scoring-job health indexes on 0021. Name Active PR #124 as the merge candidate over #114.

Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coordination — migration prefix

Do not land migrations/0021_participant_identity_link.sql as 0021.

Scoring-job readiness already claims:

  • 0021_scoring_job_health_indexes.sql on #113
  • 0022_scoring_job_expired_lease_health_indexes.sql on 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.

View PR

Open in Web View Automation 

Sent by Cursor Automation: Fix Issues

cursor Bot pushed a commit that referenced this pull request Aug 16, 2026
Add a composite foreign key so a link-end or current projection cannot
point at another participant's identity-link row. Name Active PR #133
as the landing vehicle over #124 and #114.

Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

View PR

Open in Web View Automation 

Sent by Cursor Automation: Fix Issues

cursor Bot pushed a commit that referenced this pull request Aug 16, 2026
Point TRACEABILITY, ADR-0020, ERD, and the as-built schema at this
successor so operators do not merge superseded #133, #124, or #114.

Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
cursor Bot pushed a commit that referenced this pull request Aug 16, 2026
Point TRACEABILITY, ADR-0020, ERD, and the as-built schema at this
successor so operators do not merge superseded #147, #133, #124, or #114.

Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
@seonghobae seonghobae closed this Aug 16, 2026
cursor Bot pushed a commit that referenced this pull request Aug 17, 2026
#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 <seonghobae@users.noreply.github.com>
cursor Bot pushed a commit that referenced this pull request Aug 17, 2026
#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 <seonghobae@users.noreply.github.com>
cursor Bot pushed a commit that referenced this pull request Aug 17, 2026
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>
cursor Bot pushed a commit that referenced this pull request Aug 17, 2026
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>
cursor Bot pushed a commit that referenced this pull request Aug 18, 2026
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>
cursor Bot pushed a commit that referenced this pull request Aug 18, 2026
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>
seonghobae added a commit that referenced this pull request Aug 18, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants