fix(fix): write governed files atomically and state the run-level contract - #126
Merged
Merged
Conversation
…tract Every write in internal/fix was a bare os.WriteFile, which truncates the target and then writes it. A process that dies in between leaves a governed document truncated — in a state its own schema rejects — and the only record of how far the run got was JSON on stdout. A concurrent reader could see it too: a probe polling a document during repair apply caught 20-21 half-written states per run against the previous code. Writes now stage into a sibling temp file and rename over the target, so a file is only ever observed as its old self or its new self. The staging file is created in the target's own directory because os.Rename across filesystems fails, its mode is set explicitly because os.CreateTemp makes files 0600, and every failure path removes it. This covers all thirteen write sites: repair.go's five, including the rollback-restore in postValidateWrittenTargets, where a restore that half-succeeded would be strictly worse than the write it was undoing; and fix.go's eight, which the fix --all pipeline uses to rewrite both documents and .stem schemas. The regression test is self-validating rather than merely probabilistic. It runs a cheap control pass with a bare truncating write first, and only asserts on the real path once the control has proved this machine can observe tearing at all; otherwise it reports itself inconclusive instead of passing on no evidence. The run-level contract is stated rather than left to be guessed, and it is deliberately NOT all-or-nothing. Buffering every rewrite until the last proposal succeeded would discard the good repairs in a report because of one unreadable path, and would still not be atomic against a kill, since the flush is itself many writes. Best-effort with an exact account of what happened is both more useful and more honest than a guarantee that cannot be kept. So both envelopes gain `complete`: true exactly when the run carried through everything it accepted, which is exactly when the command exits 0. It is not redundant with the exit status — a report saved as a CI artifact is read long after $? is gone, and a consumer should not have to re-derive the rule. One seal() per result type sets it once, after every phase has had its say, so it cannot disagree with the fields it summarizes; a test binds it to the exit status per case so the two cannot drift. Fixes #61
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Every write in
internal/fixis now atomic, and the run-level contract is stated instead ofleft to be guessed. This is the last remaining sub-defect of issue #61.
Related issue
Fixes #61
Residue re-derivation (done before writing any code)
Issue #61 names three sub-defects. Two of them already shipped and are green on
master;only the third still reproduced. Each was tested against
master@2ccc7c0first:TestRepairApplyExitStatus+TestSchemaApplyExitStatuspass. Shipped by #96.repair applyobserved 20-21 half-written states per run, reproducible across every run.resolveReportRoot+apply_report_root_test.gopass. Shipped by #104.Why sub-defect 2 was still open: PR #107 carried this exact fix and was
CLOSEDat2026-08-06T01:40:12Z— two seconds after #104 merged at01:40:10Z.That is the
gh pr merge --delete-branchcollateral-close of a stacked child, not a reviewrejection. Its reasoning was sound and is reused here, with the scope and the test
strengthened (below).
Only sub-defect 2 is implemented. Nothing green was rewritten.
How
Per-file atomicity. New
fix.WriteFileAtomic(target, content, perm)stages the bytes in asibling
.rootline-*.tmpfile,Syncs,Chmods and renames over the target. A file istherefore only ever observed as its old self or its new self.
Three details that are load-bearing rather than incidental:
os.Renameacrossfilesystems fails.
permis applied explicitly, becauseos.CreateTempcreates its file0600— without theChmodthe staging file's mode would silently become the document's mode.found it.
Scope is wider than #107's. It replaces all thirteen production write sites in the
package, not five:
repair.go(5), including the rollback-restore inpostValidateWrittenTargets. That onematters most: a restore that half-succeeded would be strictly worse than the write it was
undoing.
fix.go(8) — thefix --allpipeline, rewriting both documents and.stemschemas. Thesewere covered by neither repair apply and schema apply exit 0 on failure, are not atomic, and disagree on how to resolve report paths #61's original text nor follow-up Bare os.WriteFile in migrate, init and templates can leave truncated .stem files #106, so leaving them would have
fixed half a package.
The run-level contract, stated — and deliberately not all-or-nothing. A run that fails
partway leaves the files it already wrote in place. This is a decision, not an omission:
a report because of one unreadable path.
Best-effort plus an exact account of what happened is more useful, and more honest, than a
guarantee that cannot be kept. Documented as a named "Atomicity contract" section in
docs/fix.mdand mirrored into.claude/skills/rootline/.completein the envelope. Both result types gain a boolean: true exactly when the runcarried through everything it accepted, which is exactly when the command exits
0. It is notredundant with the exit status — a report saved as a CI artifact is read long after
$?isgone, and a consumer should not have to re-implement the rule:
One
seal()per result type sets it once, after every phase has had its say, so it cannotdisagree with the fields it summarizes. A test binds it to the exit status per case, so the
envelope and the shell cannot drift apart.
The regression test is self-validating, not merely probabilistic
Atomicity has no honest black-box RED test in isolation: a read-only directory makes the
atomic version fail while
os.WriteFilesucceeds, so that scenario inverts the signal. Thetest that does work is a concurrent reader polling a ~300KB governed document while rewrites
run —
os.WriteFiletruncates before it writes, so the first state it exposes is a zero-lengthfile.
A timing-dependent test that only ever asserts "I saw nothing" is worth little, so it does not
stop there.
TestApplyRepairIsObservablyAtomicruns a cheap control pass with a baretruncating write first. The control must observe tearing (it reliably sees 25-29 half-written
states); only then does the assertion against the real repair path run. If the control sees
nothing, the machine has no power and the test declares itself inconclusive via
t.Skiprather than passing on no evidence.
The asymmetry is deliberate: the test can never fail spuriously red, only skip.
Cost was tuned rather than ignored. The naive version took 55s under
-raceand dominatedthe fast cycle; splitting the rounds (control cheap and many, experiment expensive and few)
brings
internal/fixfrom 1.7s to 7.2s.Verification beyond the test suite
The issue's own reproductions were re-run against a build of this branch:
The document was restored to its original bytes and no
.rootline-*.tmpdebris was left inthe governed directory in either run.
Semver
fix— not breaking, matching the judgment already reviewed on #107. Behaviour on the happypath is identical; this closes a durability gap and adds one envelope field.
One edge case is worth naming rather than burying:
WriteFileAtomicfails whereos.WriteFilesucceeded — writing into a read-only directory that holds a writable file,since staging needs to create a sibling. That is narrow and arguably a case repair should not
have been serving anyway, but it is a real divergence. If you would rather ship it as
fix!,say so before squashing and I will retitle.
Scope notes
cmd/rootline/migrate.go,cmd/rootline/init.goandinternal/templates/fetch.gois out of scope and tracked in Bare os.WriteFile in migrate, init and templates can leave truncated .stem files #106.Checklist
just test—go test ./... -race, all 14 packages ok)just check— gofmt + golangci-lint0 issues+ build)just coverage-check— total 89.4%,internal/fix87.2%, no package below the 85% floor)docs/fix.md"Atomicity contract",CLAUDE.md,.claude/skills/rootline/ref-advanced.md)