Skip to content

fix(deferredwork): atomic ledger writes + surrogate-safe sanitizer (#328, #329) - #580

Merged
pbean merged 2 commits into
mainfrom
fix/328-329-ledger-write-safety
Aug 13, 2026
Merged

fix(deferredwork): atomic ledger writes + surrogate-safe sanitizer (#328, #329)#580
pbean merged 2 commits into
mainfrom
fix/328-329-ledger-write-safety

Conversation

@pbean

@pbean pbean commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Closes #328
Closes #329

Problem

Two hazards on the same code path, and they compound.

#328 — a failed write empties the ledger. Path.write_text opens the file 'w'
(truncate) and only then encodes and writes. Any failure inside that window — an
unencodable value, ENOSPC, EIO — leaves a zero-byte file where every hand-authored
deferred-work entry used to be. mark_done_many already wrote through
atomic_write_text; its siblings did not, and an asymmetric guard between siblings is
itself unreadable.

#329 — a lone surrogate defeats the sanitizer's never-raises contract.
_one_line collapses line breaks and documents "Sanitizes; never raises". A lone
surrogate is not a line break, so it sailed through untouched — and it has no UTF-8
encoding at all, so atomic_write_text's strict encode raises UnicodeEncodeError
(a ValueError subclass) from close paths that call these writers bare. The revival
chain is reachable, not theoretical: sweep._ensure_triage caches the session's
result.json with json.dumps, whose ensure_ascii keeps the code point a harmless
\ud800 escape on disk; a later cycle reloads it and json.loads revives the real
surrogate; validate_triage passes it through (.strip() does not remove it) into
ResolvedEntry.evidence; _close_resolved interpolates that into the mark_done
note, which reaches _one_line and then the encode.

The coupling. Before this branch, that same surrogate through an append writer did
both at once: the truncate had already landed when the encode raised, so the run
crashed and the ledger it was appending to went to zero bytes in a single call. #328
alone would keep the bytes but still crash the sweep; #329 alone would stop the crash
but leave every other failure mode (ENOSPC, EIO) able to empty the file. Fixing one
without the other leaves half the failure standing, which is why they ship together.
tests/test_deferredwork.py::test_append_entry_encode_failure_cannot_truncate_the_ledger
pins exactly that compounding.

Fix

src/bmad_loop/platform_util.py — new neutralize_surrogates(text): replaces every
code point in [\ud800-\udfff] with U+FFFD. Matched as a code-point range, not by an
encode round trip — a Python str holds astral characters as one code point, so U+1D11E
is never touched. Replace rather than strip (a vanished value reads as absent, not as
damaged) and rather than encode("utf-8", "replace") (that yields ?, indistinguishable
from one the author typed). Clean text returns the identical object, so a clean write
stays byte-identical. It lands next to atomic_write_text, the strict encode it exists
to protect.

src/bmad_loop/deferredwork.py_one_line runs neutralize_surrogates first, so
every free-text ledger field is covered at one chokepoint: append_decision's
label/detail, append_entry's title/origin/source_spec/reason/location,
and _apply_done's note. Both passes keep their own fast path, so a clean value is
scanned twice and copied never. Sanitizing stays in front of the idempotence scan, or a
replayed defer whose origin carries a surrogate would match nothing on disk and burn a
fresh id every run. append_decision and append_entry now write through
atomic_write_text, matching mark_done_many.

src/bmad_loop/engine.py_defer's post-rollback ledger restore writes through
atomic_write_text. A repair write must never be the thing that empties the file it
exists to put back.

src/bmad_loop/sweep.py_ensure_migration's migration-failure ledger restore
writes through atomic_write_text. _write_intent writes bundles/<dir>/intent.md
through atomic_write_text and neutralizes surrogates over the whole document rather
than per field — line breaks are legitimate markdown there, so _one_line's collapse
would be damage. The verbatim ledger blocks it re-attaches come from a strict-UTF-8 read
and pass through byte-unchanged.

Call sites stay bare — repair writes raise, never degrade.

Scope note

#328 names the append_decision/append_entry asymmetry against mark_done_many. The
two engine/sweep restore writes and the intent.md write were folded in with maintainer
approval: same hazard class, same one-line remedy, and leaving them out would have
recreated the asymmetry the issue is about one layer over. Five write sites in total,
enumerated in the Fix section. Nothing else in the diff.

Deliberately not touched: the json.dumps writes in sweep.py (manifest,
migrate-result.json, cached result.json, decision answers). Those are ASCII-only by
ensure_ascii and are machine-minted caches, not operator-curated state — that is the
very property that lets a surrogate hide on disk as an escape in the first place.

Visible behavior diffs

  • A ledger created from nothing by append_entry now lands mode 0600 instead of
    0644 & ~umask. atomic_write_text has no prior mode to carry over for a fresh
    target, so it keeps mkstemp's private mode — deliberate, per that helper's docstring,
    and the safer end for a file that can carry unreleased-finding text. Verified on this
    box under umask 022: oct(stat.S_IMODE(...)) == '0o600'.
  • A surrogate now renders as (REPLACEMENT CHARACTER) in ledger fields and in
    intent.md
    rather than crashing the write. A surrogate-only title therefore stays
    truthy, so append_entry's (untitled DW-<n>) substitution deliberately does not
    fire for it — the field says something unencodable was here instead of going silently
    blank.
  • Clean values are unchanged byte-for-byte: both sanitizer passes have a fast path, and
    atomic_write_text keeps write_text's translating-newline default, so line endings
    do not shift on Windows.

Scope of the durability claim: the ledger writers are now crash-free on text content.
That covers append_decision, append_entry, and the mark_done/mark_done_many/
mark_done_many_reopenable family — every caller-supplied free-text field they
interpolate routes through _one_line — plus mark_open, which interpolates no caller
text at all (it writes back a status line it decoded from hex under a strict UTF-8
decode). It is not a claim about all close paths or all writes in the orchestrator:
the JSON caches above and every writer outside deferredwork.py are untouched and
unaudited here.

Testing

Full suite green locally (uv run pytest -q -n auto: 5388 passed, 44 skipped, 5 xfailed),
uv run pyright 0 errors, trunk check --all clean.

Every guard below was ablated — the fix reverted in place, the target run, the file
restored from a cp backup (never git checkout, which would have taken the rest of the
branch with it). One line of evidence each; the tree was verified pristine afterwards.

#328 — atomic writes

Test Ablation evidence
test_deferredwork.py::test_append_decision_write_failure_raises_and_keeps_the_ledger append_decision → bare write_text: Failed: DID NOT RAISE OSError (the write no longer routes through the patched helper).
test_deferredwork.py::test_append_entry_write_failure_raises_and_keeps_the_ledger append_entry → bare write_text: Failed: DID NOT RAISE OSError.
test_deferredwork.py::test_append_entry_encode_failure_cannot_truncate_the_ledger Same ablation: assert b'' == b'# Deferred ...pec-2-3.md)\n' — the raise still happens, the bytes are what reddens.
test_engine.py::test_defer_ledger_restore_write_failure_propagates_and_keeps_the_ledger _defer restore → bare write_text: assert (False) … crashed=False — the failure never reaches the run.
test_sweep.py::test_migration_restore_write_failure_propagates_and_keeps_the_ledger _ensure_migration restore → bare write_text: assert (False) … crashed=False. Compares the restored ledger as text, not bytes — the fixture reaches disk via write_text, so a byte assertion would read CRLF and redden on Windows only.

#329 — surrogate neutralization. Wiring and predicate are separate ablation axes and
were ablated separately.

Test Ablation evidence
test_platform_util.py::test_neutralize_surrogates_replaces_only_lone_surrogates (10 rows) Predicate — helper → identity: 5 surrogate rows fail with UnicodeEncodeError; clean, empty, astral-untouched, mixed-non-ascii, range-boundaries stay green, which is what proves the range is not over-broad.
test_platform_util.py::test_neutralize_surrogates_returns_clean_text_untouched Pins the is-identity fast path, so a clean ledger write stays byte-identical.
test_platform_util.py::test_neutralize_surrogates_makes_atomic_write_text_survive_a_surrogate Predicate — helper → identity: UnicodeEncodeError … surrogates not allowed.
test_deferredwork.py::test_append_entry_writes_a_finding_carrying_a_lone_surrogate Wiring — drop the neutralize_surrogates call from _one_line: UnicodeEncodeError: 'utf-8' codec can't encode character '\ud800'. No monkeypatching in the test — the value survives a real writer, a real strict encode and a real strict read back.
test_deferredwork.py::test_append_decision_writes_a_label_and_detail_carrying_a_lone_surrogate Same ablation, same UnicodeEncodeError.
test_deferredwork.py::test_mark_done_writes_a_note_carrying_a_lone_surrogate Same ablation, same UnicodeEncodeError. Uses the exact string sweep._close_resolved builds.
test_deferredwork.py::test_append_entry_idempotence_survives_sanitizing (3 rows) Same ablation: the two surrogate rows redden, the line-break row stays green — sanitizing must stay ahead of the idempotence scan for both passes, not just the older one.
test_sweep.py::test_write_intent_neutralizes_surrogates_and_keeps_the_markdown Wiring — drop neutralize_surrogates from _write_intent: UnicodeEncodeError. Also asserts keep�this\n\nsecond para — the paragraph break either side survives, so the document-wide pass is not collapsing markdown.

No POSIX-only assertions were added. The 0600 mode diff is asserted nowhere in the
suite (mode semantics differ on Windows); it is documented above from a local probe and
in atomic_write_text's docstring.

Summary by CodeRabbit

  • Bug Fixes
    • Prevented failed deferred-work and bundle updates from truncating existing ledger data.
    • Improved recovery when ledger restoration fails, preserving the original data.
    • Sanitized unsupported characters in ledger entries, decisions, notes, and bundle intents while preserving formatting.
    • Newly created ledger files now use private permissions.

t added 2 commits August 13, 2026 13:45
…h atomic_write_text (#328)

`Path.write_text` opens 'w' (truncate) and only THEN encodes, so any failure in
that window — an unencodable value, ENOSPC, EIO — left a zero-byte ledger with
every deferred-work entry gone. `mark_done_many` already wrote through
`atomic_write_text`; its siblings did not, and an asymmetric guard between
siblings is itself unreadable.

Five write sites now build their replacement beside the target and replace
atomically, so a failure raises with the original intact:

- deferredwork.append_decision — the decision record
- deferredwork.append_entry — the new-entry append (creates the ledger)
- engine._defer — the post-rollback ledger restore
- sweep._ensure_migration — the migration-failure ledger restore
- sweep._write_intent — the run-dir bundles/<dir>/intent.md write

Visible diff beyond durability: a ledger `append_entry` creates from nothing now
lands mode 0600 (mkstemp's private mode) instead of the umask default —
deliberate, per the helper's docstring, and the safer end for a file that can
carry unreleased-finding text.

The call sites stay bare: repair writes must raise, never degrade.
…the sanitizer chokepoint (#329)

`_one_line` collapses line breaks and documents a stronger contract than it
delivered — "Sanitizes; never raises". A lone surrogate defeated that without
ever being a line break: it has no UTF-8 encoding at all, so `atomic_write_text`'s
strict encode raises `UnicodeEncodeError`, a `ValueError` subclass, from inside
the close paths that call these writers bare. That is precisely the outcome the
sanitizer exists to prevent.

The revival chain is real and reachable, not theoretical:

- `sweep._ensure_triage` caches the session's result.json with `json.dumps`, whose
  `ensure_ascii` keeps the code point a harmless `\ud800` escape on disk
- a later cycle in the same method reloads it and `json.loads` revives the actual
  surrogate
- `validate_triage` passes it (`.strip()` does not remove it) into
  `ResolvedEntry.evidence`
- `_close_resolved` interpolates that into the `mark_done` note, which reaches
  `_one_line` and then the encode

`neutralize_surrogates` lands next to `atomic_write_text`, whose strict encode it
exists to protect, and `_one_line` runs it first — so every free-text ledger field
is covered at one chokepoint (`append_decision` label/detail, `append_entry`
title/origin/source_spec/reason/location, `_apply_done` note), and the ledger is
strict-UTF-8-read, so nothing already on disk can carry one in.

`sweep._write_intent` gets the same pass over the whole document rather than per
field: it writes run-dir intent.md with no sanitization today, and the same triage
prose reaches it. Line breaks are deliberately kept there — that file is markdown,
so `_one_line`'s collapse would be damage. The verbatim ledger blocks it re-attaches
are read from a strict-UTF-8 file and pass through byte-unchanged.

Each surrogate becomes U+FFFD, not the `?` that `encode("utf-8", "replace")` would
yield: the replacement stays visible and unambiguous, where a `?` is indistinguishable
from one the author typed and a strip would let the value vanish silently. A
surrogate-only title therefore stays truthy, so `append_entry`'s `(untitled DW-<n>)`
substitution deliberately does not fire for it.

Sanitizing stays in front of the idempotence scan, so a replayed defer whose origin
carries a surrogate still matches the stored line instead of burning a fresh id.
Refusing the text upstream at `validate_triage` was the alternative and only moves
the stoppage to a pause — the same doctrine #305 settled on, so the same answer.
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7538ddac-41c0-4965-bd90-525cc1aca00b

📥 Commits

Reviewing files that changed from the base of the PR and between c353ba4 and 2bce331.

📒 Files selected for processing (9)
  • CHANGELOG.md
  • src/bmad_loop/deferredwork.py
  • src/bmad_loop/engine.py
  • src/bmad_loop/platform_util.py
  • src/bmad_loop/sweep.py
  • tests/test_deferredwork.py
  • tests/test_engine.py
  • tests/test_platform_util.py
  • tests/test_sweep.py

Walkthrough

The change adds centralized lone-surrogate replacement and switches deferred-work ledger and bundle-intent persistence to atomic writes. Regression tests cover encoding failures, filesystem failures, data preservation, line-break handling, and migration restoration.

Changes

Deferred-work persistence

Layer / File(s) Summary
Surrogate sanitization
src/bmad_loop/platform_util.py, src/bmad_loop/deferredwork.py, tests/test_platform_util.py, tests/test_deferredwork.py
neutralize_surrogates replaces lone surrogates with U+FFFD. Ledger field writers apply the sanitization while preserving existing line-break handling.
Atomic ledger writes
src/bmad_loop/deferredwork.py, src/bmad_loop/engine.py, tests/test_deferredwork.py, tests/test_engine.py
Ledger append operations and deferred-ledger restoration use atomic_write_text. Tests verify that write failures preserve existing ledger content.
Bundle and migration persistence
src/bmad_loop/sweep.py, tests/test_sweep.py, CHANGELOG.md
Bundle intent text is sanitized and written atomically. Migration restoration uses atomic writes. Tests cover replacement, Markdown line breaks, and failed restoration. The changelog records these changes and private permissions for new ledgers.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Mergeability Score: ⚪ Minimal · up to 2bce3

The change is merge-ready after normal checks and review; no actionable merge-blocking risk remains.

Possibly related issues

  • #286 — The atomic ledger and restore-path changes address the non-atomic writes described in this issue.
  • #379 — The PR addresses the listed non-atomic ledger writes and adds surrogate sanitization.

Suggested reviewers: dracic

Poem

A rabbit guards the ledger tight,
With atomic hops through silent night.
Lone surrogates turn to �,
Markdown keeps its breaks in view.
No failed write can steal the page.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 58.97% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the two main changes: atomic ledger writes and surrogate-safe sanitization.
Linked Issues check ✅ Passed The changes satisfy #328 with atomic append writes and satisfy #329 with surrogate replacement in _one_line and related close paths.
Out of Scope Changes check ✅ Passed The changes remain within the linked issue objectives and directly related deferred-work write and sanitization paths.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/328-329-ledger-write-safety

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pbean

pbean commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

CI note, so the red-then-green history on this PR is not mistaken for a fixed defect
here: the first run's test (windows, py3.11) failed on
tests/test_tui_app.py::test_poll_skips_while_another_holds_the_lock with
textual.worker.WorkerCancelled — the only failure in a 5258 passed job.

That test is unrelated to this diff (this branch touches no TUI code; git diff over
src/bmad_loop/tui/ and tests/test_tui_app.py is empty for the branch), and
test (windows, py3.14) passed it in the same run on the same commit. Re-running that
one job on the same commit passed. Diagnosis and remedy directions are filed as #581,
which this PR leaves open — the diff was not widened to reach it.

All 10 checks are green as of 2bce331.

@pbean

pbean commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Already looking forward to the next diff.

Reviewed commit: 2bce331a96

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@pbean

pbean commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: 2bce331a96

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@pbean
pbean merged commit 62445a6 into main Aug 13, 2026
20 of 21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant