Identity moves: sanctioned mover (auth_at claims, identity_moves, POST /v1/apps/{app}/identity-moves) #16

Merged
heimdall merged 5 commits from heimdall/identity-moves into main 2026-11-14 01:59:12 +00:00
Member

Executes cut_spec heimdall-body:cut_spec:cut-identity-moves.r1 with ruling move-proof-strict-passkey.

What moves

  • POST /v1/apps/{app}/identity-moves: the only sanctioned way a login changes account. Bifrost-only (profile grant identityMoves: { accountProofMethods: ["bifrost-passkey"] }). Accounts come only from the proofs (two bearers), never from the body. Idempotent moveId; reversible once (reversesMoveId).
  • Strict-passkey coverage: a proof through login X moves X only; only an asserted bifrost-passkey covers a whole account; bifrost-email-code does not.
  • moveIdentities (Postgres and in-memory) is now the only writer of linked_identities.account_id. One transaction: locks both accounts in id order, answers a replayed moveId from its row, re-checks proof sessions, strips moved credentials, revokes the source's sessions, expires its pending links and unredeemed completions, appends an identity_moves row. Revocation receipts for the source's grants and the identity_moved audit are written after commit; a replay heals missing receipts.
  • Claims: auth_at and auth_identity_id, set only by a sign_in callback. proofTier / proofCovers in claims.ts; the unlink route's inline step-up window test is deleted for proofTier(...) === "strong" (same 600 s rule).
  • One pure decideMove (new src/store/identity-move.ts) holds the move rules; both stores gather facts and apply its answer.

Owner action BEFORE the new build starts (DDL + grant)

Startup refuses with SCHEMA_MISSING / SCHEMA_PRIVILEGES otherwise. Apply the identity_moves table, its identity_moves_reverses_unique_idx and identity_moves_from_idx from src/store/schema.ts (append-only block after consent_receipts_account_idx), then:

GRANT SELECT, INSERT ON identity_moves TO <store role>;

No other grant changes (linked_identities, sessions, pending_links, auth_completions already hold UPDATE).

Idempotency ruling

Idempotency is by moveId (steward ruling): a replay with the same moveId and different identityIds returns the first receipt and moves nothing; Bifrost retries with the same body. Pinned by a test that documents it. A replay under another reversesMoveId, source, destination or app is 409 move_id_conflict.

Not done / limits

  • Not deployed; no live host touched.
  • Survivor choice and preview are Bifrost's saga, not here.
  • Bearers of the destination keep their old identity list until they refresh.
  • Until the Google client is live, a member's strong proof is the asserted passkey only; Discord/Patreon give weak (300 s) proofs.
  • Extra body properties are ignored by Fastify (its default strips them) rather than answered 400; they steer nothing and a test pins that.
  • Hands deviation: proofCovers takes nowS (the spec signature omitted it though its rule needs the tier), and the route is registered after the unlink route, where revokeLiveGrants is in scope, not beside step-up.
  • resolveAccount was deleted after Soul s1 (no caller; wrong after chains). No mutation pass (StrykerJS) was run; one hand probe: dropping the ordered account locks makes the opposite-moves race test fail with a deadlock. Soul is to run the spec's mutations.

Tests

bash scripts/test-live-pg.sh (real scratch Postgres 16): 925 passed, 17 skipped, 34 files (at 2f1b733). New: tests/identity-moves.test.ts (23, HTTP on InMemoryStore), store contract on memory and live PG, opposite concurrent moves over two pools x10 rounds, identical concurrent moveIds, startup refusing a role without INSERT or SELECT on identity_moves. tsc --noEmit clean.

Structural delta: +1531 / -9 over 15 files; one new source file, one new table, no dependencies, no targets.

🤖 Generated with Claude Code

