AetheriaEve's smoke test (text match against its own emit text), and Huginn's tests (text match).
- Stale vendored TS:
-`StreamPixels/vendor/CultLib` carries the current closed schema, so any change here breaks its
TS error decode until it is re-vendored.
-`Heimdall/vendor/CultLib` carries a pre-R-X schema with `additionalProperties:false` and no
`routingHint`, so it already refuses every error C# sends today.
- Odin, Idunn, Muninn, Ghostlight and voidbot: zero hits.
## 1. Ends
- **One schema.** `contracts/cultnet/cultnet.error.schema.json` is the only authority on the shape and on the
code registry. C# embeds it (`GameCult.Networking.csproj:19`), TS mirrors it at build time, and Rust
vendors a copy held byte-equal by test. Nothing else publishes a `cultnet.error.v0` shape (F-1 covers
Python).
- **Every runtime emits only schema-valid errors.** This is proven per emit path against the schema, and
byte-equal to the C# reference for every registry code.
- **Every runtime decodes every error it can receive into a typed value.** A known code becomes its typed
variant with its typed details. An unknown code, if Q1 says so, becomes a typed "other" that keeps the
code string and the raw details. No well-formed error is ever refused, and no field is silently dropped
(routingHint, messageId).
- **Code and details have one owner.** The details a message carries are decided by its code. The types in
every runtime make "cursor_stale with a field" unrepresentable. No free-standing code string plus a
grab-bag details object.
- **A refusal is an answer, not a silence** (R-2 precedent, and Q4). A server that refuses a peer's request,
and can still send, sends a coded error that correlates to the request.
- **The public surface is complete in every runtime**, per the consumer-expectation doctrine:
- a typed error value;
- a constructor per registry code;
- a typed exception or error that request APIs raise when the peer refuses, carrying the typed value.
## 2. Identity, lifecycle and authority
| Kind | What names it | Lifecycle | Who decides |
|---|---|---|---|
| Error message | `schemaVersion: cultnet.error.v0`; correlated to its request by `messageId` (Q3) | Sent once, in reply to one request or subscription. Never persisted. | The server that refused. |
| Error code | A snake_case string in the schema's registry (`$defs/knownCode`) | Added by one coordinated change: schema, C# constructor, a C#-written vector, and a typed variant in every runtime. Never renamed and never reused. A retired code stays in the registry marked retired, so an old reader still decodes it. | The operator rules meaning; Self keeps the registry. |
| Details for a code | The code | Frozen with the code. Adding a key to an existing code's details is a new code (or a v1), never an in-place edit (see Q2). | Same as the code. |
| Unknown code, at a reader | The raw string | Preserved as typed `Other`. The reader acts on the `error` text and the absence of a known code. | The reader (Q1). |
| Vector file | `contracts/cultnet/interop/error-vectors.cs-written.json` | Regenerated only by the C# writer test under `CULTNET_WRITE_VECTORS=1` and committed. Every other runtime judges it read-only. | C#, the reference. |
| routingHint | Shard authority refusal | Sent with a null code (unchanged). Typed in every decoder. | CultNetDatabaseServer's authority path. |
- Python: constructors per code over a frozen dataclass.
The churn is one time. The five existing C# cases with details change bytes, and all eight Rust hex
cases move into the shared vector file anyway (Cut 1).
- **C. Opaque string map.** Untyped; no runtime can offer a typed accessor.
**Recommended: B.** A is the churn the brief warned of, and it recurs on every future code. B pays it once,
at the moment every vector is being recaptured regardless.
Depends on this: the schema's `details`, the C# model and formatter, `encode_error_details`
(`packages/cultnet-rs/src/contracts.rs:1339-1366`), and every decoder.
### Q3. Does an error carry the `messageId` of the request it refuses?
Python already sends `messageId` (`server.py:583`); the schema forbids it. Without it:
-`cultmesh-browser` drops an error whenever two operations are pending (`index.ts:724-731`);
- C# content sessions fail every pending request on any error (`CultMeshContentSessions.cs:256-260`).
Adding a top-level key changes the bytes of every C# error. That happens once, in the same recapture as Q2.
- **A.** Add a nullable top-level `messageId`. A reply to a request echoes the request's id, and an error
with no request (a subscription push, an unsolicited refusal) sends nil. Receivers correlate by it and
fall back to their current behavior when it is nil.
- **B.** No correlation. Python deletes the field.
**Recommended: A.** Correlation is a capability any multiplexing client needs, and one runtime already
depends on it.
### Q4. Do servers send `document_unservable` (and every other refusal they can) to the peer?
Self ruled that "unservable" must be distinguishable as a typed code in every runtime. On the branch:
Python sends it coded, TypeScript sends it codeless, and Rust tells only its local caller, so the Rust
peer times out. The same Rust silence covers every other document-server rejection: a sink refusal, a
failed snapshot source, too many snapshot documents, and a snapshot response that is too large.
- **A.** Every refusal of a peer's request that the server can still send is answered with a coded error
carrying the request's `messageId`, and is also reported locally (Rust's `ApplicationRejected` stays as
the local report). The refusals that cannot be answered are the ones where the answer itself cannot be
queued: Rust `PayloadBudgetFull` and `ResponseQueueFailed`. Those stay local-only, and the doc says so.
- **B.** Local-only in Rust; peer replies only where a runtime already sends them.
**Recommended: A.** R-2 already ruled that a silence is worse than a missing feature
(`docs/cultnet-selection-cut.md:2049-2053`), and the operator's ruling that CultMesh "refuses" puts only
means something to the party that sent the put.
### Q5. Does Python's `unregistered_document_put` / `unregistered_document_delete` become `unowned_schema`?
Python sends these when the node has no registered document type for the record's schemaId
(`cultmesh_py/node.py:653-666`). C# `unowned_schema` means "no shard owns this schema and key"
(`CultNetSchemaMessages.cs:537-544`). To a peer both mean "this server will not take that schema here; route
elsewhere". The put and delete variants duplicate what the peer already knows (it sent the message).
- **A.** Both collapse into `unowned_schema { schemaId }`. The registry text broadens to "this server
stores no document for that schema (and key)".
- **B.** Join as a separate `schema_unregistered { schemaId }`.
- **C.** Join both codes as they are.
**Recommended: A.** It is one meaning at the peer, and there is one action to take.
### Q6. Does Python's `malformed_document_put` / `malformed_document_delete` become one `message_invalid { field }`?
These are sent when the request lacks a required field (`server.py:355-359`, `383-387`). C# has the same
condition, codeless, at `CultNetDatabaseServer.cs:373/415` and `CultNetDatabaseSubscriptionServer.cs:148`
("requires a subscriptionId or messageId").
- **A.** One `message_invalid { field }`, naming the missing or ill-typed field. It is used by Python's four
sites and C#'s three.
- **B.** Join both per-message codes and leave C# codeless.
**Recommended: A.** The per-message suffix repeats the request's own schemaVersion, and C# gains a code for
free.
### Q7. Does `simulation_observations_disabled` become `unsupported_schema_version`?
Python sends it when the observation hub is off (`server.py:593-598`), with
`details { schemaVersion: "cultnet.simulation_observation.v0" }`, which is exactly the
`unsupported_schema_version` shape. cultmesh-ts's "no cache for snapshot requests" and "no operation
handler" refusals (`index.ts:5875`, `5913`) mean the same thing: this server does not serve that message.
- **A.** Collapse. `unsupported_schema_version` means "this server does not serve messages of this
schemaVersion: unknown, a newer version, or not enabled here". The three TS sites and Huginn's "answers
only X, Y, Z" adopt it.
- **B.** Keep a separate `feature_disabled`-style code.
**Recommended: A.** A peer cannot act differently on "not enabled" and "not known". Python's
`verify.py:335` check follows the rename.
### Q8. Is this a correction of `cultnet.error.v0` in place, or a new `cultnet.error.v1`?
Under Q2-B and Q3-A, strict v0 readers (TS Ajv and vendored copies) refuse the new bytes. Tolerant readers
(C#, Rust, Python) keep working, because the probes show they ignore unknown keys and treat a missing key
as null.
- **A. In place.** The v0 contract has never been decodable across all five runtimes (section 0). No
consumer outside CultLib reads code or details (finding 10). Every publishing package takes the semver
bump its policy requires:
-`org.gamecult.cultlib` major, because the public `CultNetErrorDetails` properties change;
-`cultnet-py` and `cultmesh-py` major, because emitted codes change;
- Rust pins move deliberately.
StreamPixels and Heimdall re-vendor.
- **B. v1.** Add `cultnet.error.v1`, keep v0 emit for peers that did not advertise v1 in hello, and run
two error encoders in every runtime until v0 retires.
**Recommended: A.** B builds a version-negotiation path for a message nobody outside CultLib parses beyond
`.error`. The one thing every old reader does need, the `error` text, survives A in every tolerant runtime.
## 4. The registry (as recommended; rewrite on each ruling)
| Code | Meaning | Details (per Q2-B; `?` = present-as-nil when absent) | Emitted by after this map |
|---|---|---|---|
| `selection_invalid` | A selection field failed the door. | `{ field, value? }` | C#, Rust (selection) |
| `cursor_stale` | The cursor predates the server's retained history. | `{ asOf, current }` | C#, Rust |
| `cursor_invalid` | The cursor could not be parsed. | none (details nil) | C#, Rust |
| `reference_outside_target` | A hop reference left the declared target. | none | C#, Rust |
| `variant_unsupported` | The selection selects a document variant CultNet does not carry. | `{ subscriptionId? }` | C# (incl. `DatabaseServer.cs:560`) |
| `unowned_schema` | This server stores no document for that schema (and key). | `{ schemaId }` | C#, Python (Q5) |
| `unsupported_schema_version` | This server does not serve messages of this schemaVersion. | `{ schemaVersion }` | Python, TS, Huginn (Q7) |
| `message_invalid` | The request lacks a required field or has an ill-typed one. | `{ field }` | C#, Python (Q6) |
| `snapshot_document_limit_exceeded` | The snapshot answer would exceed the document bound. | `{ documentCount, maxSnapshotDocuments }` | Python, Rust (Q4) |
| `snapshot_byte_limit_exceeded` | The snapshot answer would exceed the byte bound. | `{ responseBytes, maxSnapshotBytes }` | Python, Rust (Q4) |
| `document_unservable` | A put was refused because no snapshot could ever return the stored document under the byte bound. | `{ responseBytes, maxSnapshotBytes }` | Python, TS, Rust (Q4) |
A null code is legal and means "unclassified". Examples: an exception message from a failed snapshot
source, an authority refusal carrying `routingHint`, and "not authorized"
(`CultNetDatabaseSubscriptionServer.cs:165`). This map does not invent codes for those.
`snapshot_byte_limit_exceeded` and `document_unservable` have identical details. That is deliberate, and it
is the fixture trap named in the verification rules.
---
## 5. Cuts
All cuts are made on one campaign branch, `hands/error-contract`, from `main` after
`hands/cultmesh-put-serve-bound` merges. The branch merges to `main` once, after Cut 5's Soul pass. The
reason: Cut 1 changes C# bytes, and Rust's static hex cases would stay green against the stale bytes
(finding: `tests/error_message.rs` pins constants, not the reference). A partial merge would ship a
wire split while every suite passed. Each cut gets its own worktree, created by Self. Heavy verification
runs through `ygg-verify.sh`, never on Starfire.
### Cut 0. Unblock the interop lane
- **Repo/branch:** CultLib, `hands/interop-guard` from `main`, merged alone ahead of the campaign. It
does not depend on the rulings.
- **Deletes first:** nothing.
- **Per-file changes:** `.github/workflows/cultnet-interop.yml:72`. The `$stray` filter also accepts
`^packages/cultnet-rs/contracts/cultnet/`, with a comment naming R-AB and
`packages/cultnet-rs/tests/schema_discovery_contracts.rs:74-99` as the equality guard. The namespace
check is unchanged.
- **Authority map:** no ownership change. The canonical owner stays `contracts/cultnet/`, and the Rust
mirror is derived from it, with drift judged by the Rust test.
- **Verification:**
- Push the branch and run the workflow. Report every step that goes red after the guard. None of those
steps has run on main for 40 runs, so expect more red, and treat each failure as its own finding;
do not fix it in this cut.
- Negative: edit one byte of a vendored copy locally and check that the Rust equality test fails.
Reach it on Yggdrasil with `cd packages/cultnet-rs && cargo test --test schema_discovery_contracts`.
- **Ledger:** +3 lines.
### Cut 1. The contract and the C# reference
- **Repo/branch:** CultLib `hands/error-contract`, worktree named by Self.
- **First:** capture the current C# bytes for all eight factory cases, plus `cs_codeless` and `cs_routing`,
into the Hands report. This is the before-image a Soul pass compares against.
- **Deletes first:**
-`CultNetErrorDetails` (`src/GameCult.Networking/CultNetSchemaMessages.cs:588-603`, 16 lines) as a
shared record.
- The `field`/`value`/`asOf`/`current` property block and the `[MessagePackObject]`/`[Key]` attributes
on `CultNetErrorMessage` (`:497-521`). Its serialization moves to one formatter.