Summary
The deferred-work ledger is line-oriented, and every mutator in deferredwork.py writes its
string arguments verbatim: mark_done_many/_apply_done (resolution: <note>),
append_decision (decision: <date> <label> — <detail>), append_entry (every field line). A
value containing a newline injects arbitrary lines into the ledger — a note of
fixed\n### DW-99: fake\nstatus: open mints a phantom entry; a mid-entry injection can displace
the real status: line. CodeRabbit flagged the location case on PR #274, and @Haven2026's
commits there generalized it (prior art — see below).
This is real: two live paths feed LLM-authored strings into these writers today —
sweep.py:911 — _close_resolved passes triage evidence into mark_done's note
(f"already resolved: {entry.evidence}").
decisions.py:146-150 — apply_pre_answer passes triage resolution/intent as the decision
detail and close note.
validate_triage (sweep.py:132) only str()s and .strip()s these fields; a multiline JSON
string sails through.
Why not raise in the mutators (the PR #274 commits 2–4 approach)
Raising ValueError deep in the writer turns bad LLM output into a mid-sweep crash on the
close path. The ledger-close doctrine is the opposite: closes are never a gate and never crash the
run (see deferred-work-format.md, "never allowed to fail the story or crash the run"). The same
input handled at the schema boundary instead becomes feedback the session retries on.
There is also a placement trap: validation on mark_done alone is inert — engine.py:2308 (the
#234 story-close path) calls mark_done_many directly.
Design
Two layers, matching where each kind of value comes from:
- Schema boundary (degrade → feedback):
validate_triage (sweep.py:97-136) rejects
multiline evidence, resolution, intent (and any other free-text field it accepts) by
appending to errors — the existing error path already re-prompts the sweep session with
feedback instead of crashing.
- Writer chokepoint (sanitize, never raise, for free text): in
deferredwork.py, collapse
line breaks (\n, \r, and the Unicode set \v \f \x1c-\x1e \x85 \u2028 \u2029) to a single
space for free-text arguments at the single points they are serialized: the note in
_apply_done, label/detail in append_decision, title/reason (and location if present by
then) in append_entry. This keeps a value that slipped past every upstream check from
corrupting the ledger, while the write still lands.
- Orchestrator-owned fields may raise:
date (always self._today()), status, severity
in append_entry are programmer-supplied; a bad value there is a bug, and a ValueError is an
acceptable assert-style guard. Whitelists: status open / done YYYY-MM-DD; severity
critical|high|medium|low (matches SEVERITY_ALIASES normalization output).
Tests to carry over/adapt from PR #274: the injection-shaped inputs ("fixed\n### DW-99: ...",
the \u2028 variants) asserting the ledger is unchanged or sanitized (not that a raise
happened, for free-text fields), plus ablation checks that each guard actually fires at the
chokepoint the engine path exercises (mark_done_many, not just the mark_done wrapper).
Prior art / credit
PR #274 commits 9b2efe2f, 582e5662, 9c6d5c86 by @Haven2026 implement the raise-style version
of this against a pre-#300 deferredwork.py; the finding originates from that PR's review. Being
split out of #274 so the flat-append fix (#304) can land on its own — @Haven2026, if you want to
take this one too with the design above, it's yours.
Summary
The deferred-work ledger is line-oriented, and every mutator in
deferredwork.pywrites itsstring arguments verbatim:
mark_done_many/_apply_done(resolution: <note>),append_decision(decision: <date> <label> — <detail>),append_entry(every field line). Avalue containing a newline injects arbitrary lines into the ledger — a note of
fixed\n### DW-99: fake\nstatus: openmints a phantom entry; a mid-entry injection can displacethe real
status:line. CodeRabbit flagged thelocationcase on PR #274, and @Haven2026'scommits there generalized it (prior art — see below).
This is real: two live paths feed LLM-authored strings into these writers today —
sweep.py:911—_close_resolvedpasses triageevidenceintomark_done's note(
f"already resolved: {entry.evidence}").decisions.py:146-150—apply_pre_answerpasses triageresolution/intentas the decisiondetail and close note.
validate_triage(sweep.py:132) onlystr()s and.strip()s these fields; a multiline JSONstring sails through.
Why not raise in the mutators (the PR #274 commits 2–4 approach)
Raising
ValueErrordeep in the writer turns bad LLM output into a mid-sweep crash on theclose path. The ledger-close doctrine is the opposite: closes are never a gate and never crash the
run (see
deferred-work-format.md, "never allowed to fail the story or crash the run"). The sameinput handled at the schema boundary instead becomes feedback the session retries on.
There is also a placement trap: validation on
mark_donealone is inert —engine.py:2308(the#234 story-close path) calls
mark_done_manydirectly.Design
Two layers, matching where each kind of value comes from:
validate_triage(sweep.py:97-136) rejectsmultiline
evidence,resolution,intent(and any other free-text field it accepts) byappending to
errors— the existing error path already re-prompts the sweep session withfeedback instead of crashing.
deferredwork.py, collapseline breaks (
\n,\r, and the Unicode set\v \f \x1c-\x1e \x85 \u2028 \u2029) to a singlespace for free-text arguments at the single points they are serialized: the note in
_apply_done, label/detail inappend_decision, title/reason (and location if present bythen) in
append_entry. This keeps a value that slipped past every upstream check fromcorrupting the ledger, while the write still lands.
date(alwaysself._today()),status,severityin
append_entryare programmer-supplied; a bad value there is a bug, and aValueErroris anacceptable assert-style guard. Whitelists: status
open/done YYYY-MM-DD; severitycritical|high|medium|low(matchesSEVERITY_ALIASESnormalization output).Tests to carry over/adapt from PR #274: the injection-shaped inputs (
"fixed\n### DW-99: ...",the
\u2028variants) asserting the ledger is unchanged or sanitized (not that a raisehappened, for free-text fields), plus ablation checks that each guard actually fires at the
chokepoint the engine path exercises (
mark_done_many, not just themark_donewrapper).Prior art / credit
PR #274 commits
9b2efe2f,582e5662,9c6d5c86by @Haven2026 implement the raise-style versionof this against a pre-#300
deferredwork.py; the finding originates from that PR's review. Beingsplit out of #274 so the flat-append fix (#304) can land on its own — @Haven2026, if you want to
take this one too with the design above, it's yours.