Context
Split out of the #95 review thread so the analysis does not evaporate when that PR merges. Pre-existing; #95 neither introduces nor touches it (verify.py is not in that PR's diff).
verify.read_frontmatter (verify.py:854) guards the decode fault but not the I/O fault:
def read_frontmatter(path: Path) -> dict[str, Any]:
if not path.is_file():
return {}
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
return {}
An OSError propagates — a permission fault, or a TOCTOU delete/replace landing between is_file() and read_text(). The window is real: specs are rewritten by the dev skill while the orchestrator polls them.
Unguarded call sites
Eight, none of which catch OSError:
engine.py:1829 (_reconcile_generic_terminal_status), engine.py:1865, engine.py:1897
sweep.py:1051
verify.py:1087, verify.py:1368, verify.py:1389, verify.py:1404
devcontract.py:227 (inside synthesize_result, reached from the Stop path)
The sole guarded caller is stories.resolve_story_spec (stories.py:327-334), which wraps it in except (OSError, UnicodeDecodeError) and degrades to status = "". That guard is therefore load-bearing today and must not be removed as "redundant".
Why the obvious fix is wrong
Do not widen read_frontmatter to except (OSError, UnicodeDecodeError).
A genuine disk or permission fault would then degrade silently to "status unknown → clean retry" across ~12 status gates in verify, engine, sweep, and stories — trading loudness for resilience on a fault that should be loud. A missing status and an unreadable disk are different conditions; only the first is safe to treat as "not finished yet."
Any fix belongs at the call sites, decided per gate: which of these twelve should surface a disk fault to the operator, and which should degrade? That is a real design pass, not a one-line widening.
Not affected
#95's _post_kill_reconcile wraps its _synth_result call in except (OSError, UnicodeDecodeError) -> return result, so the post-kill rescue keeps its verdict on any read fault and is immune to this by construction. This issue is about the Stop path only.
Related
Context
Split out of the #95 review thread so the analysis does not evaporate when that PR merges. Pre-existing; #95 neither introduces nor touches it (
verify.pyis not in that PR's diff).verify.read_frontmatter(verify.py:854) guards the decode fault but not the I/O fault:An
OSErrorpropagates — a permission fault, or a TOCTOU delete/replace landing betweenis_file()andread_text(). The window is real: specs are rewritten by the dev skill while the orchestrator polls them.Unguarded call sites
Eight, none of which catch
OSError:engine.py:1829(_reconcile_generic_terminal_status),engine.py:1865,engine.py:1897sweep.py:1051verify.py:1087,verify.py:1368,verify.py:1389,verify.py:1404devcontract.py:227(insidesynthesize_result, reached from the Stop path)The sole guarded caller is
stories.resolve_story_spec(stories.py:327-334), which wraps it inexcept (OSError, UnicodeDecodeError)and degrades tostatus = "". That guard is therefore load-bearing today and must not be removed as "redundant".Why the obvious fix is wrong
Do not widen
read_frontmattertoexcept (OSError, UnicodeDecodeError).A genuine disk or permission fault would then degrade silently to "status unknown → clean retry" across ~12 status gates in
verify,engine,sweep, andstories— trading loudness for resilience on a fault that should be loud. A missing status and an unreadable disk are different conditions; only the first is safe to treat as "not finished yet."Any fix belongs at the call sites, decided per gate: which of these twelve should surface a disk fault to the operator, and which should degrade? That is a real design pass, not a one-line widening.
Not affected
#95's
_post_kill_reconcilewraps its_synth_resultcall inexcept (OSError, UnicodeDecodeError) -> return result, so the post-kill rescue keeps its verdict on any read fault and is immune to this by construction. This issue is about the Stop path only.Related