Skip to content

Identity linking — design (v1)

Status: implemented (2026-06-12) — designed, revised (review structures split in two during implementation — see “Two review structures”), and implemented the same day; full validation + container-backed integration gates green. The dev database replay (drop + migrate) is the remaining operational step. Decisions this builds on: ADR-0013 (person-rooted identity), ADR-0014 (erasure is anonymization), ADR-0012 (milestone ordering) Design groundwork: person-auth-decoupling-v1.md (linking table rows 3–4 were assigned here), oneroster-consumer-v1-brief.md (prerequisites 1–3) Blocks: OneRoster consumer SIS correlation, merge-review admin panel

This doc covers the three in-milestone prerequisites from the consumer brief: the two pre-provisioned linking paths (claim token, verified-email match), the person-side home for correlation evidence, and the first producers for identity_merge_review.

  1. Evidence home: a new neutral-core entity, person_contact_point — not consumer snapshot tables, not person_profile fields.
  2. Evidence-class principle (below) governs all matching.
  3. Rebind rule: pristine-guard auto-rebind; fall-through enqueues merge review.
  4. Claim-token flow: authenticated redemption reusing the rebind primitive.
  5. SIS matching: auto-bind exactly-one service-asserted matches; ambiguity holds and enqueues correlation review (revised — see below; the original “mint + merge review” rule manufactured avoidable data merges).
  6. NRPS alignment lands in this milestone: confirmed-email filter (security fix), contact-point participation, correlation-review enqueue on ambiguity.
  7. Minted-person disposal: hard delete after pristine rebind, with rebind provenance recorded in the surviving binding’s binding_meta.
  8. Two review structures (revision, 2026-06-12): merge review for signup paths, correlation review for consumer paths — one honest meaning per table.
  • Service-asserted evidence — SIS roster data, invite provisioning, admin entry, a registered LTI platform’s NRPS payload — may auto-bind against other service-asserted evidence when a match is exactly one candidate.
  • End-user-claimed evidence — a self-signup email — participates in matching only once Supabase has verified it (email_confirmed_at IS NOT NULL). This is the “never link on an unverified email” hard rule generalized: an unconfirmed signup email must never capture a pre-provisioned person or an incoming external identity (the NRPS gap below).
  • Ambiguity anywhere (zero-or-many candidates where one is required, conflicting pending reviews) falls through to an operator review — never a heuristic pick. Which review depends on the path: signup paths mint and enqueue a merge-review pair; consumer paths hold and enqueue a correlation review (see “Two review structures”).

Two review structures (revision, 2026-06-12)

Section titled “Two review structures (revision, 2026-06-12)”

Implementation surfaced a conflation in the original design: identity_merge_review models a pair (minted_person_id is deliberately NOT NULL), but consumer-path ambiguity has no minted person — NRPS/SIS are resolve-only on match failure. The two situations differ by whether a human is standing at the door:

  • Signup paths (self-signup, email confirmation, token redemption): a human is present with a session and must get a person now — minting is forced. If evidence later suggests a duplicate, the truthful record is a pair and resolution is a merge. That is identity_merge_review, unchanged.
  • Consumer paths (NRPS now, SIS next): no human is waiting, so the sync can hold an ambiguous correlation instead of minting. The truthful record is “external subject X has candidate persons A, B” and resolution is a binding — tooling that already exists (identity-bindings admin + snapshot reapply). That is identity_correlation_review (new).
Column Notes
id uuid PK
provider / issuer / external_subject the unresolved external identity
candidate_person_id FK → person, cascade — one row per candidate
evidence jsonb (matched email, source snapshot reference)
reason_code e.g. nrps_ambiguous, sis_ambiguous
status pending → bound | dismissed
resolved_by_person_id / resolved_at operator resolution
  • Partial unique on (provider, issuer, external_subject, candidate_person_id) where pending — producers are idempotent per sync.
  • Erasure class cascade on the candidate; rows referencing a disposed person vanish with it (the next sync re-evaluates against the post-rebind world), so the table is excluded from pristine probes by rule, like the merge queue.
  • Operator endpoints trail with the admin panel, same as merge review: receiving structure first, UI after real traffic. Resolving a row to bound creates the identity binding and dismisses sibling pending rows for the same external subject.

