Skip to content

Non-atomic read-modify-rewrite in ~10 places; a short write truncates ledgers, specs, and the board #379

Description

@pbean

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 corruptionnext_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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions