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()
{