Status: Optional flow, disabled by default; live Google/provider enablement remains a separate approval
This candidate implements the optional coordinator account-management flow approved in ADR 0004. It completes a link only for an existing Identity and device; it creates no actor, device enrollment, Project access, sync admission, recovery path, relay, production coordinator configuration or provider deployment, or mandatory-auth activation.
The routes and states in this document describe the current initial-link candidate. They do not provide unlink, reconnect, account switching, or profile refresh. A separate proposed V3 contract, Coordinator account-management lifecycle, defines those future operations, their append-only migration boundary, and the Identity-wide revocation barrier. It is not implemented or enabled by this protocol, and it does not change the current revoked-link rules.
The mapping remains one Google account to one existing actor-backed Identity per coordinator, and one link per Identity. Issuer plus subject is the account key; email, name, and picture never prove ownership or select an Identity. Revoked link rows remain uniqueness conflicts.
A first link has one of two authority sources: an admin-reviewed controller attestation, or a planned device claim for an enrolled device whose Identity is unassigned or matches the requested Identity. Device claims pass the complete device-claim eligibility guard; attested links keep their existing attestation checks. See Device-claim first link.
The coordinator HTTPS origin is configured and pinned. Google uses openid email profile; the flow requests neither offline_access nor Google's
access_type=offline, and retains no Google access,
refresh, or ID token after the ceremony. Use a mature portable OIDC library for
authorization-code validation, including S256 PKCE, state, and nonce.
Use openid-client 6.x with explicit JWS validation through
enableNonRepudiationChecks.
If UserInfo is needed, pass the verified ID-token subject to fetchUserInfo;
never skip that match. Provider endpoints come only from trusted configuration
and validated discovery, not request parameters.
For initial linking, the runtime starts a 10-minute, single-use attempt over the
pinned coordinator origin. It supplies its existing signed-device request;
authorizeRequest in coordinator-api.ts checks the group/enrollment, stored public key,
signature, freshness, and nonce. That check proves key possession, not Identity
ownership; neither its result nor a caller label is link authority by itself.
Authority comes from a controller attestation or from the device-claim guard below.
The runtime generates:
- a 32-byte random runtime verifier and sends only SHA-256 of its raw bytes;
- a separate 32-byte random browser-start code and sends only SHA-256 of its raw bytes;
- a random public
attemptId, which is not a credential.
The coordinator stores a pending attempt with the coordinator ID, actor ID, enrolled device ID, pinned public key and fingerprint, runtime-verifier and browser-start commitments, group ID, configured issuer, and auth-config revision. Every attempt (planned) also records its server-derived authority kind. Those fields and the local callback target are immutable. The runtime does not create or receive the browser-completion secret at attempt creation; knowing both proofs at initiation would defeat their separation.
The runtime binds and holds its loopback listener before creating the attempt, then registers this literal destination exactly:
http://127.0.0.1:<selected-port>/codemem/auth/complete
http://[::1]:<selected-port>/codemem/auth/complete
The coordinator rejects another scheme, hostname, credentials, query, fragment,
path, external override, or later destination change. Creation returns trusted
coordinator/attempt metadata, not the raw runtime verifier or completion secret.
The device command builds the private browser-start URL locally with the
one-time start code. Browser routes mount only through explicit optional server
configuration; default coordinators leave them off. The code
is not a Google credential or permission grant; it prevents a
public attempt ID alone from claiming that device's flow. Do not share or log
the start URL.
parseCoordinatorAuthLoopback implements this exact syntax check, including an
explicit canonical decimal port from 1 through 65535. Preserve the returned
destination verbatim, including :80; URL normalization is not an authorization
step. Signed device creation validates this target. The listener accepts one
GET for its outstanding attempt, matching the exact literal Host and port,
saved path, and only attempt_id plus canonical completion. It rejects other
methods, duplicate or extra parameters, absolute-form targets, and prefetch or
non-navigation requests, and returns a no-store, no-referrer response.
The browser-entry handlers implement GET /auth/link/start?attempt_id=...&start_code=...
as a page only: it does not claim an attempt or start provider authorization.
GET and POST reject URLs longer than 8,192 characters before parsing the URL or
query, reading cookies, or calling the provider or store.
The page uses the existing pre-start cookie and CSRF token, with exactly
csrf, attempt_id, and start_code in a POST to the clean /auth/link/start
address. The optional app composition mounts this entry with the callback,
confirmation, completion, account, and logout handlers as one setup.
The POST checks exact Origin before spending the shared browser-form quota,
requires the cookie-bound CSRF token and canonical 32-byte code, and promotes
the same pre-start cookie value only after atomic admission. Existing transaction
cookies block a new start; existing browser sessions remain untouched. The form
page uses Referrer-Policy: same-origin so its POST retains a usable Origin;
the separate provider-continuation page uses no-referrer and carries no start
code. Gateways must omit the private handoff query, Referer, and form body from
logs. No account, device, or sharing permission comes from opening this page.
The atomic claim matches its raw-byte hash to the signed device attempt's saved commitment before recording the browser cookie binding. Missing or wrong codes leave the attempt and provider material untouched. Once claimed, the original browser binding controls progress; the code cannot claim another browser. Public creation must require the commitment, and public start must require the code. Nullable legacy commitments exist only for internal compatibility; they are not a public fallback, and blind compatibility claims cannot bypass a saved non-null commitment. The device still does not know the later completion secret.
Initiation creates a host-only __Host-codemem-auth-transaction cookie with
HttpOnly, Secure, SameSite=Lax, and Path=/, plus a CSRF value.
The browser transaction binds the exact attempt and
OIDC state, nonce, and PKCE verifier. The configured OIDC redirect URI is a
preconfigured HTTPS coordinator callback only, with response_mode=query.
Browser-transaction persistence is implemented but pending handler integration under the browser-transaction storage contract: it stores only transaction commitments and pending SDK nonce/PKCE material, not cookies or CSRF values. Future route work must retain the approved CSRF value and same-origin/Origin validation; it must not replace that protection with Origin-only checks. The callback remains a fixed configured HTTPS URI, never one derived from a request header, and the existing stored device attempt alone controls the literal loopback second hop.
The storage contract now supports explicit sign-in cancellation and trusted-config retirement before callback, but neither is an HTTP handler. Public routes still need the required cookie, CSRF, Origin, proof, and failure-order integration; retirement is not physical cleanup or capacity recovery.
At that callback, the coordinator verifies the browser cookie and exact state, nonce, PKCE, issuer, signature, audience, expiry, and provider subject. It holds only verified minimal account claims and the confirmed issuer/subject in the pending attempt. The callback writes no link, enrollment, browser session, or Identity binding.
The confirmation page names the account, target Identity, and requesting device.
Its explicit confirmation requires CSRF protection and same-origin validation.
On success, the coordinator generates the separate random 32-byte completion
secret and stores only SHA-256 of its raw bytes. Proofs use canonical base64url
encoding in transit; hash comparisons use constant-time comparison. The browser receives an explicit link only to the immutable loopback URL
with attempt_id and completion query fields. It uses Cache-Control: no-store, Referrer-Policy: no-referrer, and a restrictive CSP.
The createCoordinatorBrowserLinkHandlers continuation receives only
the existing callback's verification outcome and original browser binding. It offers
the confirmation screen but creates no account link, session, or stored profile.
Confirmation builds the return page before writing and sends its private link
only after the store confirms the write. The requesting device still supplies
its independent proof before the link becomes permanent.
Confirm and cancel POSTs use the existing exact-Origin and CSRF guard and resolve the attempt through the original browser cookie. Cancellation affects only that attempt and explicitly retires its temporary provider material before reporting success or clearing the transaction cookie. A storage or cleanup fault returns an error without clearing the cookie; no rollback is promised after a committed failure. General maintenance runs only through an explicit operator call or the optional scheduled handler; no request performs a general purge.
If the confirmation screen is lost or reloaded, its verified attempt is not reopened and the profile is not reconstructed from storage. The user must cancel from the requesting device or wait for expiry and start again. If confirmation commits but its return page is lost, the original confirmation form can still cancel the attempt. Scheduled maintenance must finish erasing temporary material when a cancellation commits but its cleanup fails.
Both GET and POST sign-in and link-start handlers check an existing transaction cookie against the current server transaction using its binder hash and exact coordinator. One SQL statement reads that transaction and its own stored link attempt; the caller cannot nominate an attempt. Invalid cookies or changed issuer, configuration revision, or redirect URI deny restart without clearing the cookie. Lookup faults return an unavailable response without exposing storage errors.
An active sign-in, link, or owner-enrollment transaction still returns 409 without
changing cookies. A missing or expired transaction, or its exact link attempt in
finalized, session_redeemed, failed, or expired state (or past its deadline),
allows a fresh start. Only a successful start POST replaces the old transaction
cookie. Start-page responses and existing-session redirects never delete it:
a delayed response based on an inactive snapshot must not erase a newer transaction.
No other transaction is cancelled or modified. Account confirmation does not clear
the cookie. Starting again intentionally gives up optional session redemption
for the previous link; it never redeems that link or switches accounts automatically.
Owner-enrollment start pages and claim POSTs use the same activity check without deleting cookies. Only absent or inactive transactions permit the existing owner flow to continue; active, malformed, changed-config, or unavailable reads deny it. This supplies no ownership evidence: admission, CSRF, pending-key/start proofs, fresh provider verification, qualified-or-attested owner checks, and independent trusted grant checks stay required.
Runtime polling requires the attempt device's signed request; browser polling requires its original transaction cookie. A public attempt ID is not authorization. Polling exposes status only. It never returns a browser-completion secret, raw account details, provider tokens, or a session secret. A callback replay, expired attempt, state mismatch, or consumed attempt fails closed.
Once the local listener receives the completion proof, it offers an explicit
Finish linking link to GET /auth/link/complete?attempt_id=... at the runtime's
pinned coordinator origin. The link includes only its own public attempt ID,
never a proof or a browser-supplied return target, and uses no-referrer.
The listener does not redirect automatically. The
completion handler checks the original transaction cookie before showing status.
A confirmed attempt shows a read-only waiting page; a finalized attempt offers
an explicit CSRF-protected POST /auth/link/complete form. GET never creates a
session or clears a cookie.
The POST must also confirm finalized state before preserving an existing live browser session or redeeming a new one. Preservation atomically consumes the attempt while keeping the existing session unchanged, so replaying without that session cannot mint another. New issuance uses the cookie-bound atomic redeem, never the trusted compatibility method. Session credentials leave the server only after an issued result; rejection or uncertainty preserves the transaction cookie. Existing sessions are not replaced, and initial-link profiles are not stored. The optional composition connects these handlers and signed device routes. Actual browser navigation, live Google setup, and remote database verification remain pilot requirements.
| Transition | Driver and guard |
|---|---|
pending → browser_claimed |
First browser claim records its transaction-cookie hash; further claims fail |
browser_claimed → oidc_verified |
Callback matches cookie, state, nonce, PKCE and verified claims; account fields become immutable |
oidc_verified → confirmed |
Same browser confirms once with CSRF proof; completion-secret hash written once |
confirmed → finalized |
Signed device finalization and all atomic authority/proof checks below |
finalized → session_redeemed |
Original browser redeems once within two minutes, with active link/config and CSRF checks |
Any unfinished state → expired |
Ten-minute attempt deadline passed; no new link or session |
Any unfinished state → failed |
Cancellation by the attempt's signed device request or original browser transaction cookie, or irrecoverable provider/config failure; no grants |
The ten-minute deadline applies through finalization; session redemption must also occur before that original deadline. Wrong proofs fail without consuming the attempt or overwriting state. Restart retains authoritative state; it never reopens consumed attempts or creates browser ownership from a request label.
The device API is opt-in through createCoordinatorApp({ authLink: ... }) with
trusted browser configuration and a store factory that supplies both request
authentication and link operations. Absent or disabled configuration leaves the
four attempt routes at 404. The configured Worker mounts them when optional
browser authentication is ready.
| Route | Signed input and result |
|---|---|
POST /v1/auth/link-attempts |
Requires group_id, identity_id, authority, attempt_id, runtime_verifier_hash, browser_start_hash, and the literal loopback_redirect. Returns public status, server-evaluated identity_id and authority, and configured coordinator_id, never proofs or a private start URL. |
GET /v1/auth/link-attempts/:attemptId?group_id=... |
The attempt device's signed request reads public status; a different device and a missing attempt both receive an unavailable response. |
POST /v1/auth/link-attempts/:attemptId/finalize |
Exact purpose, coordinator/attempt/group/Identity/device/fingerprint fields and canonical raw runtime_verifier/completion proofs. The path and body attempt must match; the server hashes decoded 32-byte proofs. Client-nominated proof hashes are rejected. |
POST /v1/auth/link-attempts/:attemptId/cancel |
Signed group_id only; fails the requester's bound attempt and retires its temporary provider material before reporting success. |
If the same attempt already failed with browser_cancelled, its original signed
device may confirm that cancellation before the attempt deadline. The endpoint
returns 200 with the existing failed status after temporary-material cleanup.
This includes retrying deletion of that attempt's unpromoted OIDC profile if
browser cancellation committed before profile cleanup failed. Promoted profiles
remain unchanged. A cleanup failure does not acknowledge cancellation or roll
back the already committed failed state.
It does not rewrite the reason, finalize a link, or grant account authority. The
runtime reports link_stopped, not successful linking. Other failure reasons,
completed or expired attempts, and mismatched device proofs do not gain this
acknowledgement.
Bodies are limited to 4 KiB and exact fields. Authentication uses the existing stored enrollment key, signature, timestamp and nonce, with a separate per-device quota using the existing limits; SQL still supplies the attempt's authority. Browser sessions or Google claims are not device authorization. Storage or cleanup faults report no success and disclose no proof or private cause. The local command and listener are implemented, along with optional app mounting; real browser checks remain outstanding. This option changes no enrollment or access.
A device uses these signed routes to move itself to the Identity of the Google
account the person signs in with (ADR 0005 Section 8). They mount only when
optional browser authentication is ready, because the person confirms the
change in the browser. Once a request passes signed admission, the routes
report 404 not_found until the coordinator has the current rebind tables. On
D1 that means migration 0038_add_device_rebind.sql has been applied.
A self-bind is the same ceremony when the Google account is linked to the device's current Identity: the target equals the current Identity, the commit writes the ownership binding and audit event, and no enrollment moves to another Identity. Only a device without its own claim, active attestation, or binding may self-bind. Switching to another Identity still requires a current Identity with no account link.
| Route | Signed input and result |
|---|---|
POST /v1/auth/rebind-attempts |
Exactly group_id, identity_id (the device's current Identity), attempt_id, browser_start_hash, loopback_redirect and review_digest. browser_start_hash is the lowercase SHA-256 hex of the decoded 32-byte start code, as for link attempts. The device keeps the raw code and gives it only to the browser. review_digest is the lowercase SHA-256 hex of the device's local review. Returns attempt_id, expires_at_ms, and the coordinator_id, origin, issuer and auth_config_revision that the final signature covers. |
GET /v1/auth/rebind-attempts/:attemptId?group_id=... |
Only the device that started the attempt can read it. A missing attempt gets the same refusal. Returns state, expires_at_ms, target_identity_id and the receipt once finalized. target_identity_id stays null until the person confirms in the browser. |
POST /v1/auth/rebind-attempts/:attemptId/finalize |
Exactly group_id, the raw completion secret and signature. Returns state (finalized, or prior_commit for a repeat) and the receipt. |
After the person confirms, the browser returns to loopback_redirect with
attempt_id, completion and confirmation. If the Google callback refuses
the attempt instead, for example because the account has no link, the browser
returns with attempt_id and refused=<reason> so the device stops waiting.
A refusal there grants nothing; the worst a forged one can do is end the wait. confirmation is the hash of
what the person confirmed. The device signs, with its enrolled key, the UTF-8
bytes of this JSON array:
["codemem-device-rebind-finalize-v1", coordinator_id, origin, auth_config_revision,
attempt_id, device_id, public_key, key_id, fingerprint, issuer, current_identity_id,
target_identity_id, review_digest, confirmation, completion]The pins come from the start response and target_identity_id from status.
The browser steps are:
- The device opens
/auth/rebind/start?attempt_id=...&start_code=.... The page submits itself, and its form post claims the attempt for that browser and continues to Google. - After Google sign-in, the shared callback shows the confirmation page. It lists the device, its key fingerprint, the current and new Identity, and the Teams the new Identity cannot access yet. The person's chosen account must already be linked to an Identity.
- Confirming posts
/auth/rebind/confirm. The response is a page that returns to the loopback address. It is a page rather than a redirect because the form policy only allows posts back to the coordinator.
A refusal at any browser step shows a short page that names the problem and the
next action. Rebind does no storage work while browser authentication starts.
On each use it first checks for its tables. Until they exist, its pages report
not found and the callback ignores rebind.
key_id is the device key's canonical Ed25519 key ID. signature is the
unpadded base64url Ed25519 signature.
The receipt contains attempt_id, binding_id, device_id,
previous_identity_id, target_identity_id, enrollment_count and
finalized_at_ms.
A refusal returns { "error": "device_rebind_refused", "reason": ... }. Its
status is 409 when the reason is retry, which the device may repeat as is.
Every other reason returns 403 and needs the person to act:
unavailablecurrent_identity_linkeddevice_already_boundaccount_not_linkedalready_this_identitydaily_limit
An unknown outcome returns 503 device_rebind_unknown. The device then reads
status before it tries again. It never repeats the step blindly.
Before a request reaches the ceremony, it can also get these responses:
| Status | Body | Cause |
|---|---|---|
400 |
device_rebind_invalid_input |
Missing, extra or malformed fields |
413 |
body_too_large |
Body over 4 KiB |
401, 403 or 409 |
device_rebind_unavailable |
Wrong origin, or the request signature failed |
429 |
rate_limited |
Request rate limit |
503 |
Plain text, or coordinator_auth_schema_upgrade_required |
Browser authentication is configured but unavailable |
A 403 with device_rebind_unavailable is an admission failure. A 403 with
device_rebind_refused comes from the ceremony.
GET /v1/auth/account-link-status?group_id=...&coordinator_origin=...&identity_id=... reports the
signed device's own account-link state. It requires the ready browser-auth adapter, not merely the
legacy link-attempt option; absent or unavailable browser auth returns 503.
Requests must use the configured callback origin, contain exactly one group_id
and one coordinator_origin and one identity_id query parameter and no body, and pass existing
enrollment, signature, nonce and rate-limit checks. The signed coordinator_origin must equal the configured
callback origin, preventing another coordinator from forwarding the request to
read account metadata. Responses are never cached.
The version-2 response includes coordinatorId, groupId, deviceId,
fingerprint, identityId, state, authority, and account. Review-required
and conflict responses contain no Identity, authority or account metadata.
Linked responses allow only an optional display name and verified email;
provider subjects, proofs, tokens and credentials are not returned.
The read rechecks current controller, enrollment and revocation evidence before returning metadata. Revoked links remain tombstones and are not replaced. Apart from normal request nonce admission, this operation changes no accounts, reviews, enrollments, grants, history or ownership. It does not advertise or introduce a requirement to use Google for legacy coordinator requests.
The account-link feature is unreleased, so version 2 replaces version 1 with no compatibility negotiation. The server implements this contract; the matched client/runtime and viewer changes must land before release or activation. Ordinary signed-device sync routes do not change.
Requests carry exactly three query parameters: group_id, coordinator_origin,
and identity_id. identity_id is the running profile's current Identity. It
is not secret, but it hints at a person, so the server treats it as a question,
never as a selector or an authority. It may appear in platform request logs, as
group_id already does.
{ "version": 2, "coordinatorId": "...", "groupId": "...", "deviceId": "...",
"fingerprint": "...", "identityId": "...", "state": "unlinked",
"authority": "device_claim", "account": null }authority names the link's source, or the source a new link would use. The
server evaluates these rules in order:
| Condition | state |
identityId |
authority |
|---|---|---|---|
| The device's committed device claim and its unique active attestation name different Identities | conflict |
null |
null |
| The device ID or key has a committed device claim, and the exact current enrollment still agrees with it | linked or revoked |
Claim Identity | device_claim |
| The device ID or key has a committed device claim that no longer agrees (new key, assigned to another Identity, or archived or disabled) | conflict |
null |
null |
| One active attestation matches the exact enrolled key | unlinked, linked, or revoked |
Attested Identity | controller_attestation |
The eligibility guard passes for identity_id |
unlinked |
identity_id |
device_claim |
| Anything else | review_required |
null |
null |
An unlinked attestation for a different requested Identity reports
review_required, with null Identity and authority. An existing linked or revoked
attestation still reports its bound Identity, with account metadata masked when
the requested Identity differs.
For device_claim, unlinked means only that this device may start an attempt
for that Identity. Nothing is verified, reserved, or linked until the same
operation's device finalization commits after Google verification. Clients must
not describe it as a Google-verified or linked state.
Account metadata appears only for this device's own link, and only when the link
Identity equals identity_id; otherwise account is null. A requested Identity
that belongs to someone else gets a generic review_required, never that
Identity's email, Google metadata, link state, or authority. The read is
read-only: it creates no attempt, claim, attestation, grant, or link, and it
never reports a device claim as an operator review.
First-link availability reveals one bit: a signed enrolled device can distinguish an eligible requested Identity from one it cannot claim, including because of an existing coordinator footprint. This disclosure is accepted for first-link readiness; it does not reveal which record caused a denial.
Every request counts against the existing authenticated-device quota, whose key must not depend on the requested Identity. There is no bulk lookup or detailed denial endpoint.
The Node client readCoordinatorAccountLinkStatus uses an existing-file read-only
device snapshot and the existing matching signing key. It performs no database
maintenance, schema initialization or key generation. Read-only means no SQL,
schema, key or history changes; SQLite may create or update its normal WAL/SHM
coordination files, so the profile directory may need to be writable. Reads use
committed WAL data rather than an immutable or main-file-only snapshot that could
miss a key change. Requests use manual
redirect handling and a ten-second header/body deadline; response bodies are
limited to 16 KiB. The matched client must validate the exact version-2 shape and
requested group/device/fingerprint tuple, copying only allowed display fields.
The old decoder must be replaced before release, without a dual-version fallback.
The store API is readAccountLinkStatus(signer, config, { identityId }) on both
SQLite and D1. Its final SQL statement reads source authority, current enrollment,
tombstones and display metadata together. Committed device claims use historical
proof, while the current signed enrollment must still match. Create passes the
trusted optional expectedIdentityId pin for either authority kind; the atomic
store checks repeat the source and Identity constraints, not just HTTP readiness.
Failures return fixed unsupported/unavailable results, never an inferred unlinked or Google-required state. Embedded callers must still match the reported Identity to their current local Identity, pin the coordinator when starting a link, and recheck their current context before displaying account metadata or acting on it.
| Route | Condition | Response |
|---|---|---|
| Create or finalize | The authenticated, enabled enrolled signer has a verified signature and nonce, but its stored controller review is absent, inactive, or no longer matches, including concurrent enrollment or group changes. For a device-claim request or attempt, the eligibility guard fails instead. | 403 auth_link_review_required |
| Create | The attempt ID conflicts with a different immutable create request | 409 auth_link_conflict (attempt conflict only) |
| Create | The requested identity_id or authority no longer matches the server's evaluation |
403 auth_link_review_required; no attempt is created |
| Finalize | A matched finalization conflicts with an existing account-link binding, including a revoked tombstone | 409 auth_link_conflict |
| Any signed route | Bad signatures or proofs, disabled enrollment, archived group, expiry, unavailable attempt, or another failed guard | Existing generic response; do not reveal controller or account-link state |
The 403 applies only after the signed request reaches the controller check.
Only the finalization 409 auth_link_conflict indicates an account-link conflict;
the same label on create is an attempt conflict. Clients must match the request
stage, status, and exact error label, not infer a binding from any 409 response.
Neither rejection identifies an account or Identity, grants access, or replaces a link.
A failed device-claim guard never says which check failed.
All mounted device-link responses, including errors, use Cache-Control: no-store.
Custom signature headers do not provide the caching protections of a standard
Authorization header; every status poll must reach the signer check and current
attempt state instead of a cached response.
The device pins the coordinator and Identity from creation, then signs polling, finalization, and cancellation with its existing key. Expiry-response validation allows the existing five-minute signature clock tolerance; the device still waits at most ten local minutes, and the coordinator enforces its own attempt deadline. Neither a status response nor a loopback proof alone reports success: the device requires its own proof-bearing finalization response. A lost final reply may be retried once with a fresh signed nonce.
Embedded callers can supply expectedIdentityId, expectedCoordinatorId, and
assertCurrentContext to linkCoordinatorAccount. Expected IDs and callback types
are validated before local access. A context callback must return true; a false
result, thrown error, or rejected promise produces context_changed. The callback
runs before local access, attempt creation, private browser-link delivery, and each
finalization POST, including retries. Waiting for the callback honors caller
cancellation and the link's ten-minute deadline, including the first check before
local access. A late callback cannot release a browser link after cancellation.
Creation replies with a different expected
Identity or coordinator produce identity_mismatch before the browser link is
released. These checks do not replace the existing loopback and final-response
proofs; omitted options keep proof and result verification unchanged. Cancellation
is checked before creation and browser-link delivery even without a context hook.
If cancellation cannot be confirmed after a mismatch or context change, the result
is cancellation_unconfirmed. Callers must not report that nothing changed when a
finalization response or cleanup outcome is uncertain; read authoritative account
status before offering another linking attempt.
captureCoordinatorBrowserAuthOptions captures server-owned configuration, an
already-imported independent CSRF key, and optional SDK transport settings.
It returns an opaque handle: secrets live only in a private lookup, so logging
or serializing the handle exposes no credentials. Copied or forged enabled
handles cannot build handlers or run maintenance.
createCoordinatorBrowserAuth builds all existing handler sets before mounting
anything. A disabled setup leaves auth routes at 404; an invalid or failed
explicit setup returns fixed, uncached 503 responses on auth paths while
leaving legacy routes available.
An explicitly configured Worker keeps one browser-form limiter and one device request limiter. It pins the first D1 binding and rejects a different reference instead of mixing stores or resetting quotas. Each cold request owns its setup I/O; only completed results cross requests, and warm requests reuse them. Concurrent cold requests may repeat discovery, but share the captured key, configuration, and quota state. Local tests exercise separate native HTTP request contexts; they do not guarantee production binding-update behavior.
When optional auth is enabled, the shared device limiter also enforces the existing limits on legacy routes across requests: 120 reads and 30 mutations per device per minute, per isolate. Default and disabled Workers keep their existing behavior. The first completed setup result is cached even if setup fails; a transient cold-start discovery failure requires a new Worker instance before auth becomes available, while legacy routes remain usable.
maintainCoordinatorBrowserAuth runs existing attempt maintenance, browser
retirement, sign-in transaction purge, guarded session purge, and guarded
receipt purge in that order. Each stage gets one to eight batches (default four)
with its existing row limit; failures stop the run without exposing raw causes.
Historical controller, account-link, link-attempt, and audit records are not
deleted. The optional Worker schedules this work with ctx.waitUntil; no cron
trigger or live cleanup is enabled by this change.
Before enabling a live cron, add sanitized maintenance-result reporting and
check the batch budget against the deployment's D1 query allowance.
Changing configuration or the CSRF key requires a new reviewed Worker instance. Before disabling auth, explicitly retire pending provider material; an in-flight request may still require a follow-up retirement pass. Review platform logging for private URL, Referer, and body exposure before enabling a live pilot.
The runtime signs a finalization POST for the same attempt. It presents the
original runtime verifier and the browser-completion secret; the coordinator
matches them to that attempt's expected commitment and secret hash, then rejects
reuse. The existing request signature covers method, path/query, timestamp,
nonce, and the exact body digest. The finalization body must include purpose
coordinator-account-link-v1, coordinator ID, attempt ID, group ID, actor ID,
device ID, fingerprint, and both proofs. Compare every field to server-owned
attempt state. The signed attempt ID binds the provider account: only the
coordinator holds the verified issuer/subject, frozen at oidc_verified and
confirmed by the browser. The runtime neither supplies nor learns those claims.
The canonical signature does not sign the origin; explicit purpose and
coordinator ID in the body prevent cross-coordinator replay.
Attested linking requires an active controller attestation for the initiating key, created
only by an admin-authenticated ownership review in this slice. The configured
review-route source and tests are ready, but publishing that route, applying its
schema to a live deployment, and enabling Google remain separate approvals. A
legacy caller-provided recipient_actor_id, enrollment
identity_id, invitation claim, or request label cannot authorize linking. The
planned device-claim path treats the local Identity only as a claim that its
guard checks, never as authority on its own.
Persist controller attestations with coordinator ID, existing actor ID, group ID,
device ID, exact stored public key/fingerprint, authority-source receipt ID,
revision, creation time, and revocation time. They add no parallel Identity registry.
The controller storage contract defines
this first persistence slice; browser and account/session routes remain separate.
Legacy enrollment rows begin with no attestation; do not backfill caller claims
as trusted evidence. An authenticated coordinator admin explicitly reviews the
existing actor/device binding and records a server-computed evidence digest.
Authority comes from the configured admin credential, not caller-supplied audit
actor labels. The admin request supplies the existing actor ID; only an explicit
confirmation carrying the preceding preview digest can create the record. A non-null
enrollment identity_id must equal that actor ID; reject a mismatch. If it is null,
the reviewed admin attestation is the actor source and records that fact, without
rewriting enrollment. Later null-to-different-actor changes invalidate the proof.
Admin credential checks use constant-time digest comparisons, never browser
session or actor-label claims as admin authority.
Additional controllers require a single-use signed approval from an active attested controller, bound to the exact replacement/additional key and Identity; this optional link slice does not enable that enrollment or recovery operation. For linking, read the active attestation for the initiating enrolled key and keep its receipt ID/revision in the attempt. A changed key, disabled enrollment, archived group, revoked attestation, or mismatched Identity invalidates it. An unattested legacy device either uses an eligible device claim or waits for admin review. Neither its signature nor a Google account alone creates a controller, and a device claim never creates one.
Status: Internal Core readiness, attempt creation, browser transitions, and atomic device-claim finalization for the claim, account link, and creation audit are implemented. The device-binding resource below adds ownership completion. Its internal receipt and provenance checks remain part of finalization; they do not expand the public response. Device-authenticated status requires a current enrolled, unrevoked key: the original key or an exact shared assignment as described below. Finalization retries still require the original key's current enrollment and no device/key revocation.
Core account-session issuance, redemption, sign-in, profile/account reads, and operator link revocation accept device-claim links only with coherent retained attempt, immutable proof, link, and creation-audit provenance. They do not require the original device to remain active. Session/profile checks still require an active link and current session/configuration pins. Revocation preserves the proof and link tombstone and records the original authority kind in its audit. Owner enrollment and privileged invite/grant proofs remain attestation-only, even if the device-claimed Identity later receives grants.
External creation remains attestation-only; device-claim HTTP mode, external routes, and Viewer support are not enabled by these internal Core operations.
A device claim lets an enrolled device link Google to the running profile's current Identity without a separate admin review. A matching enrollment Identity is reused; an unassigned legacy enrollment uses trust on first use.
The coordinator learns that the holder of the enrolled key and the holder of a verified Google account agreed on the same Identity during one attempt. It cannot see who unlocked the device.
An Identity with no coordinator footprint is first-come under this trust model. An administrator can revoke a mistaken link, but revocation preserves its tombstone; reclaiming or transferring that Identity requires a separately approved recovery policy, not another first-link attempt.
The user's explicit Link Google action, with the current Identity pinned, plus the normal Google consent and confirmation pages, is the claim confirmation. There is no extra checkbox or admin step. The confirmation page names the Identity and device as it does today, and says that this link comes from the device key and Google sign-in, not an admin review.
A device claim yields Google sign-in for that Identity. Its first qualifying finalization also creates the canonical ownership binding for that same device, as described below. It creates no controller attestation, Identity-group grant, key, scope, or owner-enrollment right, and it cannot authorize another device or owner enrollment even if grants for the Identity appear later. Upgrading a device-claim link with stronger proof is out of scope.
Each row states its authority source in an authority_kind column and refers
only to the record of that kind:
| Table | controller_attestation row |
device_claim row |
|---|---|---|
coordinator_auth_link_attempts |
Attestation ID, review receipt ID, and revision 1 | All three controller columns null. identity_id holds the pending claim. |
coordinator_auth_device_claims (new) |
Not used | One immutable row per committed claim |
coordinator_auth_account_links |
controller_attestation_id set, device_claim_id null |
device_claim_id set, controller_attestation_id null |
coordinator_auth_link_audit_log |
Same as account links | Same as account links |
A CHECK constraint on each table enforces that pairing. Readers also check it
through readAuthLinkAttemptAuthority and readAuthLinkRecordAuthority in
coordinator-auth-link-authority.ts, and treat any mismatch as no authority. The
kind is server-owned: no transition changes it, and the request only states
which kind the client expects.
The readers validate row shape, not ownership or caller permission. Link creation and in-flight transitions resolve their source and rerun its live eligibility checks. A kind string alone never authorizes an operation.
After finalization, the source fields are immutable historical provenance. Consumers of an existing link validate the active, unrevoked link and that provenance, plus their own current session, grant, or key-possession requirements. They do not require the link-creation controller, device, or signer to remain active, enrolled, reachable, or unrevoked. Current device-authenticated requests still authenticate their present signer; that is not a reauthorization of the link's historical source.
New writes must name their authority kind explicitly. There is no default that silently turns an omitted kind into administrator-reviewed authority.
coordinator_auth_device_claims is the committed proof. It stores the
coordinator ID, claim ID, Identity, group ID, device ID, exact public key and
fingerprint, canonical key ID, the link attempt ID, issuer, auth-config revision,
and creation time. It is unique per coordinator on Identity, device ID, key ID,
and attempt ID, so each device, key, and Identity can hold at most one committed
claim, ever. Triggers reject updates, deletes, and replacing inserts, following
the device-ownership bindings. Link revocation leaves the claim in place as a
tombstone.
Account links keep their existing uniques on link ID, issuer plus subject,
Identity, and attempt ID, including revoked rows, and add one on
device_claim_id. References are logical, as elsewhere in the coordinator
schema: there are no declared foreign keys, because local SQLite stores do not
enable them. The finalization batch writes the references together, and readers
join on them exactly. Comparisons on nullable reference columns use IS, not =.
The controller attestation table is unchanged, and no attestation row is ever
written for a device claim. Attested authority queries also require the attempt's
authority_kind = 'controller_attestation'.
The reviewed leaf source is packages/core/src/coordinator-auth-link-authority-upgrade.ts.
The operator command calls its explicit transaction; store opening never runs it.
It is not installed in the Worker's migration directory. SQLite and native D1 fixture
tests exercise the same copy/check/swap SQL, including rollback at each swap.
It rejects unknown schema and dependencies rather than rebuilding them silently.
Fresh auth storage now uses explicit authority kinds. HTTP link creation still
accepts only controller attestations; internal Core link and account-session
operations also support device claims as described above. Fresh SQLite
auth storage installs the committed-proof table without enabling external
device-claim creation.
Owner enrollment requires an attestation-backed link without rechecking its old
controller or device. Existing auth storage is not rebuilt on open, and ordinary
device-signed sync remains unchanged. Optional auth on an old schema returns
503 coordinator_auth_schema_upgrade_required, with operator instructions, before
querying the new columns or starting provider discovery. The fixed
X-Codemem-Auth-Error header carries the same code to CLI and Viewer clients.
Disabled auth stays disabled.
No authority upgrade is automatically applied through the Worker migration directory.
Do not deploy new auth writers against a legacy schema. Stop admitting new link attempts, drain or expire existing attempts using the existing deployment, then pause all optional-auth writers. Back up SQLite; for D1, take a Time Travel bookmark and export. Keep ordinary device-signed sync separate from this auth pause.
If new auth writers were deployed prematurely, restore the previous deployment before draining attempts; the new schema check also refuses legacy maintenance.
For an existing local coordinator database, explicitly run:
codemem coordinator schema upgrade-auth --db-path coordinator.sqlite --json
This opens only that existing file, validates the recognized legacy schema and retained provenance, then commits the three-table copy/check/swap and proof schema in one immediate transaction. A failed assertion aborts everything; do not repair references, delete history, or invent controller IDs to force it through.
For D1, export the exact reviewed source without opening any database:
codemem coordinator schema upgrade-auth --export-sql > coordinator-auth-upgrade.sql
The file targets Wrangler's remote SQL-file import (wrangler d1 execute --remote --file), which requires separate operator approval and confirmation of the
deployed import service's atomicity. No remote import is tested or run here.
The native D1 tests prove the programmatic path: execute the exported
AUTH_LINK_AUTHORITY_CUTOVER_STATEMENTS array in one db.batch() while auth
writers remain paused. The file contains complete proof triggers; never split
it on semicolons.
Do not replay the historical migration directory on a deployment without a ledger.
The export command performs no remote execution and grants no rollout approval.
Before resuming auth writers, verify row counts and every preserved column, proof immutability, and an enabled auth request against the upgraded database. Deploy the matching writers as part of that controlled cutover; restart SQLite auth composition if it was stopped during setup. Device-claim creation remains disabled.
The three legacy link tables declare controller_attestation_id as NOT NULL, and
SQLite cannot relax that with ALTER TABLE. The schema change therefore rebuilds
those tables by creating a replacement, copying every row with
authority_kind = 'controller_attestation', checking row counts, swapping names,
and recreating indexes. The proof definition matches the separate additive migration
0032_device_claim_proofs.sql; the explicit cutover also installs that leaf schema
in the same transaction or D1 batch. Existing rows keep every value.
The copy must contain executable assertions, not merely SELECT results. Create
a migration-owned check table with passed INTEGER NOT NULL CHECK (passed = 1)
and insert each count and full-row equality result as 1 or 0 before any table
swap. A mismatch inserts 0, fails the constraint, and aborts the transaction.
An unexpected pre-existing check-table name also aborts; it is never cleared or reused. Remove only that migration-owned table after all assertions succeed.
This work ships migration code and local tests only. Nothing is applied to a live D1 database or a user's database without a separate approval. Before any live apply:
-
Pause affected auth writers, require live link attempts to finish or expire, and recognize the exact legacy schema that supported only attestation-backed links. Run read-only preflight queries for tables, dependencies, and row counts, and validate every reference with
isAuthControllerId.Existing completed links retain their known legacy authority kind and immutable historical tuple; their original controller need not still exist. Retained source or audit records must agree with that tuple, including receipts, revisions, coordinator, Identity, and attempt references where present. Expired or failed attempts confer no authority and keep their historical references; revoked records are also preserved.
An unknown schema, malformed references, inconsistent retained provenance, or any remaining live attempt aborts with an actionable operator report. Known historical provenance is not a default for new writes or a reconstructed operator attestation. Never drop, repair, or silently promote a bad row.
-
Take a D1 Time Travel bookmark and an export.
-
Pause affected auth writers and apply only the reviewed upgrade file through a verified atomic execution path. Do not assume a migration ledger or replay the existing migration directory.
-
Re-check row counts and compare every preserved column in every existing row, using exact projections or deterministic hashes rather than sampling.
An existing SQLite coordinator store also requires an explicit upgrade. Opening a store or reading account status must never rebuild its tables automatically. Fresh stores use the new schema directly; old stores fail with an actionable upgrade-required result until an operator runs the reviewed migration.
Cloudflare documents transactional rollback for Worker db.batch() and failure
recovery for remote SQL-file import. Wrangler 4.127.1 uses the D1 import service
for remote execute --file; it does not apply that file one statement at a time
through its command-query path. The exact copy/check/swap file must still pass
non-live D1 runtime tests, including injected failures before and after each
table swap and deliberately failing the check-table assertion, before those
guarantees are used for a rollout. That assertion failure must roll back the
entire import, including all previously executed copy and DDL statements.
The D1 file has no explicit BEGIN or COMMIT; D1 owns that transaction. A local
SQLite upgrade uses an explicit immediate transaction. Neither path may rely on
PRAGMA foreign_keys = OFF: D1 enforces foreign keys, and deferring checks does
not suppress cascading deletes. Preflight and fixtures must cover referenced
tables, triggers, indexes, and views, as well as row contents.
The old auth writer omits authority_kind, while the new schema requires it.
The code/schema cutover therefore needs a coordinated writer pause, not an
implicit administrator default or a compatibility shim. Time Travel restore is
a separately controlled recovery operation that overwrites current data, not a
routine rollback step.
References: D1 batch transactions, remote SQL-file execution, D1 foreign keys, and Time Travel.
The coordinator and viewer ship together. Ordinary signed-device sync routes, enrollments, keys, and memory history do not change.
The internal readAuthDeviceClaimReadiness store method now evaluates the shared
guard in coordinator-auth-device-claim-eligibility.ts on SQLite and D1. It reads
only and returns no account metadata or committed proof. Existing external status
and creation contracts remain unchanged. Trusted Core creation now explicitly
names authority.kind; device_claim also pins the current local Identity and
requires a browser-start commitment. Its atomic INSERT uses this same guard and
the existing quotas; exact retry rechecks both the full tuple and live eligibility
without extending its deadline. The canonical key ID is derived from the pinned
public key, not stored as a new attempt column. Creation and eligibility reads
create no committed proof; only successful finalization commits one.
Current HTTP creation still selects only controller_attestation. Trusted Core
device-claim status, browser-start claim, browser exchange and resolution, OIDC
verification, confirmation, and pre-finalization destination reads now recheck
the shared eligibility guard inside the winning SQL statement. Each operation
captures fresh key aliases and pins the stored source, target, config, and
commitments; a stale capture fails closed rather than bypassing the guard.
Original device or browser cancellation proof can release an uncommitted
attempt even after enrollment changes. Time-based maintenance also expires these
attempts without requiring live enrollment and clears their account subject;
neither operation creates ownership proof or removes retained quota history.
Trusted Core finalization now atomically consumes the confirmed attempt and
commits its immutable device proof, account link, and audit. Exact retries read
the retained receipt without reinserting proof. Core sign-in, sessions, profiles,
and operator revocation validate the committed device provenance; device proof
still supplies no owner-enrollment or privileged grant/invite authority.
External device-claim creation remains disabled until the matched status,
server/client, and Viewer contracts and complete-flow tests are ready.
One guard decides whether a device claim may exist. The coordinator runs it as a single SQL statement that binds its inputs once through a CTE, keeping at most 100 bound parameters. For device-claim attempts it runs at status reads, creation, creation retries, the browser start-code claim, OIDC verification, confirmation, the completion-destination read, and finalization. Attested attempts keep their current checks. A failed guard fails closed and never says which check failed.
"Target" means the signer's exact enrollment: group, device ID, public key,
fingerprint, and canonical key ID. "Claim" means the requested Identity. "Live"
means an attempt in pending, browser_claimed, oidc_verified, or confirmed
before its deadline, other than the attempt being checked.
| Subject | The guard denies when |
|---|---|
| Target enrollment | It is missing, disabled, in an archived group, or has a non-null identity_id that differs from the claim |
| Target revocation | A coordinator-wide revocation exists for the device ID or key ID |
| Other enrollments of the target device or key | Any has a non-null identity_id other than the claim |
| Attestations for the target device or key | Any is revoked or names another Identity. An active matching attestation uses the attested path instead. |
| Ownership bindings for the target device or key | The bound Identity is not the claim |
| Consumed invites bound to the target device, key, or fingerprint, unless revoked before consumption | assigned_identity_id or target_identity_id is set to another Identity |
| Legacy team completions | A device decision for the target is excluded, or includes it with another Identity as recorded ownership (not a label) |
| Owner-enrollment attempts for the target | One is live |
| Device claims for the target | A committed claim exists for the device ID or key ID |
| Link attempts from the target | A live device-claim attempt names another Identity |
| Account links for the claim | Any exists, active or revoked |
| Device claims for the claim | A committed claim exists |
| Enrollments naming the claim | One belongs to another device or key without the exact same-record peer assignment described below |
| Attestations naming the claim | One, active or revoked, belongs to another device or key (identity_id or enrollment_identity_id) |
| Ownership bindings naming the claim | One belongs to another device or key |
| Identity-group grants for the claim | Any exists, active or revoked |
| Invites naming the claim | An unrevoked invite assigns or targets it and is still pending, or is bound to another device or key |
| Invite Identity footprints | recipient_actor_id names the claim and is bound to another device or key, or inviter_actor_id names the claim and inviter_device_id is another device |
| Legacy team completions naming the claim | Another device/key names it as recorded ownership (not a label) without the exact current assignment described below; the current device with another key, or current key under another device, always denies |
| Owner-enrollment attempts for the claim | One is live |
| Link attempts for the claim | A live attempt of either kind comes from another device or key |
These are not contradictions, and must not block a claim:
- records about other people in the same group or Team;
- unrelated labels and display names that do not name the claim; a claimed Identity tied to another device is ambiguous without an exact current assignment;
- expired, cancelled, failed, or retired attempts of either flow, including attempts that reached OIDC verification;
- invites that expired or were revoked before being consumed;
- legacy completion memberships that neither name the claim nor assign it to another device or key;
- evidence that agrees with both the target and the claim;
- legacy completion labels, described below.
A completion's included device decisions may carry identity_evidence. self
marks the uploading device's own assignment. label marks that device's guess
about another device. A manifest either omits the field everywhere, as older
manifests do, or marks every included decision, with at most one self. A label
keeps the device's Team inclusion, but it is never ownership evidence. It cannot
form an exact assignment, admit a shared-Identity peer, suggest an owner in
review, or deny another device's claim. A new upload's labels are still checked
against recorded ownership, so a guess cannot contradict it. Decisions without the
field keep their earlier meaning until an ownership binding supersedes them.
An exact assignment is an included, enabled, non-label decision matching the current device,
authenticated fingerprint and Identity in the canonical completion selected by
legacyTeamCandidateId(coordinator, group). Another coordinator's record cannot
shadow it. Core validates the full manifest, exact canonical JSON, and stored
candidate/version/completion metadata. Malformed evidence supplies no exception.
The winning SQL rechecks the captured bytes, scope, current enrollment and
revocations. A captured assignment cannot disappear during the operation, even
when that would remove the last footprint; a fresh operation may recapture it.
Only other included, enabled decisions with different fingerprints in that same record may avoid the legacy contradiction. A peer enrollment naming the Identity must also remain enabled in the current group and match its recorded device, fingerprint and Identity, with a canonically different Ed25519 key. Key aliases never qualify. All other eligibility checks stay in force; this grants no controller, owner-enrollment, invitation or grant authority.
An exactly assigned peer may read the Identity's linked/revoked status through
its historical attempt/proof/link/creation-audit chain. The origin key may differ;
the origin device or controller need not remain active. Current-requester
ownership/enrollment mismatches, revoked or other-Identity attestations in any
group/key alias, consumed other-Identity invitation assignments and committed
other-Identity link history still deny access. Token revocation or retired
controllers do not erase those pins. Such a peer sees linked with account: null:
profile details (name, email, picture) go only to a requester whose own claim or
attestation is the proof (ADR 0005 Section 8). The resolved-account-owner status
likewise reports profileState: "linked" with an empty profile, because its reader
runs as another Identity. Guessed Identities expose no metadata, and
this read never permits another binding or replacement of a revoked link.
The local Identity comes from the current profile context, whether config or environment selects it. The viewer and runtime pin that context and stop on any change; the coordinator does not treat one local source as more trustworthy than the other.
A pending claim is only a link attempt with authority_kind = 'device_claim'.
It pins the device key and claimed Identity before Google starts. It uses the
normal ten-minute deadline, quotas, start code, runtime verifier, and
cancellation, and it reserves nothing after it ends. Creation is a single
INSERT … SELECT that includes the guard, so competing live claims serialize
there. An exact retry with the same attempt ID, key, Identity, and commitments
returns the existing attempt.
Expiry, failure, and cancellation leave no claim record and no link. After any of
them, including cancellation after OIDC verification, the device may start a new
attempt once the guard passes again; the existing per-device quotas still apply.
link_id stays null until finalization.
Finalization commits in one SQLite immediate transaction or one D1 batch:
- Consume the attempt, with its proofs and the guard.
- Insert the device-claims row from the consumed attempt.
- Insert the account link that references that claim.
- Insert the redacted audit row.
- Insert the canonical ownership binding when this is the device's first qualifying claim, and reconcile the matching enrollment rows as one set.
A unique conflict on the claim (Identity, device, or key) or on the link
(Identity, or issuer plus subject) rolls back the whole batch and returns
409 auth_link_conflict. Only this committed claim, link, and binding use up the
first claim. If the outcome is unknown, read status before offering another
attempt.
GET and POST /v1/auth/device-binding use existing signed-device admission and
strict group_id plus current identity_id inputs. GET returns one kind:
ready, complete, requires_review, or connect_required. POST returns
complete or requires_review; Core maps its internal completed result to
complete. The resource exposes no claim, audit, or receipt fields. Definitive
authentication, schema, rate, and conflict errors fail closed. A route 404 is a
typed unsupported result that can cover an older coordinator, disabled account auth,
or an unavailable backend.
The normal Viewer action makes one GET. connect_required starts Connect this
device, the existing rebind ceremony with fresh Google proof for the same current
Identity, browser consent, review digest, and final device-key signature. A
controller-only legacy device never gains ownership from a GET. The device-binding
POST completes claim-backed ownership only; the controller path uses the fresh
rebind ceremony.
Automatic completion posts directly after maintenance has a fresh initial
enrollment snapshot. An enabled enrollment in the current group with the exact key
and fingerprint is a healthy skip condition only when both
ownership_identity_id and identity_id equal the current Identity. A null or
unowned initial row may POST under the internal authority guards. An explicit
Identity row without canonical ownership posts, including when the null update
count is zero. For that explicit Identity row, a requires_review answer keeps the
earlier baseline and reconciliation continues. A null own row that receives
requires_review stops that group before invites. A missing own row keeps the
existing operator baseline. Only the POST path requires a coordinator URL that the
account-link origin check accepts. When that check rejects the URL, or the
coordinator answers that it has no completion route, the coordinator state is
unchanged and every row keeps the earlier baseline.
The first device claim keeps the full eligibility guard before OIDC and at the
winning commit. It atomically writes the immutable claim, link, audit, canonical
ownership binding (owner_enrollment, device_claim:<claim_id>), and permitted
null enrollment updates. It preserves Identity, grants, key ownership, and history
outside that device's binding. Existing same-Identity devices and intended grants
remain valid. The internal receipt and compare-and-set checks make a later matching
request idempotent. The coordinator validates immutable provenance and the
internal receipt before and after the atomic batch.
POST uncertainty permits one status GET. complete resolves that outcome. A
complete status is not authority: maintenance continues only after a fresh
listing shows the exact key with identity_id and ownership_identity_id both
equal to the current Identity. A current schema-unavailable result preserves the
upgrade guidance. Other unverified
results are unknown and must not trigger an immediate POST retry. A later background
tick may make an idempotent request after it captures a new context and enrollment
snapshot. The absence of a fresh canonical listing does not prove that an earlier
request failed.
No persistent recovery workflow or registry records the unknown result.
Self-bind authority is server-derived. The winning rebind start stores immutable
start_authority_json, then the browser claim, callback, confirmation, and commit
recompute its link genesis, exact current controller, enrollment set and null
count, issuer, and configuration. Account mismatch, drift, revocation, competing
key, alias, or exclusion denies the ceremony. Migration 0044 adds this field;
pre-migration readiness fails closed, while historical null attempts keep their
original rules. Existing rebind receipts and client review-digest handling remain
unchanged. This adds no table, wizard, flag, or registry.
Bare device claims must never pass as operator approval:
- Owner enrollment accepts attestation-backed links, or the exact explicitly qualified device-claim link described below. Independent grants alone never qualify a claim. Resolved attempts pin the original controller reference XOR the server-resolved qualification ID; they never rewrite link authority.
- Identity-group grants come only from active attestations or consumed
team_memberinvites. A device-claim link is never accepted as the account-ownership proof for either path. - The separate existing-Identity invitation-acceptance protocol must require an attestation-backed link before it can use that link as account-ownership proof. Until that protocol is implemented and reviewed, device-claim links are denied by default. Existing device-signed legacy invite flows are unchanged.
- Admin review, attestation revocation, and link revocation stay admin-authenticated. Link revocation works on both kinds and keeps tombstones.
- Status and audit output never present a device claim as a review.
The coordinator_owner_enrollment_qualifications table and migration
0035_add_owner_enrollment_qualifications.sql support a narrow owner-enrollment
exception only after explicit schema installation and genuine reviewed issuance.
The existing owner consumer accepts qualified links only with the canonical ledger,
complete connection-history schema, and migration 0036 owner-attempt column and
authority guards. The configured Worker mounts the authenticated
preview/issue route
used by codemem coordinator owner qualify only with valid browser authentication
and owner enrollment enabled. Neither performs automatic migration or store-open
installation. Default Workers and local serving cannot issue qualifications.
Bare device claims and all other privileged consumers remain denied
by default. See the first-owner A/B recipe
for the separate review, qualification and transport-grant steps.
Issuance must resolve the exact committed link and immutable device claim on the server, then resolve a genuine operator-reviewed controller attestation for that Identity and source device/key. Request labels, row types, enrollment labels and SQL checks are not evidence. Issuance must snapshot the real attestation ID, revision and original creation text, review receipt and evidence digest, nullable enrollment Identity, source group/device and exact key facts, plus the exact link's creation attempt, configuration revision and creation time. The qualification stores no subject or email: the exact link ID and claim facts identify the account provenance for later server-side resolution. It must not rewrite the original link's authority kind or claim.
The internal OwnerEnrollmentQualificationOps capability exposes preview({attestationId}),
issue({attestationId, expectedDigest}), and revoke({qualificationId, linkId}).
Its trusted constructor captures coordinator, issuer, configuration revision and enabled
state; it accepts an internal backend and clock, not request-supplied authority.
Preview returns nonsecret references and a domain-separated digest binding the exact
case-sensitive account subject without returning or copying that subject into the ledger.
Issuance repeats the source checks in one conditional INSERT after asynchronous key
classification and hashing. Exact retries return only the stored ID; revoked records
cannot be reissued. Revocation changes only the ledger timestamp and is idempotent.
These operations currently require the complete connection-history schema and exact
qualification DDL; V2-only, partial, unknown and future lifecycle layouts fail closed.
The connection-history liveness mode checks the exact current link and retained
revocation barrier; existing lifecycle-mode callers are unchanged. This capability
creates no grant or controller attestation. Its schemaReady() method supports
the authenticated handler's explicit unavailable/503 response; it does not install
storage or enable issuance without the configured Worker gate. This source stack
includes qualified owner consumption, not a live deployment or a verified live
two-device journey.
Preview, issuance and exact retry require the current enrollment Identity to match
the review's nullable enrollment snapshot exactly (IS, including null). A change
from null to the same reviewed Identity still requires a fresh review; compatibility
with the Identity alone is insufficient. Both null and matching non-null snapshots
remain eligible when the other checks pass.
issue and revoke can also return {kind: "outcome_unknown"}. Once the backend
write has been called, a thrown write response or subsequent read failure cannot
prove that nothing committed. Callers must not present this result as rejection or
rollback. An exact issue retry can recover the stored ID while the source remains
valid; revocation can be retried with the same fixed references and never clears an
existing revocation. Invalid inputs and failures before the write call retain the
existing rejected results. Unknown outcomes return no candidate ID or raw error.
Each qualification ID is globally unique; each coordinator/link pair can have
only one qualification, including after revocation. Every birth fact is immutable,
including a null enrollment snapshot. Deletion and colliding INSERT, UPSERT or
REPLACE are denied; collision probes use the qualification primary key and
coordinator/link unique index, even with recursive triggers disabled. The table
uses WITHOUT ROWID: there is no hidden rowid to collide with or preassignment
value for a trigger to inspect. Inserts require null revocation; only explicit
UPDATE may set revocation to an integer timestamp at least the qualification
creation time. If the clock moves backward, revocation uses the later of now and
the stored creation time. Exact retries retain the first revocation timestamp.
Repeating that timestamp is allowed, but clearing or changing it is not.
Logical references have no cascading
foreign keys, so removal or revocation of an old device cannot erase history.
Migration 0036_add_owner_attempt_qualification_pin.sql enforces
exactly one proof branch: the existing attestation-backed link pins XOR the new
qualification pins. The consumer resolves the qualification on the server at OIDC,
pins its exact nullable ID through confirmation, commit and retained receipt, and
checks the live link generation, account revocation barrier and full retained claim
and review birth tuple at each new authority decision. Qualification revocation,
unlink/relink or changed grants deny a new commit. Revoking the historical controller
or old device after issuance does not invalidate that retained birth evidence.
Qualified confirmations add a separate hash domain and qualification ID; existing
operator/controller confirmation hashes and receipt payloads remain unchanged.
The owner-bound start token still commits to exact issuer/subject/Identity/link:
one immutable qualification per link and callback revalidation prevent replacement
or revoked qualifications from inheriting that admission. Schema application and
deployment still require explicit approval; this source change performs neither.
POST /v1/auth/link-attempts adds two required fields: identity_id (the
current local Identity) and authority (the kind that the client's status read
reported). The server repeats its own evaluation. If the kind or Identity no
longer matches, it returns 403 auth_link_review_required and creates nothing.
The response adds authority. Finalize and cancel bodies do not change.
linkCoordinatorAccount takes the current Identity and expected authority. The
viewer passes the values from the status it displayed when the user clicks
Link Google; a status read or poll never starts an attempt. The CLI resolves
the same readiness for its current profile and uses the same linking runtime,
without a new flag or an administrator-only default. Neither caller selects
authority independently of the coordinator's read-only readiness result.
One transaction must:
- guard the live pending attempt and recheck expiry, state, the attempt's authority (active attestation or device-claim guard), live enrollment, issuer, config revision, and both secrets;
- verify one-to-one account-to-Identity uniqueness across active and revoked rows, then bind the verified issuer/subject to the existing actor; and
- consume the proofs and attempt, persist a redacted audit record, and create no actor, enrollment, or device grant.
Any conflict, backend error, or race rolls back. A consumed retry cannot return a
session secret. Transaction and callback replays fail closed.
SQLite uses an immediate transaction. D1 uses a guarded atomic batch: all writes
depend on the winning attempt transition. Its first conditional UPDATE contains
every live predicate: state/expiry, proof hashes, the source-specific authority
check, exact group/device/public key/fingerprint, enabled enrollment,
null-or-matching Identity, unarchived group, and config revision. An attested
attempt also matches the active attestation and revision; a device-claim attempt
instead reruns the device-claim eligibility guard.
There is no existing enrollment key-revision column to rely on; compare the live
key values. Link and audit writes depend only on that winning consume token, not
on later fallible guards. Use full UNIQUE indexes on (coordinator_id, issuer, subject) and (coordinator_id, actor_id), including revoked rows; uniqueness
conflicts abort the batch. Read back the receipt and link to confirm the effect.
A consumed receipt without a link is an invariant failure, never success. A
zero-row guard is not success; never use INSERT OR IGNORE to hide a link conflict.
Later sign-ins to an existing active link use the normal browser OIDC ceremony and resolve exact issuer/subject to that link. They require no runtime loopback or fresh device approval, and cannot invoke the initial-link helper to enroll or reassign devices. An unknown account remains unlinked; it gets no management session or new actor. Initial linking follows the separate dual-proof flow above.
After OIDC/JWS verification and the original cookie, CSRF, and Origin checks,
future public handlers must issue a normal sign-in session through
signInWithConsumedBrowserTransaction, as defined by the
session admission contract. The older
signInWithAuthAccount helper remains trusted internal only and is not a public
handler substitute.
Successful device finalization does not return a browser cookie to the CLI and does not treat a runtime credential as a browser session. The original browser transaction cookie can redeem finalized status once; only that browser then gets the account-management session. A new requester cannot follow the authorization link to redeem it.
The session cookie is __Host-codemem-session: an opaque random 32-byte value,
Secure, HttpOnly, SameSite=Lax, Path=/, and no Domain. The server stores
only its hash with coordinator, link, actor, auth-config revision, issued-at, expiry, and revocation
state. Its initial absolute lifetime is eight hours and has no silent indefinite
extension; it has no device fingerprint because browser-session life is separate
from device status.
POST logout requires CSRF protection, revokes the server session row, and
clears the cookie. It does not revoke links or devices. Sessions end when their
link is revoked or provider configuration is disabled or changed. Management
authorization still uses existing role checks; this flow grants no administrator
role. Sensitive first-account linking requires fresh OIDC plus the original
controller or admin proof, or an eligible device claim. That legacy/bootstrap rule does not authorize future
owner enrollment of a fresh key for an already active linked Identity; the
separate Identity enrollment contract is
a proposed, review-gated protocol for that path and is not implemented or an
activation approval.
Every protected request rechecks live session expiry/revocation, active link,
config revision, and the action's authorization. Session credentials are bearer
secrets even though they are not Google tokens; never return them to the CLI,
put them in URLs, or include them in polling, logs, or audit records. Replacing
a session rotates its credential rather than adopting a caller-supplied value.
Old eligible normal-sign-in session rows and receipts may later be removed only by the separate trusted explicit guarded-session retention capability. It creates no logout/revoke hook or public handler; browser transaction, cookie, OIDC/JWS, CSRF, and Origin requirements are unchanged.
Only configured-admin authentication can revoke an account link in this slice, with a redacted audit receipt; user sessions cannot self-revoke the link yet. Revocation denies every related session immediately on its next request and keeps the account/Identity tombstone. Replacing that link requires a future reviewed workflow, not a fresh login or enrollment upsert.
The browser session is never sync admission. No supported headless or remote-login flow is claimed.
A profile picture URL is an untrusted remote asset. Rendering and fetch policy need UI review; the server must not fetch arbitrary URLs, and Google tokens must not be cached to refresh an icon.
Required tests cover expiry boundaries; callback and finalization replay; mixed proofs; redirect downgrade and mutation; browser transaction-cookie theft; a compromised local host retaining callback material; no persistent Google tokens; logout not revoking a device; proof authority and source on every path; and transaction races or backend failures failing closed. Test plans must not describe a headless or remote-login path as supported. Both backends must test revoked-link uniqueness and re-enrollment with another key between initiation and finalization (no link and no consumed attempt). Test cross-coordinator signed-body replay, one-shot confirmation, competing browser claims, and lost session-redemption responses without leaking bearer credentials.
Device-claim tests run on both backends and cover:
- schema: preserved rows after the rebuild, kind/reference
CHECKrejections, claim immutability triggers, and rollback of the entire upgrade on a failing copy-equality assertion; - the primary case: an unassigned legacy device with unrelated group members present links its own Identity;
- each guard row as a separate rejection, plus each listed non-contradiction still succeeding;
- the claim appearing in a consumed invite or completion tied to another device/key, including recipient and inviter Identity footprints;
- races: two devices claiming one Identity, one device claiming two Identities, and a Google account already linked or revoked;
- mid-flow changes before each transition: disabled, revoked, re-keyed, archived, assigned, or newly attested;
- OIDC verification, then cancellation or failure, then a successful retry;
- owner-enrollment start, callback, and commit, plus grant issuance, rejecting a device-claim link even when grants exist;
- existing-Identity invite acceptance rejecting a device-claim link as its account-ownership proof;
- status privacy: no metadata for another person's Identity, no writes, and different requested Identities sharing the same authenticated-device quota;
- attested behavior unchanged.
- historical link consumers continue to accept a valid active attested link after its original controller or device is revoked or removed, while current session, key-possession, grant, and link-revocation checks remain enforced;
- schema upgrades preserve valid completed legacy links with historical-only source pins, including after their original controller was removed, and reject contradictions in any source or creation audit that is still retained.