Why person-side, not snapshots or profile: linking row 4’s rule is person-scoped (“the Person was provisioned with exactly that email”) and pre-provisioned persons arrive via SIS sync, invites, guardian provisioning, and admin creation — only the first has snapshots. The auth triggers need one indexed lookup, not N boundary shapes. And ADR-0012’s totality argument needs a first-class core home for OneRoster User.email/sms/phone regardless (a neutral entity, never a wire shape). Profile fields fail on provenance and multiplicity.

Column Notes
id uuid PK
person_id FK → person, cascade
kind enum email | sms | phone (v1 matching uses email only)
value normalized — emails lowercased/trimmed at write time
source provenance code: sis-import | invite | admin (service-role writers only)
source_meta jsonb (e.g. issuer + sourcedId of the asserting import)
audit columns auditColumnsUs()
  • Unique on (person_id, kind, value). Not unique on value: guardians and children legitimately share an email in SIS data; multiplicity is handled by the exactly-one matching rule, not the schema.
  • Index on (kind, value) for the match lookup.
  • No person-side “verified” flag: mailbox verification is Supabase’s (email_confirmed_at); the person-side trust signal is provenance.
  • Erasure class: cascade (subjectColumns: [person_id]) — contact values are pure identity evidence, deleted with the person.
Column Notes
id uuid PK
person_id FK → person, cascade — the person this token claims
token_hash sha-256 of the token; plaintext shown exactly once at issuance
expires_at required TTL
redeemed_at / redeemed_by_subject single-use enforcement
revoked_at operator revocation
created_by_person_id actor, set-null
audit columns auditColumnsUs()
  • Erasure class: cascade (subjectColumns: [person_id], actorColumns: [created_by_person_id]).
  • Issuance is service-role only (admin/teacher endpoints), consistent with the standing hard rule that binding rows come only from service-role paths.

The rebind primitive (shared core of both paths)

Section titled “The rebind primitive (shared core of both paths)”

One SECURITY DEFINER function — callable from the email_confirmed_at trigger and from the token-redemption endpoint — implementing: rebind an auth subject from its minted person to a candidate person, guarded; else enqueue review.

Semantics, in order:

  1. Candidate guard: the candidate person must be active and have no active supabase-auth binding. Otherwise: reject (token path) / enqueue review (email path).
  2. Pending-review guard: a pending identity_merge_review row involving the minted person with a different candidate blocks auto-rebind (leave both for the operator). A pending row for the same pair is auto-resolved to merged (resolved_at set, resolved_by_person_id NULL = system) by a successful rebind.
  3. Pristine guard: the minted person has no rows in any person-linked table beyond its own person_profile and the supabase-auth binding being moved. The check is generated from the erasure registry: every registry entry’s subjectColumns and actorColumns become NOT EXISTS probes (a seconds-old person has authored nothing either), with person_profile, identity_binding, and identity_merge_review handled by rules 1–2. Generator emission keeps the DB function and the registry from drifting — a new person-linked table is automatically part of the guard.
  4. Rebind: UPDATE identity_binding SET person_id = candidate in place, stamping binding_meta with { rebound_from, path: 'email-match' | 'claim-token', rebound_at }; restamp claims through the shared helper (so person_id and authorization never disagree); hard-delete the minted person (cascades its profile; pristine means nothing else references it).
  5. Fall-through (guard 3 fails): keep the minted person, enqueue identity_merge_review with the binding coordinates and a reason_code (email_match_nonpristine, email_match_ambiguous, claim_token_nonpristine, …).

Post-rebind sessions: linking after a session exists requires a token refresh for the claim to appear (standing rule). The email path orders naturally — the confirmation flow issues its session after the UPDATE trigger has restamped. The redemption endpoint returns a must-refresh signal and the client forces refreshSession().

Two evaluation points, because confirmation happens after the INSERT trigger v2 sees:

  • At INSERT (email_confirmed_at already set — OAuth signups, some invite flows): trigger v2 gains a resolve step before mint — exactly-one person_contact_point email match against persons with no active auth binding → bind directly, no minted person ever exists. Zero-or-many → mint as today (many additionally enqueues review).
  • At UPDATE (confirmation arrives later): new trigger on auth.users, firing on the email_confirmed_at NULL → NOT NULL transition → rebind primitive. With confirmation required there is no session before this point, so the minted person is pristine in virtually every real flow — rostered-learner onboarding stays frictionless; the queue receives genuine ambiguity only.

