Skip to content

[gauntlet:reference-ec2-vpc/day2_crash] destroying a deposed object recorded no tombstone, so its lingering tag was a second claimant - #943

Merged
lex00 merged 3 commits into
mainfrom
gauntlet/reference-ec2-vpc-day2_crash
Sep 7, 2026
Merged

[gauntlet:reference-ec2-vpc/day2_crash] destroying a deposed object recorded no tombstone, so its lingering tag was a second claimant#943
lex00 merged 3 commits into
mainfrom
gauntlet/reference-ec2-vpc-day2_crash

Conversation

@lex00

@lex00 lex00 commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

The unit

reference-ec2-vpc/day2_crash, tracked as #938. The stage is planned, so
it is not on either headline bar and gauntlet next does not name it; the
estate reads clear either way.

The five-row class

Row 1: choudoufu refuses where stock proceeds. Stock's state file names
the surviving object outright and replans the estate without a word. This
fork refused it, totally, until AWS forgot the terminated instance:

Error: Two live resources claiming one address

2 live aws_instance resources carry estate "ec2-reference" and address
"aws_instance.main" at once: i-557a11917316ed797, i-c3fd6bf4eb2de323a. A
human has to resolve the collision before this estate can be planned

The bisect

Three PRs merged into the window between eec6fb4282 (last pass) and
22d9a3f099 (fail). Done by reading, not by assuming the lead:

PR what it touches verdict
#932 (12ab0e5493) identity.Class handler tables in projection/command/mv/liveimport/lint not it. A dispatch refactor; writeback.go is untouched and no tombstone or deposed path moves.
#935 (c6aebde9f5) discovery's double-sighting dedupe not it. It merges ONE live object seen by two enumeration legs. The two claimants here are two distinct instance IDs, which no dedupe can or should merge.
#920 (001fd5dd8a, issue #901) writeback.go's tombstone gate it, and it is correct about the apply it fixed.

git diff 001fd5dd8a^1 001fd5dd8a -- internal/live/projection/writeback.go
is the whole change: deposedMayStillHold was added to the switch that
decides whether a replace records a destroyed identity.

What was wrong

#920 fixed H1 and uncovered H2. The stage's three applies:

Before #920 this passed for the wrong reason: H1 wrote a tombstone for a
deposed-and-alive object, which #901 is right to call a lie about a live
resource. The fix is not to put that lie back.

The generic rule

An apply that destroys a deposed object records that identity as destroyed
by this estate, on two facts and no type name:

  1. the plan's own Delete of that deposed keyWriteBackRequest.DestroyedDeposed,
    derived by backend/local's new destroyedDeposedInstances(plan) exactly
    as tombstones: derive the replace signal from the plan, not from the record's identity changing (import and mv wording) #854's replace set is derived by replacedInstances(plan). The two
    partition the plan's destroy evidence: IsReplace() already excludes a
    deposed-key change, and this takes only deposed-key Deletes.
  2. the final state no longer carrying the key as deposed — because a plan
    verb says a destroy was scheduled, which is precisely the gap tombstones: a create_before_destroy replace whose destroy fails records a live deposed object as destroyed #901
    exists for. A destroy leg that fails again leaves the key in ri.Deposed
    and records nothing.

The identity written is the one the record already holds under that key,
rendered on the crashed apply's own write-back by LocatedRecordFrom. So the
rule reaches every recordable type by copying a payload an existing writer
produced rather than deriving a second one, and it is called from the one
place both write-back loops already share, beside diffDeposedForWrite.

How many types it reaches. No aws_* name appears anywhere in the change,
and no entry is needed in live/derivation_guard_test.go's registry. Measured
against a real hashicorp/aws 6.59.0 GetProviderSchema response
(pluginschema.Acquire, throwaway probe, not committed): of 1699 managed
resource types, 1462 have a recordable identity plan (90 composite, 1372
scalar) — that is the population whose deposed entry carries an identity, and
therefore the population this rule can write an entry for. The remaining 237
render no identity, and addTombstoneEntry's own empty check leaves them
silent, which is the same "unproven, so stay quiet" direction
deposedMayStillHold takes.

