Found while sweeping for copies of #375. That issue fixed one non-atomic read-modify-rewrite in install._worktree_local_exclude; the sweep found the pattern in roughly ten more places.
Path.write_text opens "w", which truncates before writing. Where the code first READS a file, merges, then writes the whole thing back, a fault partway through (ENOSPC, EIO, quota) leaves the file truncated mid-content and destroys pre-existing content.
platform_util.atomic_write_text is the established fix and is already used at 14 sites — it resolves symlinks, uses an in-directory mkstemp (unique name), fsyncs before the replace, and copies mode + xattrs.
The recurring shape: one side of a sibling pair is hardened, the other is not
This is the notable part — in most cases the same file already knows the answer.
| # |
Site |
Its own hardened sibling |
What a torn write costs |
| B1 |
deferredwork.py:396 append_decision, :499 add_entry |
mark_done_many (:361) uses atomic_write_text on the same ledger, with a docstring explaining why |
every logged item, plus id-space corruption — next_seq() derives ids from surviving text, so the next entry reuses a burned DW-n |
| B2 |
engine.py:3485 |
engine.py:2522 — same ledger restore, same class, atomic |
ledger entries |
| B3 |
install.py:672→:682, worktree_flow.py:270→:281 |
— |
the operator's .claude/settings.json: permission allowlist, env, other hooks, MCP entries. Truncated JSON then fails to parse, so the next init prints "is not valid JSON; fix it and re-run init" — the tool destroys the file, then tells the human to repair it. No backup |
| B4 |
sprintstatus.py:224→:247 |
— |
AGENTS.md's sole write path to the board. YAML cut at a mapping boundary still parses, yielding a valid but smaller board — epics silently cease to exist |
| B5 |
frontmatter.py:385→:393, verify.py:998→:1006 |
devcontract.py:443 _atomic_write_spec writes the same spec files byte-verbatim AND atomically |
layout is before + edited + after, so a short write leaves intact frontmatter saying status: done over a truncated body — a spec that lies, which the loop then commits |
| B6 |
verify.py:594→:627 (policy.toml restore) |
policy.py:1323 writes it atomically |
truncated TOML usually still parses, silently reverting trailing tables to defaults |
| B7 |
sweep.py:788 |
— |
the ledger recovery path, explicitly for "an untracked ledger that git reset cannot restore" — the one write that must not itself tear |
| B8 |
engine.py:1936→:1967 (park-record restore) |
forward path at operatoractions.py:165/:226 is atomic |
park record |
On B5 specifically
frontmatter.py:10 documents the omission as a deliberate trade-off — byte-preserving, "a torn write is a separate concern". The repo disproves that twice over: devcontract._atomic_write_spec achieves byte-verbatim (write_bytes) and atomic on the same files, and records its measured fault injection — "reduced a 46-byte spec to 12." The import-layering rule the comment cites is also already breached by both modules it names (devcontract.py:30 imports platform_util; stories.py reaches it via deferredwork.py:21). Worth re-deciding rather than inheriting.
Cleared
install.py:919 (guarded write-only), install.py:927 (open("a"), correct), probe.py:634/:644, cli.py:2497/:2609, machine.py:139, both data/*_hook.py (confirmed os.replace), all run-dir artifact writes, unity plugin.
Suggested order
B1 and B2 first — one-line swaps to a helper those files already import. Then B4/B5 (silent semantic corruption: a smaller board, a lying spec), then B3, B6, B7, B8.
All pre-existing; none introduced by #373 or #376.
Found while sweeping for copies of #375. That issue fixed one non-atomic read-modify-rewrite in
install._worktree_local_exclude; the sweep found the pattern in roughly ten more places.Path.write_textopens"w", which truncates before writing. Where the code first READS a file, merges, then writes the whole thing back, a fault partway through (ENOSPC, EIO, quota) leaves the file truncated mid-content and destroys pre-existing content.platform_util.atomic_write_textis the established fix and is already used at 14 sites — it resolves symlinks, uses an in-directorymkstemp(unique name), fsyncs before the replace, and copies mode + xattrs.The recurring shape: one side of a sibling pair is hardened, the other is not
This is the notable part — in most cases the same file already knows the answer.
deferredwork.py:396append_decision,:499add_entrymark_done_many(:361) usesatomic_write_texton the same ledger, with a docstring explaining whynext_seq()derives ids from surviving text, so the next entry reuses a burnedDW-nengine.py:3485engine.py:2522— same ledger restore, same class, atomicinstall.py:672→:682,worktree_flow.py:270→:281.claude/settings.json: permission allowlist, env, other hooks, MCP entries. Truncated JSON then fails to parse, so the nextinitprints "is not valid JSON; fix it and re-run init" — the tool destroys the file, then tells the human to repair it. No backupsprintstatus.py:224→:247frontmatter.py:385→:393,verify.py:998→:1006devcontract.py:443_atomic_write_specwrites the same spec files byte-verbatim AND atomicallybefore + edited + after, so a short write leaves intact frontmatter sayingstatus: doneover a truncated body — a spec that lies, which the loop then commitsverify.py:594→:627(policy.tomlrestore)policy.py:1323writes it atomicallysweep.py:788git resetcannot restore" — the one write that must not itself tearengine.py:1936→:1967(park-record restore)operatoractions.py:165/:226is atomicOn B5 specifically
frontmatter.py:10documents the omission as a deliberate trade-off — byte-preserving, "a torn write is a separate concern". The repo disproves that twice over:devcontract._atomic_write_specachieves byte-verbatim (write_bytes) and atomic on the same files, and records its measured fault injection — "reduced a 46-byte spec to 12." The import-layering rule the comment cites is also already breached by both modules it names (devcontract.py:30importsplatform_util;stories.pyreaches it viadeferredwork.py:21). Worth re-deciding rather than inheriting.Cleared
install.py:919(guarded write-only),install.py:927(open("a"), correct),probe.py:634/:644,cli.py:2497/:2609,machine.py:139, bothdata/*_hook.py(confirmedos.replace), all run-dir artifact writes, unity plugin.Suggested order
B1 and B2 first — one-line swaps to a helper those files already import. Then B4/B5 (silent semantic corruption: a smaller board, a lying spec), then B3, B6, B7, B8.
All pre-existing; none introduced by #373 or #376.