Account merge saga: plan digest ignores the id of a membership not yet created, pin grace boundary, unique move ids and the confirm write gate, ProofSeal prints no bearer, commit the mutant probe

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Heimdall 2026-11-14 09:26:27 +00:00
commit 757ca2eeab
5 changed files with 205 additions and 2 deletions

View file

@ -43,7 +43,8 @@ public sealed record MergePlan(
{
Field(entry.Kind.ToString());
Field(entry.Action.ToString());
Field(entry.RecordId.ToString("N"));
// A Created row does not exist yet: its id is minted anew by every plan, so it cannot be part of what the member saw.
Field(entry.Action == AccountMergeEntryAction.Created ? null : entry.RecordId.ToString("N"));
Field(entry.PriorUserAccountId?.ToString("N"));
Field(entry.PriorText);
Field(entry.PriorAtUtc?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture));

View file

@ -104,7 +104,15 @@ public sealed partial class AccountMergeSaga(
private readonly IDataProtector _seal = dataProtection.CreateProtector("Bifrost.AccountMerge.Proofs.v1");
private sealed record ProofSeal(List<string> From, string? To);
private sealed record ProofSeal(List<string> From, string? To)
{
// The seal holds live bearers: the generated ToString would print them into any log or exception that holds it.
private bool PrintMembers(StringBuilder builder)
{
builder.Append($"From = {From?.Count}, To = {(To is null ? "none" : "present")}");
return true;
}
}
// ---- confirm ----

View file

@ -804,6 +804,66 @@ public sealed class AccountMergePostgresTests(MergePostgres postgres) : IClassFi
Assert.Equal(1, await check.AccountMerges.CountAsync());
}
[PostgresFact]
public async Task Two_merges_cannot_share_a_Heimdall_move_id_or_a_reverse_move_id()
{
var world = await postgres.NewWorldAsync();
var survivor = await world.AccountAsync("survivor");
var one = await world.MergeAsync(survivor, await world.AccountAsync("one"), AccountMergeState.Confirmed);
var two = await world.MergeAsync(survivor, await world.AccountAsync("two"), AccountMergeState.Confirmed);
foreach (var column in new[] { "HeimdallMoveId", "HeimdallReverseMoveId" })
{
await ExecuteAsync(world, $"UPDATE \"AccountMerges\" SET \"{column}\" = 'shared-0123456789abcdef' WHERE \"Id\" = '{one.Id}'");
var error = await Assert.ThrowsAnyAsync<Exception>(() =>
ExecuteAsync(world, $"UPDATE \"AccountMerges\" SET \"{column}\" = 'shared-0123456789abcdef' WHERE \"Id\" = '{two.Id}'"));
Assert.Equal("23505", Pg(error).SqlState);
}
}
[PostgresFact]
public async Task Confirm_holds_the_actors_write_lock_until_its_row_is_saved()
{
var world = await postgres.NewWorldAsync();
var survivor = await world.AccountAsync("survivor");
var folded = await world.AccountAsync("folded");
var heimdall = new Heimdall(world);
var proof = heimdall.Proof(survivor, "twitch");
var foldedProof = heimdall.Proof(folded, "discord");
string digest;
await using (var db = world.Db())
{
digest = (await world.Ledger(db).PlanAsync(survivor.Id, folded.Id, default)).Digest(survivor.Id, folded.Id);
}
Task? exclusive = null;
var blocked = false;
var confirmers = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new BeforeFirstSave(async () =>
{
// A writer that needs the actor's exclusive lock (a commit freezing it) arrives while the confirm is about to save.
exclusive = Task.Run(async () =>
{
await using var connection = new NpgsqlConnection(ConnectionOf(world));
await connection.OpenAsync();
await using var transaction = await connection.BeginTransactionAsync();
await using var command = new NpgsqlCommand("SELECT pg_advisory_xact_lock(hashtextextended(@key, 0))", connection, transaction);
command.Parameters.AddWithValue("key", survivor.Id.ToString());
await command.ExecuteNonQueryAsync();
});
await Task.Delay(1500);
blocked = !exclusive.IsCompleted;
}));
await using (var db = confirmers.Db())
{
Assert.IsType<ConfirmOutcome.Confirmed>(await heimdall.Saga(confirmers, db).ConfirmAsync(
new ConfirmMergeRequest(survivor.Id, survivor.Id, folded.Id, digest, proof, [foldedProof]), default));
}
await exclusive!.WaitAsync(TimeSpan.FromSeconds(30));
Assert.True(blocked);
}
[PostgresFact]
public async Task The_saga_migration_adds_and_drops_its_columns_and_keys_and_its_Down_is_refused_while_any_merge_exists()
{

View file

@ -634,6 +634,57 @@ public sealed class AccountMergeSagaTests
Assert.Equal(AccountMergeState.Final, (await rig.MergeAsync(id)).State);
}
[Fact]
public async Task A_suspended_folded_account_into_a_memberless_survivor_confirms_on_the_digest_the_member_saw_and_commits()
{
using var rig = new Rig();
var survivor = await rig.AccountAsync("survivor", "twitch");
var folded = await rig.AccountAsync("folded", "discord");
await rig.InScopeAsync(async sp =>
{
var db = sp.GetRequiredService<BifrostDbContext>();
db.Memberships.Remove(await db.Memberships.SingleAsync(x => x.UserAccountId == survivor.Id)); // the chat tier holds no membership
(await db.Memberships.SingleAsync(x => x.UserAccountId == folded.Id)).Status = MembershipStatus.Suspended;
await db.SaveChangesAsync();
return 0;
});
// The member is shown a plan and confirms it: the same unchanged world must show the same digest.
Assert.Equal(await rig.DigestAsync(survivor, folded), await rig.DigestAsync(survivor, folded));
var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord"));
Assert.Equal(AccountMergeState.Committed, (await rig.AdvanceAsync(id)).State);
var carried = await rig.InScopeAsync(sp => sp.GetRequiredService<BifrostDbContext>().Memberships.SingleAsync(x => x.UserAccountId == survivor.Id));
Assert.Equal(MembershipStatus.Suspended, carried.Status);
}
[Fact]
public async Task Undo_is_refused_at_the_exact_instant_the_grace_ends_and_allowed_a_second_before()
{
using var rig = new Rig();
var survivor = await rig.AccountAsync("survivor", "twitch");
var folded = await rig.AccountAsync("folded", "discord");
var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord"));
await rig.AdvanceAsync(id);
var ends = (await rig.MergeAsync(id)).GraceEndsUtc!.Value;
rig.Clock.Advance(ends - rig.Clock.Now);
Assert.IsType<UndoOutcome.GraceOver>(await RequestUndoAsync(rig, id, survivor, rig.Weak(survivor, "twitch")));
rig.Clock.Advance(TimeSpan.FromSeconds(-1));
Assert.IsType<UndoOutcome.Requested>(await RequestUndoAsync(rig, id, survivor, rig.Weak(survivor, "twitch")));
}
[Fact]
public void The_sealed_proofs_record_prints_no_bearer()
{
var seal = typeof(AccountMergeSaga).GetNestedType("ProofSeal", System.Reflection.BindingFlags.NonPublic)!;
var instance = Activator.CreateInstance(seal, new List<string> { "bearer-folded-secret" }, "bearer-survivor-secret")!;
var text = instance.ToString()!;
Assert.DoesNotContain("secret", text);
}
[Fact]
public async Task An_unknown_answer_to_the_reversal_is_replayed_under_the_same_reverse_id_even_past_the_grace()
{

View file

@ -0,0 +1,83 @@
"""One-off mutation probe for the account merge saga (not a gate; survivors are triaged by name).
Usage, from any directory with BIFROST_TEST_PG and the dotnet environment set:
python3 tools/merge-saga-mutants.py [mutant-name ...] # no names: all
Each mutant is one exact text edit, applied, run against the merge tests (and again alone if it fails), then restored.
Logs go to $MUTANT_LOGS (default ./mutant-logs). Expected: every mutant KILLED.
"""
import subprocess, re, sys, os, json
WT=subprocess.run(['git','rev-parse','--show-toplevel'],capture_output=True,text=True,check=True,cwd=os.path.dirname(os.path.abspath(__file__))).stdout.strip()
LOGS=os.environ.get('MUTANT_LOGS','./mutant-logs'); os.makedirs(LOGS,exist_ok=True)
SAGA='src/Bifrost.Web/Features/Membership/AccountMergeSaga.cs'
LEDGER='src/Bifrost.Web/Features/Membership/AccountMergeLedger.cs'
FILTER='FullyQualifiedName~AccountMergeSaga|FullyQualifiedName~AccountMergePostgres'
M=[
('derive-move-id', SAGA, [(" var merge = new AccountMerge\n {\n State = AccountMergeState.Confirmed,"," var fixedId = Guid.NewGuid();\n var merge = new AccountMerge\n {\n Id = fixedId,\n State = AccountMergeState.Confirmed,"),('HeimdallMoveId = Guid.NewGuid().ToString("N"),','HeimdallMoveId = fixedId.ToString("N"),')], None),
('no-wipe-on-moved', SAGA, " merge.SealedProofs = null;\n if (moved.Receipt.AccountId != destination)", " if (moved.Receipt.AccountId != destination)"),
('no-wipe-on-refused', SAGA, " // Heimdall applied nothing before a 4xx. A forward refusal ends the merge; a refused undo leaves it as it was.\n merge.SealedProofs = null;", " // Heimdall applied nothing before a 4xx. A forward refusal ends the merge; a refused undo leaves it as it was."),
('refused-stays-confirmed', SAGA, "merge.State = AccountMergeState.Failed;", "merge.State = AccountMergeState.Confirmed;"),
('unknown-no-attempt', SAGA, " merge.Attempts++;\n", ""),
('unknown-no-backoff', SAGA, "merge.NextAttemptUtc = now + Backoff[Math.Min(merge.Attempts, Backoff.Length) - 1];", "merge.NextAttemptUtc = null;"),
('no-digest-check', SAGA, "if (!string.Equals(plan.Digest(request.SurvivorUserAccountId, request.FoldedUserAccountId), request.PreviewDigest, StringComparison.Ordinal))", "if (false)"),
('no-proof-account-check', SAGA, "if (request.SurvivorProof.HeimdallAccountId != survivorHeimdall || request.FoldedProofs.Any(x => x.HeimdallAccountId != foldedHeimdall))", "if (false)"),
('no-notparty', SAGA, "if (request.ActorUserAccountId != request.SurvivorUserAccountId && request.ActorUserAccountId != request.FoldedUserAccountId)", "if (false)"),
('no-block-check', SAGA, "if (plan.Blocks.Count > 0)", "if (false)"),
('no-grace-check', SAGA, "if (merge.GraceEndsUtc is not { } graceEnds || now >= graceEnds)", "if (merge.GraceEndsUtc is not { } graceEnds)"),
('no-notsurvivor', SAGA, "if (actorUserAccountId != merge.SurvivorUserAccountId)", "if (false)"),
('no-undo-proof-check', SAGA, "if (survivorProofs.Any(x => x.HeimdallAccountId != survivorHeimdall))", "if (false)"),
('no-final-graceover', SAGA, "if (merge.State == AccountMergeState.Final || merge.FinalizedUtc is not null)", "if (false)"),
('no-receipt-check', SAGA, "if (moved.Receipt.AccountId != destination)", "if (false)"),
('no-concurrency-retry', SAGA, "catch (DbUpdateConcurrencyException) when (lost < 2)", "catch (DbUpdateConcurrencyException) when (false)"),
('no-live-violation-catch', SAGA, "catch (DbUpdateException error) when (IsLiveMergeViolation(error))", "catch (DbUpdateException error) when (false)"),
('undo-refused-fails', SAGA, " merge.State = AccountMergeState.Committed;\n merge.LastOutcome = \"undo_\" + code;", " merge.State = AccountMergeState.Failed;\n merge.LastOutcome = \"undo_\" + code;"),
('unreadable-no-delay', SAGA, "merge.NextAttemptUtc = now + UnreadableRetry;", "merge.NextAttemptUtc = null;"),
('due-ignores-next-attempt', SAGA, "&& (x.NextAttemptUtc == null || x.NextAttemptUtc <= now)", ""),
('mismatch-retries', SAGA, " if (merge.LastOutcome == ReceiptMismatch)\n {\n return merge;", " if (false)\n {\n return merge;"),
('reverse-id-forward', SAGA, "new HeimdallMoveRequest(merge.HeimdallReverseMoveId!, proofs.From, ReversesMoveId: merge.HeimdallMoveId)", "new HeimdallMoveRequest(merge.HeimdallMoveId!, proofs.From, ReversesMoveId: merge.HeimdallMoveId)"),
('log-token', SAGA, 'logger.LogInformation("Account merge {MergeId} {State}: {Outcome} {Code}", merge.Id, merge.State, outcome.GetType().Name, code);', 'logger.LogInformation("Account merge {MergeId} {State}: {Outcome} {Code} {Req}", merge.Id, merge.State, outcome.GetType().Name, code, proofs.From[0]);'),
('seal-plain', SAGA, "private string Seal(IEnumerable<string> from, string? to) => _seal.Protect(JsonSerializer.Serialize(new ProofSeal([.. from], to)));", "private string Seal(IEnumerable<string> from, string? to) => JsonSerializer.Serialize(new ProofSeal([.. from], to));"),
('sweeper-no-advance', LEDGER, " await scope.ServiceProvider.GetRequiredService<AccountMergeSaga>().AdvanceDueAsync(now, stoppingToken);\n", ""),
('sweeper-no-finalize', LEDGER, " await scope.ServiceProvider.GetRequiredService<AccountMergeLedger>().FinalizeDueAsync(now, stoppingToken);\n", ""),
('no-writegate-confirm', SAGA, "return await AccountMergeLedger.WriteAsAsync(dbContext, request.ActorUserAccountId, _ => InsertConfirmedAsync(request, cancellationToken), cancellationToken);", "return await InsertConfirmedAsync(request, cancellationToken);"),
('digest-no-entries', LEDGER, " foreach (var entry in Entries)\n {\n Field(entry.Kind.ToString());", " foreach (var entry in Entries.Take(0))\n {\n Field(entry.Kind.ToString());"),
('tier-swapped', SAGA, "merge.ProofFromTier = Tier(moved.Receipt.ProofFrom);", "merge.ProofFromTier = \"strong\";"),
('wipe-missing-on-reversed', SAGA, " merge.State = AccountMergeState.HeimdallReversed;\n merge.ReversedUtc = now;", " merge.State = AccountMergeState.HeimdallReversed;\n merge.ReversedUtc = now;\n merge.SealedProofs = \"kept\";"),
('grace-boundary', SAGA, "now >= graceEnds", "now > graceEnds"),
('move-id-not-unique', 'src/Bifrost.Web/Data/Migrations/20261114130000_AddAccountMergeSaga.cs', 'column: "HeimdallMoveId",\n unique: true,', 'column: "HeimdallMoveId",\n unique: false,'),
('reverse-id-not-unique', 'src/Bifrost.Web/Data/Migrations/20261114130000_AddAccountMergeSaga.cs', 'column: "HeimdallReverseMoveId",\n unique: true,', 'column: "HeimdallReverseMoveId",\n unique: false,'),
('digest-includes-created-id', LEDGER, 'entry.Action == AccountMergeEntryAction.Created ? null : entry.RecordId.ToString("N")', 'entry.RecordId.ToString("N")'),
('proofseal-prints-bearer', SAGA, 'builder.Append($"From = {From?.Count}, To = {(To is null ? "none" : "present")}");', 'builder.Append($"From = {string.Join(",", From)}, To = {To}");'),
]
only=sys.argv[1:]
def run(filt,log):
env=dict(os.environ,DOTNET_USE_POLLING_FILE_WATCHER='1')
r=subprocess.run([*os.environ.get('DOTNET','dotnet').split(),'test','tests/Bifrost.Web.Tests','--filter',filt],cwd=WT,env=env,capture_output=True,text=True)
open(log,'w').write(r.stdout+r.stderr)
out=r.stdout+r.stderr
fails=sorted(set(re.findall(r'^\s*Failed (Bifrost\.\S+)',out,re.M)))
built='Build FAILED' not in out and 'error CS' not in out
return built,fails,out
res={}
for name,path,old,new in M:
if only and name not in only: continue
full=os.path.join(WT,path)
src=open(full).read()
pairs=old if isinstance(old,list) else [(old,new)]
if any(src.count(o)!=1 for o,_ in pairs):
print(name,'ANCHOR',flush=True); res[name]='anchor-miss'; continue
mutated=src
for o,n in pairs: mutated=mutated.replace(o,n)
open(full,'w').write(mutated)
try:
built,fails,out=run(FILTER,f'{LOGS}/{name}.log')
if not built: res[name]='BUILD-FAIL'
elif not fails: res[name]='SURVIVED'
else:
# rerun the first failing test in isolation
t=fails[0]; method=t.split('.')[-1].split('(')[0]
built2,fails2,_=run('FullyQualifiedName~'+method,f'{LOGS}/{name}.iso.log')
res[name]=('KILLED x%d; isolated %s: %s'%(len(fails),method,'still fails' if fails2 else 'PASSES ALONE (order-dependent)'))
finally:
open(full,'w').write(src)
print(name,res[name],flush=True)
json.dump(res,open(LOGS+'/result.json','w'),indent=1)
print('MUT-DONE',flush=True)