Person directory
The directory holds explicit cross-surface relationships, never grants. The authenticated adapters use one atomic ten-minute operation for link, unlink and same-person recovery. Unlink fences joined projection at a new tombstone revision while each source keeps its independently authenticated rights. No operation changes historical actor, attribution or receipt fields, merges people or matches email.
- Code:
src/core/organization/worker.ts,src/core/organization/directoryTransport.ts,src/core/identity/notices.ts,src/core/identity/contract.ts,src/core/identity/engine.ts,src/core/identity/memory.ts,src/core/identity/sqlite.ts,src/core/identity/linkContract.ts,src/core/identity/linkEngine.ts,src/core/identity/humanProof.ts,src/core/identity/linkCeremony.ts,src/core/identity/testing/fakeHumanProofs.ts,src/channels/linkProofAccess.ts,src/channels/linkProofSlack.ts,src/channels/linkProofJwt.ts. - Tests:
deploy/cloudflare-memory/organization.test.ts,src/core/identity/directory.test.ts,src/core/identity/link.test.ts,src/core/identity/linkCeremony.test.ts,src/channels/linkProofs.test.ts.
Behavior
- Identity is not email. A directory mints opaque
person:<uuid>ids independently of surface ids. An external identity is the exact(issuer, tenant, subject)tuple, with explicitnulltenant for issuers without a tenant. Case and punctuation are significant; no email/name lookup or normalization exists. Only a trusted caller that has authenticated the issuer, subject, audience and tenant may supply this tuple. Neither the directory nor its proof metadata verifies a credential. - Trusted writes, not a link API.
changeis an internal persistence contract, not an unauthenticated tool or route. Its caller must authorize the operation and verify control of both human identities, or authorize separately audited recovery, before calling it. Creating a person alone confers no relationship. Link requires an existing person, an expected revision and closed proof metadata. The separate proof ceremony authenticates both sides before its atomic command; service delegation retains its own native authority checks. - One identity, one person. Link creates revision 1 from expected revision 0, or refreshes an active binding at its current revision. Conflicting people cannot both win. An identity is never reassigned to a different person, even after revocation. Recovery only reactivates the same person's revoked binding, requires administrator-recovery proof and cannot merge people or their histories. Every accepted mutation increments the revision once. A stale revision cannot undo a newer mutation; retries with the old revision return
stalewithout a second receipt. - Revocation is retained. Revoke changes an active binding to a tombstone rather than deleting it. Ordinary link cannot resurrect it, even with the current revision. Resolve returns
bound,unknown,revoked,conflict,invalidorunavailable; onlyboundcarries a person claim. Every read consults current storage, without a positive cache. A read begun after committed revocation observes the tombstone. Organization admission and current stream reads use the current revision; old joined actors remain fenced. A newly authenticated unlinked human uses its own surface principal and the tombstone revision, without a person claim. Native credential revocation remains a separate current-proof failure. - Atomic durable facts. In-memory and transactional SQLite implementations use the same transition rules. SQLite persists persons, binding revisions and attributable link/revoke/recovery receipts. Reading the current binding, testing the revision, writing it and appending its receipt occur in one synchronous transaction. A failure rolls back both writes. Reopening the same database retains person ids and revoked state. The SQLite implementation accepts a node-free storage interface compatible with Durable Object SQL storage; the Worker-backed organization directory uses this same authoritative database. Tests use real file-backed SQLite, not a simulated database.
- Fail closed. An absent or failing directory yields
unavailable, never an in-memory or email fallback. Invalid persisted bindings, including mismatched identity keys or missing people, resolve asconflictwithout a person claim. Storage errors do not leak their message. Inputs and stored proof shapes reject extra fields. Proof method and integer version are retained alongside the binding state and revision; no token, raw email or free-text proof payload is accepted. The run-attribution projection contains only the opaque person id, binding revision and proof method/version. It emits nothing for revoked/invalid bindings and does not itself emit a run event.
Validation criteria
Each contract case runs against both implementations; SQLite cases use independent connections to one real database. In-memory storage is deliberately process-local and makes no restart-durability claim.
| Criterion | Evidence |
|---|---|
| Opaque persons and two independently authenticated subjects can bind to one person | [unit] src/core/identity/directory.test.ts::person directory contract::*::binds two authenticated subjects to one opaque person |
| Issuer, tenant and exact subject isolate identities, including delimiter collisions | [unit] src/core/identity/directory.test.ts::person directory contract::*::isolates issuer tenant and exact subject tuples |
| Concurrent conflicting links have exactly one winner, matching current binding and one receipt, including independent SQLite writer processes forced to contend on a held write lock | [unit] src/core/identity/directory.test.ts::person directory contract::*::commits only one person for concurrent links of one subject, src/core/identity/directory.test.ts::SQLite person directory::arbitrates independent processes contending for one identity |
| Current-revision refresh advances once; callers cannot mutate stored bindings or receipts through returned objects | [unit] src/core/identity/directory.test.ts::person directory contract::*::refreshes at the current revision without exposing mutable stored objects |
| Email collision neither resolves another subject nor supplies proof | [unit] src/core/identity/directory.test.ts::person directory contract::*::email collision grants no person claim |
| Revoke is visible to another reader; stale writes, retries and ordinary relinks cannot restore it | [unit] src/core/identity/directory.test.ts::person directory contract::*::fences stale writes and refuses ordinary resurrection after revoke |
| Recovery requires its proof and same person, preserves attributable history and advances revision | [unit] src/core/identity/directory.test.ts::person directory contract::*::recovers only the revoked person with administrator recovery proof |
| Proof metadata persists without tokens/email; run attribution excludes external subjects | [unit] src/core/identity/directory.test.ts::person directory contract::*::persists closed proof metadata and projects only safe run attribution |
| Missing people, malformed inputs and unsafe revisions never write | [unit] src/core/identity/directory.test.ts::person directory contract::*::rejects invalid writes without receipts |
| Reopened SQLite preserves the person, proof, revision, receipts and revocation | [unit] src/core/identity/directory.test.ts::SQLite person directory::preserves binding and revocation across database reopen |
| SQLite rolls back the binding when receipt persistence fails | [unit] src/core/identity/directory.test.ts::SQLite person directory::rolls back the binding if its receipt cannot commit |
| Corrupt durable data supplies no person claim | [unit] src/core/identity/directory.test.ts::SQLite person directory::refuses corrupt or mismatched durable bindings |
| Missing and unavailable directories never fall back to a person claim or leak errors | [unit] src/core/identity/directory.test.ts::directory resolution::fails closed without a directory or when storage fails |
Operation-bound dual-identity transaction
The operation contract follows record 0104. Each new link, unlink or recovery requires fresh proof; this store slice does not activate a route, OAuth configuration or email bridge.
- Trusted metadata, not credential verification.
linkis an internal command boundary onPersonDirectory. The offline caller described below authenticates a human Access session, pins its issuer/audience, supplies SHA-256 browser/state/nonce digests of independent random values, chooses bounded intent/result deadlines, and validates Slack OIDC before staging proof. The store accepts closed metadata only, never a code, JWT, email or target person. It pins Access's exact tuple (null tenant), audience, browser digest and proof-policy version; Slack's issuer, workspace and client audience are pinned at initiation. No grant or ownership mutation exists here. - Durable one-time state. Server-minted intents transition
pending → exchanging → awaiting-consent → committed, or terminalfailed,cancelled,expired. Callbackclaimchecks state digest and the authenticated owner and advances the revision once. An authenticated internalinspectreturns detached active metadata for proof validation; it applies the same owner/deadline fence and confers no exchange permission. Onlyclaimedpermits the offline caller to exchange a code. A duplicate claim cannot exchange again. After an uncertain exchange, the future owner must sendinterrupt, never repeat code exchange; an interrupted operation cannot later stage proof. Restart alone does not reset a claim. All commands reauthenticate the exact initiating actor/audience/browser, reject expired Access proof, and compare expected intent/proof revisions. Authentication failures neither disclose nor consume an intent. Deadlines can only shorten to proof expiry. - One commit, no compensation. Explicit local consent, intent revision, deadline and both observed binding revisions are checked inside the same storage transaction that chooses/creates the person, writes missing bindings and binding receipts, consumes the intent, and appends a separate operation audit. Two unknown identities create one person; one known active identity selects its person; two active identities already on that person create only an already-linked operation outcome. Existing active bindings are not gratuitously refreshed. Different people, either tombstone in ordinary link, stale revisions or corrupt data refuse. Explicit unlink/recovery requires a new operation-bound challenge; recovery additionally requires current identity-admin permission and only reactivates the same person. No identity adoption, movement or person merge is possible. Terminal refusals retain an attributable audit but no person or binding mutation. Audit/storage failure rolls back the entire attempted transition, permitting a safe retry.
- Replay is a receipt, not authority. Within the pinned result-read deadline,
reador repeatcommitreturns the original committed receipt only to the original freshly authenticated actor/audience/browser. It does not re-resolve into another target, mutate revisions, issue another receipt or restore a later-revoked relationship. A repeatcommitmust carry the original consent-ready revision and explicit consent; a changed revision returnsstale, and missing consent returnsconsent_required. Onlyreadis revision-free. Replay checks the operation audit against an independent immutable receipt snapshot retained on the terminal intent; contradictory person, identity or other receipt facts returnconflictwithout consulting current bindings. Pending/proof staging is discarded on terminal transition; the owner digest remains solely for bounded result authorization, not an access token. Result reads after the deadline fail closed; an authenticatedinspectstill marks a committed intent as already claimed so a browser cannot exchange its old callback again. Deployment lifetime/rate limits, background retention cleanup and protocol/revocation fencing remain later ceremony gates, not claims of this storage slice. - Matching stores. SQLite runs the shared transition engine inside
transactionSync, including reads, selection and operation audit writes. The in-memory implementation stages a snapshot and publishes it only on success. Both are synchronous across each transaction; only SQLite promises restart durability. The configured browser proof adapter uses the same atomic command.
| Criterion | Evidence |
|---|---|
| Two unknown identities create exactly one person with two binding receipts and one operation audit | [unit] src/core/identity/link.test.ts::atomic link contract::*::creates one person and both bindings atomically |
| Either known identity selects its existing person, and an already-linked pair does not bump revisions or duplicate binding receipts | [unit] src/core/identity/link.test.ts::atomic link contract::*::selects the known person and preserves already linked revisions |
| Different people, revoked identities and stale observations refuse without partial writes | [unit] src/core/identity/link.test.ts::atomic link contract::*::refuses conflicts tombstones and stale observations without partial writes |
| Owner, audience, browser, state, nonce, workspace, consent and proof/intent revisions fence every transition | [unit] src/core/identity/link.test.ts::atomic link contract::*::fences authentication proof consent and intent revisions |
| Duplicate callback claim and interrupted exchange never permit another exchange or completion | [unit] src/core/identity/link.test.ts::atomic link contract::*::claims a callback once and fails an interrupted exchange |
| Concurrent overlapping intents serialize without orphan people or half-links | [unit] src/core/identity/link.test.ts::atomic link contract::*::serializes overlapping intents without adopting the winner |
| Expired and cancelled intents cannot commit; shorter proof expiry cannot extend the original deadline | [unit] src/core/identity/link.test.ts::atomic link contract::*::expires and cancels intents without bindings |
| Failed audit writes roll back person, both bindings, both receipts and intent consumption | [unit] src/core/identity/link.test.ts::atomic link contract::*::rolls back every write on audit failure |
| Response-loss retry returns the exact receipt only to its original owner, within its window, without restoring revocation; inspection still marks it claimed after the result window | [unit] src/core/identity/link.test.ts::atomic link contract::*::replays a lost response without restoring a revoked binding |
| Repeat commit requires the original consent-ready revision and consent; read remains revision-free | [unit] src/core/identity/link.test.ts::atomic link contract::*::requires the original revision and consent for committed retries |
| Replay rejects altered person and Slack identity facts even when the replacement identity is bound to the same person | [unit] src/core/identity/link.test.ts::atomic link contract::*::refuses replay when valid receipt facts contradict the committed snapshot |
| Closed inputs and returned objects cannot smuggle credentials or mutate stored state | [unit] src/core/identity/link.test.ts::atomic link contract::*::rejects credential payloads and isolates returned objects |
| Reopening SQLite preserves callback claim, pending consent and committed receipt | [unit] src/core/identity/link.test.ts::durable link intent::survives restart at claim consent and committed response loss |
| Independent SQLite processes contend on a held write lock and commit one complete pair | [unit] src/core/identity/link.test.ts::durable link intent::serializes independent writers at the database authority |
| Revocation/refresh after proof staging cannot bind the other identity; concurrent retries produce one outcome | [unit] src/core/identity/link.test.ts::atomic link contract::*::fences revocation and refresh after proof staging, src/core/identity/link.test.ts::atomic link contract::*::returns one receipt for concurrent retries of the same intent |
| Corrupt persisted state or contradictory operation outcomes cannot be replayed as successful links | [unit] src/core/identity/link.test.ts::atomic link contract::*::refuses inconsistent persisted intent and audit states |
Offline human proof and ceremony adapters
The proof slice reuses the existing Access application-JWT verifier and supplies a separate confidential-client Slack OIDC adapter. The proof adapters verify the inputs to the internal operation contract; this slice does not register ingress or change the legacy email bridge. Record 0104 defines the current operation rules. Deterministic fakes implement both proof seams; no production scopes, redirects, secrets, live link/unlink UI, or person-derived authorization are enabled.
- Access admits only the
accessstrategy's signed human session, not a service token, view-as, email/header assertion, decoded claims, or synthetic token/none identity. Retained metadata is issuer, null tenant, exact subject, configured admitted audience, issuance, expiry and human kind; email is discarded. Strict linking validation is intentionally narrower than the unchanged dashboard gate. - Slack pins discovery to query/code, RS256, issuer and endpoints, authenticates the client server-side, and uses the exact configured HTTPS redirect. State and nonce are independent unpredictable values, never substitutes for one another. A signed, allowlisted team plus agreeing human user/sub determines identity; a team hint, email or bot token cannot. Multi-audience tokens require matching authorized party.
- Initiation stores digests only and caps the deadline at ten minutes or earlier Access expiry. Callback durably claims state before exchange, reauthenticates the initiating Access subject/audience/browser, and stages only validated Slack metadata. Slack expiry can only shorten the deadline. Result reads end no later than ten minutes after the original intent deadline. Final consent revalidates Access and leaves atomic binding/revision/consent checks to the directory transaction.
- Repeated callbacks never exchange again. An uncertain exchange is interrupted, not retried; after process loss, an authenticated explicit recovery interrupt fences any old completion. A wrong browser/account/state does not consume another intent. No credential is logged, audited, cached, or emitted as a run event. Callback responses always use a clean same-origin redirect with no-store/no-referrer. The staged browser adapter adds local secure-cookie transport and CSRF checks. Edge query-log redaction, deployed cookie/CSRF policy, rate limits, and live deployment proof remain release gates, not claims of this unwired library.
| Criterion | Evidence |
|---|---|
| Access projects verified human metadata only | [unit] src/channels/linkProofs.test.ts::Access human proof::projects only signed human metadata from the application JWT |
| Access issuer, audience, finite validity, service ambiguity and synthetic evidence fail closed | [unit] src/channels/linkProofs.test.ts::Access human proof::rejects issuer audience time and human service ambiguity, src/channels/linkProofs.test.ts::Access human proof::rejects unsigned email header synthetic and view-as evidence |
| Trusted Access signing-key rotation preserves identity; unsafe keys do not verify | [unit] src/channels/linkProofs.test.ts::Access human proof::accepts trusted key rotation without changing identity and rejects unsafe keys |
| Slack pins query discovery and confidential-client exchange, exact redirect and validated identity metadata | [unit] src/channels/linkProofs.test.ts::Slack OIDC human proof::pins query discovery and exchanges once as a confidential client with the exact redirect |
| Slack rejects signature, key, issuer, audience, time, nonce, workspace, user-subject and bot/email substitutes | [unit] src/channels/linkProofs.test.ts::Slack OIDC human proof::rejects Slack signature key issuer audience time nonce team and user mismatches |
| Multi-audience authorized party and trusted rotated keys validate; protocol or policy drift never exchanges a code | [unit] src/channels/linkProofs.test.ts::Slack OIDC human proof::accepts authorized multi-audience and rotated trusted keys, src/channels/linkProofs.test.ts::Slack OIDC human proof::fails closed on discovery redirect or workspace-policy drift without exchanging a code |
| Provider errors and response credentials never escape as proof | [unit] src/channels/linkProofs.test.ts::Slack OIDC human proof::suppresses provider errors and drops all response credentials |
| Both directory implementations receive metadata only and require explicit local consent before atomic binding; replay reads the same receipt | [unit] src/core/identity/linkCeremony.test.ts::offline link ceremony::*::hands only validated metadata to the atomic consent transaction |
| Replay and concurrent callbacks exchange at most once; wrong state, browser and Access account neither exchange nor commit | [unit] src/core/identity/linkCeremony.test.ts::offline link ceremony::*::rejects replay concurrent callbacks wrong state wrong browser and account switch |
| Ten-minute intent maximum, earlier Access expiry, Slack shortening and bounded result reads cannot be extended by refresh | [unit] src/core/identity/linkCeremony.test.ts::offline link ceremony::*::bounds Access Slack intent and result lifetimes without refresh extension |
| Callback failure and durable claim recovery fail closed without code replay or any binding | [unit] src/core/identity/linkCeremony.test.ts::offline link ceremony::*::fails callback crashes and recovers a durable claim without exchanging again |
| Callback method/redirect/duplicate parameters are refused; all responses strip secrets and use no-store/no-referrer, durable rows contain no credentials or raw state/nonce/browser secret | [unit] src/core/identity/linkCeremony.test.ts::offline link ceremony::*::rejects callback transport drift and keeps secrets out of persistence and responses |
| A warmed Access key cache accepts a genuinely new trusted RSA key without changing identity, and refuses a foreign signer | [unit] src/channels/linkProofs.test.ts::Access human proof::refreshes a warm key cache for real rotation and rejects a foreign signing key |
| Real signed Access and Slack proofs reach the transaction through the ceremony, with credentials excluded from every directory command | [unit] src/channels/linkProofs.test.ts::verified proof handoff::carries real signed Access and Slack proofs through consent without persisting credentials |
Unlink, recovery and durable notices
| Criterion | Evidence |
|---|---|
| Both stores require a new two-sided operation for unlink/recover, retain the same person, keep one outcome on exact retry and write both notice rows atomically | [unit] src/core/identity/link.test.ts::identity operations (*)::requires a new two-sided challenge to unlink and recover the same person, and retains both notices |
| Fixed-organization Slack delivery receipts survive lost acknowledgments and database reopen without acknowledging browser notices | [unit] src/core/identity/link.test.ts::identity operations (*)::retains a fixed-organization Slack notice across lost acknowledgments without acknowledging its browser counterpart |
| Unlink allows new independent source input and fences the former joined owner and peer projection | [unit] src/core/organization/service.test.ts::organization authenticated service::keeps a freshly authenticated unlinked source usable while fencing its former joined owner |
| An unlinked reader retains exact-source prior history and its complete current policy census without inheriting former peer rows or rewriting the original actor | [unit] src/core/organization/contract.test.ts::organization store contract::retains only its exact source history and policy census after unlink without changing the original actor |
| Linking later preserves the accepted owner receipt and source-only history; old logical stream authority does not gain the new person's historical audience | [unit] src/core/organization/contract.test.ts::organization store contract::keeps an accepted unlinked owner's receipt and source history unchanged when linking later |
| Organization and operation stay pinned through callback and consent; all fresh proof lifetimes are at most ten minutes | [unit] src/core/identity/link.test.ts::identity operations (*)::pins the organization and operation through callback and consent and caps proof lifetime at ten minutes |
| A bounded failed delivery batch rotates durably so unavailable recipients cannot starve later notices | [unit] src/core/identity/link.test.ts::identity operations (*)::rotates a bounded failed-notice batch durably so one unavailable recipient cannot starve later notices |
| Native SQLite commits one operation and two addressed notices, preserves exact retry and bindings after client replacement, retains attempt order, and idempotently acknowledges the Slack notice without acknowledging its browser counterpart | [integration] deploy/cloudflare-memory/organization.test.ts::OrganizationDO routes::retains operation-bound account notices across client replacement and acknowledges only the addressed Slack notice |
| Browser notices filter the current recipient before the bounded read; an undelivered Slack queue cannot hide newer browser notices across restart | [unit] src/core/identity/link.test.ts::identity operations (*)::reads current browser notices before limiting undelivered Slack notices |
| A current signed Access session may predate the operation; each link, unlink and recovery still needs a new target challenge and local consent, and exact retries cannot mutate again | [unit] src/core/identity/linkCeremony.test.ts::offline link ceremony::*::revalidates a current Access session while each account change requires its own target challenge and consent |