Executes cut_spec `heimdall-body:cut_spec:cut-identity-moves.r1` with ruling `move-proof-strict-passkey`. ## What moves - `POST /v1/apps/{app}/identity-moves`: the only sanctioned way a login changes account. Bifrost-only (profile grant `identityMoves: { accountProofMethods: ["bifrost-passkey"] }`). Accounts come only from the proofs (two bearers), never from the body. Idempotent `moveId`; reversible once (`reversesMoveId`). - Strict-passkey coverage: a proof through login X moves X only; only an asserted `bifrost-passkey` covers a whole account; `bifrost-email-code` does not. - `moveIdentities` (Postgres and in-memory) is now the only writer of `linked_identities.account_id`. One transaction: locks both accounts in id order, answers a replayed `moveId` from its row, re-checks proof sessions, strips moved credentials, revokes the source's sessions, expires its pending links and unredeemed completions, appends an `identity_moves` row. Revocation receipts for the source's grants and the `identity_moved` audit are written after commit; a replay heals missing receipts. - Claims: `auth_at` and `auth_identity_id`, set only by a `sign_in` callback. `proofTier` / `proofCovers` in `claims.ts`; the unlink route's inline step-up window test is deleted for `proofTier(...) === "strong"` (same 600 s rule). - One pure `decideMove` (new `src/store/identity-move.ts`) holds the move rules; both stores gather facts and apply its answer. ## Owner action BEFORE the new build starts (DDL + grant) Startup refuses with SCHEMA_MISSING / SCHEMA_PRIVILEGES otherwise. Apply the `identity_moves` table, its `identity_moves_reverses_unique_idx` and `identity_moves_from_idx` from `src/store/schema.ts` (append-only block after `consent_receipts_account_idx`), then: GRANT SELECT, INSERT ON identity_moves TO <store role>; No other grant changes (linked_identities, sessions, pending_links, auth_completions already hold UPDATE). ## Idempotency ruling Idempotency is by moveId (steward ruling): a replay with the same moveId and different identityIds returns the first receipt and moves nothing; Bifrost retries with the same body. Pinned by a test that documents it. A replay under another reversesMoveId, source, destination or app is 409 move_id_conflict. ## Not done / limits - Not deployed; no live host touched. - Survivor choice and preview are Bifrost's saga, not here. - Bearers of the destination keep their old identity list until they refresh. - Until the Google client is live, a member's strong proof is the asserted passkey only; Discord/Patreon give weak (300 s) proofs. - Extra body properties are ignored by Fastify (its default strips them) rather than answered 400; they steer nothing and a test pins that. - Hands deviation: `proofCovers` takes `nowS` (the spec signature omitted it though its rule needs the tier), and the route is registered after the unlink route, where `revokeLiveGrants` is in scope, not beside step-up. - resolveAccount was deleted after Soul s1 (no caller; wrong after chains). No mutation pass (StrykerJS) was run; one hand probe: dropping the ordered account locks makes the opposite-moves race test fail with a deadlock. Soul is to run the spec's mutations. ## Tests `bash scripts/test-live-pg.sh` (real scratch Postgres 16): 925 passed, 17 skipped, 34 files (at 2f1b733). New: `tests/identity-moves.test.ts` (23, HTTP on InMemoryStore), store contract on memory and live PG, opposite concurrent moves over two pools x10 rounds, identical concurrent moveIds, startup refusing a role without INSERT or SELECT on `identity_moves`. `tsc --noEmit` clean. Structural delta: +1531 / -9 over 15 files; one new source file, one new table, no dependencies, no targets. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
proofTier is the one freshness rule (unlink now reads it); the sign_in callback
stamps auth_at and auth_identity_id; moveIdentities is the only writer that
changes linked_identities.account_id, decided by one shared decideMove.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The route verifies proofs (resolveBearer, proofTier, proofCovers), maps the
store's refusals to fixed codes, and writes the revocation receipts and audit.
Tests drive it over HTTP; the store contract runs on memory and live Postgres;
opposite concurrent moves over two pools never deadlock.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Tests now kill M09, M11, M14, M17, M18, M19, M21, M23, M28, M29, M31 (each
re-run by hand). The account locks are taken one statement each in sorted
order, so dropping the sort is observable (M01) instead of planner-dependent.
resolveAccount had no caller and answered wrongly after a chain of moves: deleted
with its docs claim; the alias test inside moveIdentities stays.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Author
Member

Soul s1 follow-up at a591c71: tests now kill M01 (lock order moved into code, sorted), M09, M11, M14, M17, M18, M19/M19b, M21, M23, M28, M29, M31 (each re-run by hand); resolveAccount deleted (no caller; wrong after chains). Suite 923 passed, 17 skipped on scratch PG 16; tsc clean. Open: a replay with changed identityIds under a used moveId still answers the first receipt.

🤖 Generated with Claude Code

Soul s1 follow-up at a591c71: tests now kill M01 (lock order moved into code, sorted), M09, M11, M14, M17, M18, M19/M19b, M21, M23, M28, M29, M31 (each re-run by hand); resolveAccount deleted (no caller; wrong after chains). Suite 923 passed, 17 skipped on scratch PG 16; tsc clean. Open: a replay with changed identityIds under a used moveId still answers the first receipt. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
GameCult/Heimdall!16
No description provided.