Account merge: ledger, Heimdall mover client, crash-safe saga (merge design cuts A+B+saga) #86

Merged
heimdall merged 21 commits from heimdall/account-merge into main 2026-11-14 09:42:49 +00:00

2026-11-14

Heimdall
Account merge saga: pin that a receipt mismatch stays the recorded answer across later sweeps
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 08:09:39 +00:00
Heimdall
Move client: pin duplicate identityIds and over-length proofs as refused before any request
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 07:49:52 +00:00
Heimdall
2026-11-14 06:48:43 +00:00
Heimdall
Account merge saga: Confirm with consent record and sealed proofs, one crash-safe step function for the Heimdall move and reversal, one merge loop
AccountMergeSaga is the only caller of the move client and the only writer of Confirmed, HeimdallMoved, Failed, UndoRequested and HeimdallReversed; the
ledger keeps Committed, Final and Undone. Migration AddAccountMergeSaga adds the saga columns and the two move-id keys; its Down is roll-forward only.
AccountMergeSweeper becomes the one loop: saga advance, then finalize, every 30 s.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 06:48:43 +00:00
Heimdall
Account merge ledger: pin the Stripe refusal for a folded account whose survivor is suspended
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 06:32:44 +00:00
Heimdall
Account merge ledger: one write gate for every writer, held across the standing read and the save
WriteAsAsync takes a shared Postgres advisory lock on the account (the key the chain trigger locks exclusively) across the standing read and the writer's save; CommitAsync takes the exclusive lock before it reduces. Votes, appraisals, sealed commit and reveal, patron events (RecordSupportEventAsync routes and journals in the same save, so the manual Ledger door obeys it too) and the Heimdall sign-in pass through it; SupportTargetAsync, MayWriteAsync and JournalIntakeAsync are deleted. A folded or frozen account's sign-in writes no membership. A retry of an event stranded on the folded account journals nothing. Tests: Down refuses in every merge state, Stripe journal prior owner and undo, the lock racing a freeze and a commit on live Postgres. Docs say what the lock guarantees and what it does not.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 06:08:36 +00:00
Heimdall
Move client: pin that invalid_request at 413 and 415 is Unknown, the rule is exactly 400
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 06:08:36 +00:00
Heimdall
Account merge ledger: test the migration's duplicate current Patron snapshot dedupe
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 04:59:44 +00:00
Heimdall
Move client: a 400 is RequestInvalid only with Heimdall's invalid_request body
An nginx HTML 400, an empty 400 or any other 400 is Unknown. The fake answers its schema 400 with Heimdall's error handler body.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 04:14:38 +00:00
Heimdall
Move client: Refused only for Heimdall's coded move refusals, any pipeline exception is Unknown, ids end at \z
An uncoded 4xx (gateway 404, 408, 410, unknown code) is Unknown: Refused wipes sealed proofs. Unrecognized leaves the refusal enum.
The catch lists no types: only the caller's cancellation propagates. moveId and reversesMoveId patterns use \z in client and fake,
so a trailing line feed is refused as Heimdall's ajv refuses it; 64, 65 and newline are pinned.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 03:50:42 +00:00
Heimdall
Move client: no redirects, one deadline over body reads, 64 KiB receipt cap, 409 move_id_conflict is Unknown
A 3xx is Unknown and never carries the secret or proofs off origin (registration owns the handler);
the HttpClient timeout now bounds the body read and any IOException or stall is Unknown; receipts
over 64 KiB are Unknown; the request's ToString hides its proofs; move_id_conflict is Unknown because
a committed move answers it when its proofs no longer verify, which keeps the sealed proofs resumable;
identity ids need not be GUIDs, proofs are bounded to 8192 and identityIds unique.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 03:13:14 +00:00
Heimdall
Fake mover: port Heimdall 4fe367a's route schema and decision order
400 for every body the route schema rejects (oneOf toProof/reversesMoveId, duplicate or empty
identityIds, proof bounds), 404 identity_not_found for a source holding nothing, coverage-all-null
403 before the store, store order not_reversible/already_moved/not_found/not_proved/provider_conflict,
replay by signature without liveness (409 move_id_conflict for unsigned proofs), future-dated proofs
stale. Soul's P1-P4 are committed as FakeHeimdallMoverTests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 03:13:14 +00:00
Heimdall
Account merge ledger: AccountMerge receipt, reductions, alias standing, undo and final sweep
AccountMergeLedger owns what a merge does to Bifrost records: PlanAsync (dry run, block codes),
CommitAsync (one save, total, idempotent), UndoLocalAsync (journal replay), FinalizeDueAsync
(AccountMergeSweeper) and StandingAsync/ResolveAsync. Votes and concept appraisals are voided by a
stamp that a model query filter hides from every tally. The actor accessor no longer ORs several
credentials under SingleOrDefault (a latent crash); a frozen or folded account resolves to no
account on every route, the OIDC authorize and chat refusal deny it, and patron intake follows the
alias. Migration AddAccountMerges is additive. No UI, no Heimdall call, nothing deployed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 02:31:51 +00:00
Heimdall
Fake Heimdall mover and consumer-side move client tests
FakeHeimdall.Moves owns per-account logins, a bearer registry, revoked sessions and stored moves,
and answers the identity-moves route in decideMove's order (4fe367a): replay before proofs,
strict passkey coverage, freshness windows, first-receipt replay. Fault switches for status,
apply-then-fail and down.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 02:27:38 +00:00
Heimdall
Heimdall identity-move client: closed Moved/Refused/Unknown outcome
POST /v1/apps/bifrost/identity-moves under the app secret (HeimdallAuthService.ConfigureClient,
same header redaction), 15 s timeout, request validated to the contract's closed body before any
I/O, 200 trusted only as a whole receipt answering this request. HeimdallRefusal gains ReadAsync
(code plus safe proof side and provider); CodeAsync delegates to it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 02:27:38 +00:00
Heimdall
Test: snapshot has no drift from the design-time model (passes at base 425af74)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 02:20:25 +00:00