The user signs up however they like (any provider — no email coupling), then redeems the token in an authenticated session:

  1. Authenticated endpoint receives the plaintext token; service-role lookup by hash; validate not expired / not redeemed / not revoked.
  2. Rebind primitive (same guards, path: 'claim-token'). A candidate that already has an active auth binding rejects the redemption (the token was issued against a person someone else now owns — operator investigates).
  3. Mark token redeemed (redeemed_at, redeemed_by_subject), force refresh.
Producer Queue Trigger reason_code
Email-match fall-through merge review rebind guards fail at either evaluation point email_match_*
Claim-token fall-through merge review non-pristine minted person at redemption claim_token_*
NRPS ambiguity (this milestone) correlation review two-or-more candidates during member resolution nrps_ambiguous
SIS correlation (consumer milestone) correlation review ambiguous contact match during roster ingest sis_ambiguous

SIS rule restated for the consumer doc to inherit (revised): unbound sourcedId whose contact evidence matches exactly one person → auto-bind (service-asserted ↔ service-asserted); zero matches → pre-provision (mint person

  • sourcedId binding + contact points — the normal rostered-person flow); many → hold the member and enqueue correlation reviews; the rest of the sync proceeds. The ambiguous member’s data waits for an operator bind — never a post-hoc person merge.

apps/api/src/features/lti-tool/nrps-service.ts predates this design and auto-binds platform-asserted emails against auth.users.email:

  1. Security fix: the match must require email_confirmed_at IS NOT NULL. Today an attacker who signs up with a victim’s email and never confirms it captures the victim’s LTI identity on the next roster sync.
  2. Contact-point participation: candidates are the union of persons with a matching confirmed auth email and persons with a matching email contact point (service-asserted ↔ service-asserted), so rostered-but-never-logged-in persons resolve too. NRPS binds LTI subjects, so a candidate’s auth-binding state is irrelevant here — the no-active-auth-binding constraint belongs to the signup email path only.
  3. Correlation enqueue: two-or-more candidates stop being silent — each candidate gets an identity_correlation_review row (nrps_ambiguous) and the member stays unresolved in the snapshot. Zero-candidate members remain plain unresolved (nothing for an operator to decide). NRPS thereby becomes the first correlation producer and proves the structure before the SIS consumer lands.

Sequencing (greenfield — fresh migration set, per standing approach)

Section titled “Sequencing (greenfield — fresh migration set, per standing approach)”
  1. Schema + erasure declarations: person_contact_point, person_claim_token, identity_correlation_review in identity-context.ts; registry entries (CI enforces).
  2. Generators: rebind primitive + registry-derived pristine check (new generator); trigger v2 INSERT-time resolve step (generate-supabase-auth-hooks-migration.ts); the email_confirmed_at UPDATE trigger. Migration set regenerated; dropping/recreating the dev database is Anton’s step.
  3. API: token issuance + redemption endpoints; NRPS alignment (filter, contact points, enqueue).
  4. Seeds/tests adapt where they assume contact data lives elsewhere.
  • Unit: pristine-check generator emits a probe for every registry table’s subject and actor columns (test against the registry, so a new table without regeneration fails); email normalization; token hash/expiry validation.
  • Integration (extend the erasure-suite pattern): INSERT-time resolve (OAuth, pre-confirmed) binds without mint; UPDATE-time pristine rebind (signup → confirm) rebinds, deletes minted person, restamps claims; non-pristine → review row, both persons survive; two contact points sharing an email → review, no bind; token happy path / expired / revoked / double-redeem / candidate-already-bound; NRPS: unconfirmed auth email no longer matches, contact-point-only persons resolve, ambiguity enqueues correlation rows; erasure removes contact points, claim tokens, and correlation rows (registry CI covers declarations).
  • E2E: rostered-learner onboarding — provision person + contact point, signup, confirm, learner lands in their offering with history attached.
  • sms/phone matching (schema carries the kinds; matching is email-only in v1).
  • Admin-console identity-review panel (merge + correlation tabs) — unchanged posture: trails real queue traffic. Correlation bound-resolution endpoints land with it.
  • Non-pristine person merging (true data merges) — operator tooling beyond pending/merged/dismissed comes with the admin panel. The hold-and-correlate consumer rule keeps this rare by construction.
  • Contact-point effective windows / richer verification states — provenance suffices until a concrete need arrives.