Red, then green

Both directions, so neither guard is one that cannot fail.

With tombstoneDestroyedDeposed returning 0 (main's behaviour):

--- FAIL: TestWriteBackDestroyingADeposedObjectRecordsItAsDestroyed
    after the apply that destroyed the deposed object the record names [] as
    destroyed by this estate, want exactly ["eipassoc-00112233445566778"].
--- FAIL: TestWriteBackDeposedDestroyRecordsOnlyTheKeyThePlanNamed
    the record names [] as destroyed, want exactly ["eipassoc-00112233445566778"]
--- FAIL: TestDiscover_crashWindowClosedLeavesOneClaimant
    after the recovery apply the record names [] as destroyed by this estate,
    want [vpc-old] - GitHub issue #938

With the still-deposed guard removed (i.e. #901's suppression broken):

--- FAIL: TestWriteBackDeposedDestroyThatFailedAgainRecordsNothing
    a deposed destroy that failed again recorded [eipassoc-00112233445566778]
    as destroyed, want none
--- FAIL: TestDiscover_crashWindowStillOpenKeepsBothClaimants
    a deposed destroy that did not happen recorded map[vpc-old:...] as
    destroyed: vpc-old is deposed and still running

With destroyedDeposedInstances' deposed filter inverted:

--- FAIL: TestWriteBackSeesTheDeposedDestroySetAfterApply
    WriteBack was handed the deposed-destroy set [], want
    [test_instance.foo deadbeef].

internal/live/discovery/crashwindowclose_test.go is deliberately the only
test in that package that builds its record by running
projection.WriteBack instead of seeding one. Every seeded superseded-claimant
test passed throughout this regression, because the shape the write half
actually produced after a crashed replace was one no seeded test ever asked
for: no deposed entry, and no tombstone either.

The estate: reproduce, then fix

Not just unit tests. With tombstoneDestroyedDeposed neutered to return 0 and
everything else identical, on this same worktree and emulator:

GAUNTLET stage=day2_crash verdict=fail duration_s=37 detail=the post-recovery plan exited 1

and with the fix in:

GAUNTLET stage=day2_crash verdict=pass duration_s=35

Before (main's row, written by #939)

"day2_crash": "fail"   detail: "the post-recovery plan exited 1"

After (this branch, all thirteen verdict lines)

GAUNTLET stage=cold_deploy verdict=pass
GAUNTLET stage=greenfield verdict=pass
GAUNTLET stage=migrate verdict=pass
GAUNTLET stage=test_plan verdict=pass
GAUNTLET stage=test_apply verdict=pass
GAUNTLET stage=drift_reconverge verdict=pass
GAUNTLET stage=plan_approval verdict=pass
GAUNTLET stage=day2_replace verdict=pass
GAUNTLET stage=day2_rename verdict=pass
GAUNTLET stage=day2_remove verdict=pass
GAUNTLET stage=day2_count verdict=pass
GAUNTLET stage=day2_crash verdict=pass
GAUNTLET stage=strict verdict=pass

Nothing moved backwards. The board's day2_crash tally goes 0 pass / 1 fail /
26 not_run to 1 pass / 0 fail / 26 not_run; the two headline bars stay at core
26 of 26 and all 27 of 27, since this stage is planned.

The rebase onto #939 conflicted on live/gauntlet.json and the three rendered
files. Resolved by taking main's copies wholesale and re-running the estate,
never by hand-merging.

#920's open question: is the read side's deposed-over-tombstone ordering still needed?

Yes, and it is now a compatibility leg rather than a live one.

pruneSupersededEntry keeps a claimant that matches a deposed record even
when a tombstone names the same object. After this change the write side can
no longer produce that record from one estate's own applies: the entry is
written only for a key that has LEFT ri.Deposed, and diffDeposedForWrite
deletes that key inside the same mutate closure, so deposed-and-tombstoned is
not a state a current build reaches.

What still reaches it is what #920's own comment named: a record written by an
estate running an older build (every pre-#920 crashed apply wrote exactly that
shape), a hand-edited record, or a future writer. The ordering costs one
refusal when it is wrong and prevents a live object being pruned out of a
collision set when it is right, so it stays.
TestDiscover_crashWindowStillOpenKeepsBothClaimants now pins the live
direction of it end to end.

Reproduce

git checkout gauntlet/reference-ec2-vpc-day2_crash
go build -o .bin/choudoufu ./cmd/choudoufu
TOFU_BIN="$PWD/.bin/choudoufu" go run ./tools/gauntlet run reference-ec2-vpc
grep 'GAUNTLET stage=' live/gauntlet/logs/reference-ec2-vpc.log

Unit level:

go test -count=1 ./internal/live/projection/ ./internal/live/discovery/ ./internal/backend/local/

Testing

gofmt -l clean, go build ./..., go vet ./..., and
go test -count=1 ./internal/live/projection/ ./internal/live/discovery/ ./internal/live/check/ ./live/ ./tools/gauntlet/ ./internal/backend/local/ ./internal/live/marksafe/ all green. gauntlet check reports rendered files
current.

Refs #938, #901, #920, #670, #854, #908, #939.

🤖 Generated with Claude Code

https://claude.ai/code/session_018QSftBd7j3Dp4mnB6feRKL

lex00 and others added 3 commits September 6, 2026 21:52
…sed object records it as destroyed (#938)

HANDOFF row 1: choudoufu refuses where stock proceeds. reference-ec2-vpc's
day2_crash H3 refused with "Two live resources claiming one address" naming
the instance H2 had just terminated.

Bisect, by reading: of the three merges that landed between eec6fb4
(passing) and 22d9a3f (failing), only #920 (001fd5d, issue #901)
touches this path - #932 is an identity.Class handler-table refactor in
projection/command/mv/liveimport and #935 is discovery's double-sighting
dedupe of ONE object seen by two enumeration legs, which cannot merge two
distinct instance IDs. #920 is correct about the apply it fixed and left the
apply after it uncovered:

  H1 (crashed create_before_destroy replace) records no tombstone, because
  the deposed object is alive - #901, and right.
  H2 (the recovery apply) destroys it. Its plan schedules a deposed destroy,
  not a replace, so ReplacedAddrs is empty; the address's recorded identity
  has named the new object since H1, so identitySuperseded is false. Neither
  of supersedeIdentity's two facts holds, and the identity this estate's own
  apply terminated is recorded nowhere.
  H3 reads the terminated object's lingering tag as a second live claimant.

The generic rule, on the write side and with no type name in it: an apply
that destroys a deposed object records that identity as destroyed by this
estate, from the plan's own Delete of that deposed key
(WriteBackRequest.DestroyedDeposed, derived by backend/local's
destroyedDeposedInstances exactly as #854's replace set is) AND the final
state no longer carrying the key as deposed. The identity written is the one
the record already holds under that key, rendered by LocatedRecordFrom on the
crashed apply's own write-back, so this reaches every recordable type by
copying a payload rather than deriving a second one.

#901's suppression holds unchanged at the second apply too: a destroy leg
that failed again leaves the key in ri.Deposed and records nothing.

Red proof, with tombstoneDestroyedDeposed returning 0 (main's behaviour):

    --- FAIL: TestWriteBackDestroyingADeposedObjectRecordsItAsDestroyed
        after the apply that destroyed the deposed object the record names []
        as destroyed by this estate, want exactly
        ["eipassoc-00112233445566778"].
    --- FAIL: TestDiscover_crashWindowClosedLeavesOneClaimant
        after the recovery apply the record names [] as destroyed by this
        estate, want [vpc-old] - GitHub issue #938

and with the still-deposed guard removed, the other direction:

    --- FAIL: TestWriteBackDeposedDestroyThatFailedAgainRecordsNothing
        a deposed destroy that failed again recorded
        [eipassoc-00112233445566778] as destroyed, want none
    --- FAIL: TestDiscover_crashWindowStillOpenKeepsBothClaimants
        a deposed destroy that did not happen recorded map[vpc-old:...] as
        destroyed: vpc-old is deposed and still running

internal/live/discovery/crashwindowclose_test.go is deliberately the only
test in that package that builds its record by RUNNING projection.WriteBack
rather than seeding one: every seeded superseded-claimant test passed
throughout, because the shape the write half actually produced after a
crashed replace was one no seeded test ever asked for.

Refs #938, #901, #920, #670, #854, #908.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018QSftBd7j3Dp4mnB6feRKL
…stroy set reaches WriteBack on a real apply (#938)

TestWriteBackSeesTheReplaceSetAfterApply's twin for the new signal: a real
plan and graph walk over a prior state in the crashed-replace shape (current
object plus a deposed one), asserting the set WriteBack is handed by rendered
value.

Proven red by inverting destroyedDeposedInstances' deposed filter:

    --- FAIL: TestWriteBackSeesTheDeposedDestroySetAfterApply
        WriteBack was handed the deposed-destroy set [], want
        [test_instance.foo deadbeef].

Measured, not assumed: unlike #908's replace set, a deposed Delete SURVIVES
lr.Core.Apply in plan.Changes - computing the set at the WriteBack call site
still passes this test, because the apply-side deposed node does not call
writeChange(nil, key). The computation sits beside replacedInstances(plan)
anyway; the test's doc comment says exactly this so nobody reads it as a
drain guard it is not.

Refs #938, #908.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018QSftBd7j3Dp4mnB6feRKL
…ay2_crash fail -> pass (#938)

Re-measured on top of #939's all-27 re-measure, at f6b0554 against the
pinned emulator
(ghcr.io/lex00/floci@sha256:a39185cc3971d0188663d61043cb038dff1260d8a975b1aa72c4e2bb1feac3cb),
oracle terraform 1.16.0 / tofu 1.12.6.

Main's row for this estate, written by #939, read day2_crash=fail with detail
"the post-recovery plan exited 1". This run's verdict lines:

  GAUNTLET stage=cold_deploy verdict=pass
  GAUNTLET stage=greenfield verdict=pass
  GAUNTLET stage=migrate verdict=pass
  GAUNTLET stage=test_plan verdict=pass
  GAUNTLET stage=test_apply verdict=pass
  GAUNTLET stage=drift_reconverge verdict=pass
  GAUNTLET stage=plan_approval verdict=pass
  GAUNTLET stage=day2_replace verdict=pass
  GAUNTLET stage=day2_rename verdict=pass
  GAUNTLET stage=day2_remove verdict=pass
  GAUNTLET stage=day2_count verdict=pass
  GAUNTLET stage=day2_crash verdict=pass
  GAUNTLET stage=strict verdict=pass

The only stage that moved is day2_crash, fail -> pass; the board's day2_crash
tally goes 0 pass / 1 fail / 26 not_run to 1 pass / 0 fail / 26 not_run, and
the two headline bars are unchanged at core 26 of 26 and all 27 of 27 (this
stage is planned, so it is not counted in either).

The rebase conflict on live/gauntlet.json and the three rendered files was
resolved by taking main's copies wholesale and re-running the estate, never
by hand-merging - CLAUDE.md, "Measured artifacts are never hand-merged".

Refs #938, #939, #903.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018QSftBd7j3Dp4mnB6feRKL
@lex00
lex00 merged commit 06798df into main Sep 7, 2026
3 checks passed
@lex00
lex00 deleted the gauntlet/reference-ec2-vpc-day2_crash branch September 7, 2026 04:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant