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.
Decisions (settled 2026-06-12)
Section titled “Decisions (settled 2026-06-12)”- Evidence home: a new neutral-core entity,
person_contact_point— not consumer snapshot tables, notperson_profilefields. - Evidence-class principle (below) governs all matching.
- Rebind rule: pristine-guard auto-rebind; fall-through enqueues merge review.
- Claim-token flow: authenticated redemption reusing the rebind primitive.
- 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).
- NRPS alignment lands in this milestone: confirmed-email filter (security fix), contact-point participation, correlation-review enqueue on ambiguity.
- Minted-person disposal: hard delete after pristine rebind, with rebind
provenance recorded in the surviving binding’s
binding_meta. - Two review structures (revision, 2026-06-12): merge review for signup paths, correlation review for consumer paths — one honest meaning per table.
The evidence-class principle
Section titled “The evidence-class principle”- 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).
identity_correlation_review
Section titled “identity_correlation_review”| 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
boundcreates the identity binding and dismisses sibling pending rows for the same external subject.
Schema
Section titled “Schema”person_contact_point (neutral core)
Section titled “person_contact_point (neutral core)”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.
person_claim_token
Section titled “person_claim_token”| 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:
- Candidate guard: the candidate person must be
activeand have no activesupabase-authbinding. Otherwise: reject (token path) / enqueue review (email path). - Pending-review guard: a pending
identity_merge_reviewrow 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 tomerged(resolved_atset,resolved_by_person_idNULL = system) by a successful rebind. - Pristine guard: the minted person has no rows in any person-linked table
beyond its own
person_profileand thesupabase-authbinding being moved. The check is generated from the erasure registry: every registry entry’ssubjectColumnsandactorColumnsbecomeNOT EXISTSprobes (a seconds-old person has authored nothing either), withperson_profile,identity_binding, andidentity_merge_reviewhandled 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. - Rebind:
UPDATE identity_binding SET person_id = candidatein place, stampingbinding_metawith{ rebound_from, path: 'email-match' | 'claim-token', rebound_at }; restamp claims through the shared helper (soperson_idandauthorizationnever disagree); hard-delete the minted person (cascades its profile; pristine means nothing else references it). - Fall-through (guard 3 fails): keep the minted person, enqueue
identity_merge_reviewwith the binding coordinates and areason_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().
Email-match path (linking row 4)
Section titled “Email-match path (linking row 4)”Two evaluation points, because confirmation happens after the INSERT trigger v2 sees:
- At INSERT (
email_confirmed_atalready set — OAuth signups, some invite flows): trigger v2 gains a resolve step before mint — exactly-oneperson_contact_pointemail 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 theemail_confirmed_at NULL → NOT NULLtransition → 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.
Claim-token path (linking row 3)
Section titled “Claim-token path (linking row 3)”The user signs up however they like (any provider — no email coupling), then redeems the token in an authenticated session:
- Authenticated endpoint receives the plaintext token; service-role lookup by hash; validate not expired / not redeemed / not revoked.
- 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). - Mark token redeemed (
redeemed_at,redeemed_by_subject), force refresh.
Review producers (prerequisite 3)
Section titled “Review producers (prerequisite 3)”| 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.
NRPS alignment (in this milestone)
Section titled “NRPS alignment (in this milestone)”apps/api/src/features/lti-tool/nrps-service.ts predates this design and
auto-binds platform-asserted emails against auth.users.email:
- 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. - 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.
- Correlation enqueue: two-or-more candidates stop being silent — each
candidate gets an
identity_correlation_reviewrow (nrps_ambiguous) and the member staysunresolvedin the snapshot. Zero-candidate members remain plainunresolved(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)”- Schema + erasure declarations:
person_contact_point,person_claim_token,identity_correlation_reviewinidentity-context.ts; registry entries (CI enforces). - Generators: rebind primitive + registry-derived pristine check (new
generator); trigger v2 INSERT-time resolve step
(
generate-supabase-auth-hooks-migration.ts); theemail_confirmed_atUPDATE trigger. Migration set regenerated; dropping/recreating the dev database is Anton’s step. - API: token issuance + redemption endpoints; NRPS alignment (filter, contact points, enqueue).
- Seeds/tests adapt where they assume contact data lives elsewhere.
Test plan
Section titled “Test plan”- 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.
Deferred (recorded, not blocking)
Section titled “Deferred (recorded, not blocking)”sms/phonematching (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.