From 6e705852fbbfbc9ff33b96be4cdb255e7a251992 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 14 Nov 2026 14:00:59 +0000 Subject: [PATCH] Settle: live-PG pins (lost race writes no audit row, audit fault rolls back, concurrent settles once); SettleableOutcomes read-only; docs: a settle does not fence an in-flight replay Co-Authored-By: Claude Sonnet 5.5 --- docs/account-merge.md | 2 +- .../Features/Membership/AccountMergeSaga.cs | 2 +- .../AccountMergePostgresTests.cs | 83 +++++++++++++++++++ 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/docs/account-merge.md b/docs/account-merge.md index 75665ba..67e224b 100644 --- a/docs/account-merge.md +++ b/docs/account-merge.md @@ -79,7 +79,7 @@ Two actions (`heimdall-body:ruling:merge-stuck-end-only-typed-id`), both `Accoun | Fail the merge (`FailForward`) | Confirmed, stuck | `Failed`, `FailureCode` and `LastOutcome` `operator_failed`, proofs wiped. The folded account is free again; the pair can be confirmed anew. | | Give up the undo (`AbandonUndo`) | UndoRequested, stuck | back to `Committed`, `LastOutcome` `undo_operator_abandoned`, proofs wiped, the grace as it was. The merge stands; the logins stay with the survivor. | -HeimdallMoved and Committed are never settleable: Heimdall answered Moved with a matching receipt there, and the saga rolls forward. A settle and the sweeper's `AdvanceAsync` meet at the `State` concurrency token; the loser changes nothing and the other transition stands (answered `lost`). +HeimdallMoved and Committed are never settleable: Heimdall answered Moved with a matching receipt there, and the saga rolls forward. A settle and the sweeper's `AdvanceAsync` meet at the `State` concurrency token; the loser changes nothing and the other transition stands (answered `lost`). A settle does not fence a replay already in flight to Heimdall, so read `identity_moves` first and read it again after. Before ending a merge, read Heimdall's `identity_moves` row by the stored move id. If Heimdall did move the logins, failing the merge leaves them moved with no Bifrost merge to match: repair or reverse at Heimdall first. For `receipt_mismatch`, repair Heimdall's or Bifrost's account link before failing. Adopting a move Heimdall made (writing HeimdallMoved from a receipt) stays manual SQL until follow-up `merge-operator-adopt-moved`; the page never does it. diff --git a/src/Bifrost.Web/Features/Membership/AccountMergeSaga.cs b/src/Bifrost.Web/Features/Membership/AccountMergeSaga.cs index 6e60db5..153ee80 100644 --- a/src/Bifrost.Web/Features/Membership/AccountMergeSaga.cs +++ b/src/Bifrost.Web/Features/Membership/AccountMergeSaga.cs @@ -432,7 +432,7 @@ public sealed partial class AccountMergeSaga( // ---- operator settle ---- /// The Heimdall answers no replay can change: the one the saga recorded and stopped on, the key ring's loss, and a move Heimdall holds but will not confirm. - public static readonly string[] SettleableOutcomes = [ReceiptMismatch, ProofsUnreadable, MoveIdConflict]; + public static readonly IReadOnlyList SettleableOutcomes = [ReceiptMismatch, ProofsUnreadable, MoveIdConflict]; /// /// Whether an operator may end this merge's leg: in flight (Confirmed or UndoRequested) and stopped on one of the closed diff --git a/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs b/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs index 06981a6..e353e0f 100644 --- a/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs +++ b/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs @@ -865,6 +865,89 @@ public sealed class AccountMergePostgresTests(MergePostgres postgres) : IClassFi Assert.True(blocked); } + /// A Confirmed merge with sealed proofs, stopped on the given outcome, and the operator who will settle it. + private async Task<(AccountMergeLedgerTests.World World, Heimdall Heimdall, Guid Id, string MoveId, Guid Operator)> StuckConfirmedAsync(string outcome) + { + var world = await postgres.NewWorldAsync(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var operatorId = (await world.AccountAsync("operator")).Id; + var heimdall = new Heimdall(world); + Guid id; + await using (var db = world.Db()) + { + var digest = (await world.Ledger(db).PlanAsync(survivor.Id, folded.Id, default)).Digest(survivor.Id, folded.Id); + id = Assert.IsType(await heimdall.Saga(world, db).ConfirmAsync( + new ConfirmMergeRequest(survivor.Id, survivor.Id, folded.Id, digest, heimdall.Proof(survivor, "twitch"), [heimdall.Proof(folded, "discord")]), default)).MergeId; + } + + await ExecuteAsync(world, $"UPDATE \"AccountMerges\" SET \"State\" = 'Confirmed', \"LastOutcome\" = '{outcome}' WHERE \"Id\" = '{id}'"); + await using var read = world.Db(); + var merge = await read.AccountMerges.AsNoTracking().SingleAsync(); + Assert.NotNull(merge.SealedProofs); + return (world, heimdall, id, merge.HeimdallMoveId!, operatorId); + } + + [PostgresFact] + public async Task A_settle_that_loses_to_a_rival_state_write_inside_its_save_window_writes_nothing_and_no_audit_row() + { + var (world, heimdall, id, moveId, operatorId) = await StuckConfirmedAsync("move_id_conflict"); + var settler = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new BeforeFirstSave(() => + ExecuteAsync(world, $"UPDATE \"AccountMerges\" SET \"State\" = 'HeimdallMoved' WHERE \"Id\" = '{id}'"))); + await using (var db = settler.Db()) + { + Assert.IsType(await heimdall.Saga(settler, db).SettleStuckAsync(id, SettleChoice.FailForward, moveId, operatorId, default)); + } + + await using var read = world.Db(); + var merge = await read.AccountMerges.AsNoTracking().SingleAsync(); + Assert.Equal(AccountMergeState.HeimdallMoved, merge.State); + Assert.NotNull(merge.SealedProofs); + Assert.Equal("move_id_conflict", merge.LastOutcome); + Assert.Equal(0, await read.AuditEvents.CountAsync()); + } + + [PostgresFact] + public async Task A_settle_whose_audit_insert_fails_rolls_the_transition_back() + { + var (world, heimdall, id, moveId, operatorId) = await StuckConfirmedAsync("receipt_mismatch"); + await ExecuteAsync(world, """ + CREATE FUNCTION fail_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'injected audit fault'; END $$; + CREATE TRIGGER fail_audit BEFORE INSERT ON "AuditEvents" FOR EACH ROW EXECUTE FUNCTION fail_audit(); + """); + await using (var db = world.Db()) + { + await Assert.ThrowsAsync(() => heimdall.Saga(world, db).SettleStuckAsync(id, SettleChoice.FailForward, moveId, operatorId, default)); + } + + await using var read = world.Db(); + var merge = await read.AccountMerges.AsNoTracking().SingleAsync(); + Assert.Equal(AccountMergeState.Confirmed, merge.State); + Assert.NotNull(merge.SealedProofs); + Assert.Equal("receipt_mismatch", merge.LastOutcome); + Assert.Equal(0, await read.AuditEvents.CountAsync()); + } + + [PostgresFact] + public async Task Two_concurrent_settles_of_one_stuck_merge_settle_once_lose_once_and_audit_once() + { + var (world, heimdall, id, moveId, operatorId) = await StuckConfirmedAsync("proofs_unreadable"); + var racers = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new FirstSaveRendezvous()); + async Task Settle() + { + await using var db = racers.Db(); + return await heimdall.Saga(racers, db).SettleStuckAsync(id, SettleChoice.FailForward, moveId, operatorId, default); + } + + var both = await Task.WhenAll(Task.Run(Settle), Task.Run(Settle)); + + Assert.Single(both.OfType()); + Assert.Single(both.OfType()); + await using var read = world.Db(); + Assert.Equal(1, await read.AuditEvents.CountAsync()); + Assert.Equal(AccountMergeState.Failed, (await read.AccountMerges.AsNoTracking().SingleAsync()).State); + } + [PostgresFact] public async Task The_saga_migration_adds_and_drops_its_columns_and_keys_and_its_Down_is_refused_while_any_merge_exists() {