Invariants: Endpoints and the wire¶
An endpoint's failure answer, the parity of endpoint, event and reason names between backend and panel, and what may travel on the wire. How an entry reads and binds, and the other areas: the register's index.
-
Endpoint failures use
{success, reason, message}(nevererror/error_code), and no module butmain.pybuilds that shape — a service raises its refusal, whichEndpointsanswers — check + test + prompt-onlyWith two known exceptions that compute a failing
successoutside it: the save-sync results (a completed sync with per-file errors,"success": len(errors) == 0, #2205 D10, and a Sync All that passed a game over for a missing saves folder, #2151 D4) and the removed-game cleanup'sprune_completeevent; anything an endpoint raises but a refusal or aRommApiErrorstays a transport errorscripts/check_failure_shape.py --check(every module underbackend/butmain.py,_vendor/andnative/: a dict literal with a literalFalse,0orNonesuccess, anerror_responsecall or a refusal-helper spread, event payloads included; unseen: a computedsuccess, the two exceptions among it, a failure without asuccesskey, a failure dict built without a literal),tests/test_endpoints_translation.py(on every route, a raisedRefused/DomainRefusedanswers that shape and any other exception staysbackend_exception; on onedefand oneasync defroute, aRommApiErroranswersclassify_error's reason and a returnedPartialFailureis serialized into it; every route is the translating wrapper). Prompt-only: no failure beyond those two computes itssuccess; the keysmain.pybuilds the shape with — no refusal's details carryerrororerror_code— and a further type joinsmain._TRANSLATEDonly by decision - Frontend↔backend endpoint parity (names + arity) — check —scripts/check_endpoint_parity.py- Every backendemitevent name has a frontend listener, and vice versa — check —scripts/check_event_parity.py- The panel'sWireReasonunion (frontend/src/types/reasons.ts) holds exactly the reasons an endpoint's failure answer can carry — refusals, partial failures andclassify_error's table — and itsRommErrorReasonsub-union exactly that table — check + prompt-only —scripts/check_reason_parity.py. The backend spells a reason where it refuses and the panel compares it with values of its own, so without the gate a reason renamed or added on one side reaches the other as a value no comparison matches, and nothing fails. The gate reads the backend's refusal constructions, named refusal classes, partial failures and the classifier by AST, resolving module constants,StrEnumvalues and a refusal class reached through an import alias or its module itself; a reason passed on from another refusal is accepted unread only where itsPASS_THROUGHSlist names it, and any other reason it cannot read fails. Its docstring is the one list of what it does not see and of what stays out of the union; unseen by it: a refusal class reached through an assignment alias (R = Refused),getattror as a value, another site in aPASS_THROUGHSfile with a listed site's source text, a module constant shadowed by a local, whether a counted reason can reach the wire, and whether a type that narrows the union names its endpoint's reasons truly. Prompt-only: a literal-typed endpoint-failure reason in the panel goes throughWireReasonSubset, so a reason outside the union does not compile — a convention, unchecked - A transport failure and an endpoint's own failure never arrive in the same shape, on either end — test + prompt-only —backend/host/protocol.pystates the vocabulary and.claude/rules/host.mdholds the backend half; the frontend half isfrontend/src/api/hostSocket.ts, which THROWSHostTransportErrorfor anerrormessage and resolves only areply, so a transport reason cannot reach a reader of{success, reason, message}.hostSocket.test.tspins both directions. Nothing joins the two ends:connection_lostandstranded_panelare the two reasons no backend ever sends — the caller's own socket answers with them — and each is spelled once in Python and once in TypeScript with no check that the two agree; so are the close codes 4001 and 4002 a stranded panel's upgrade is answered with. A frontend that spelled one differently would go green, and the divergence would surface only to whoever eventually matched on it — for a close code, on a device, as a stranded panel that keeps reconnecting. Prompt-only: the Python and TypeScript spellings ofconnection_lostandstranded_panel, and of the stranded-panel close codes 4001 and 4002, must agree — nothing holds them equal - No sentinel objects on the wire — explicit JSON-representable tagged values only — prompt-only — no sentinel survives on the wire today (NO_MIGRATIONretired with #1004, legacyslot:nullconfirmation with #1276), so the rule now guards reintroduction; nothing mechanical detects a new one