Skip to content

deferredwork: ledger mutators write free text verbatim — a multiline note/detail injects lines (sanitize at chokepoint + validate_triage feedback, not raise) #305

Description

@pbean

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-150apply_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:

  1. 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.
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:deferred-workdeferred-work.md ledger and sweepbugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions