Beava Wire Spec
Status: Authoritative for v0. Engine and all SDKs (Python, TypeScript, Go) MUST conform to this spec. JSON Schema dialect: Draft 2020-12. Last reviewed: 2026-05-03 (Phase 13.0).
Overview
Beava speaks two transports — HTTP/1.1 + JSON for compatibility with curl, load balancers, WAFs, and any HTTP client; and a custom-framed TCP path for low-latency fast-path traffic. Both transports carry the same logical opcode set and the same JSON body shapes. Choosing one transport over the other is an operational decision (HTTP for reach + observability, TCP for tail latency); it is never a contract decision.
Correlation on the TCP transport follows Redis-style strict-FIFO: the order of responses on a connection matches the order of requests, and there is no request_id or correlation_id field anywhere in the wire format. This keeps the protocol simple, eliminates an entire class of header-bookkeeping bugs in client implementations, and makes the framed envelope as small as possible.
This document is authoritative. Where prose and JSON Schema disagree, the JSON Schema in examples/wire/schemas/ wins — schemas are machine-validatable contracts, prose is explanatory. Phase 13.4 ships a CI test (crates/beava-server/tests/wire_spec_validates.rs) that loads every schema and asserts every fixture under examples/wire/ validates against its corresponding schema. SDK ports in 13.5 (Python) and 13.6 (TypeScript + Go) consume the same fixtures via language-native validators.
The opcode-discovery question — "which family of body shape do I parse?" — is answered by the opcode in the TCP frame header (or the URL path on HTTP). Within a body, polymorphic shapes are disambiguated by a JSON kind discriminator. Specifically, OP_REGISTER carries a kind=event|table|derivation discriminator that selects between three sub-shapes; all other opcodes have a single body shape per direction.
Frame Format
The TCP transport frames every request and every response identically:
+---------------+---------------+----------------------+--------------------------------+
| length (u32) | op (u16) | content_type (u8) | payload |
| big-endian | big-endian | | length - 3 bytes |
| 4 bytes | 2 bytes | 1 byte | 0..(length-3) bytes |
+---------------+---------------+----------------------+--------------------------------+
Notes:
lengthis the size in bytes ofop + content_type + payload. It does not include itself. The smallest legal frame is therefore0x00000003(length=3, empty payload), which carries[length=3 op=XX content_type=YY]with no payload bytes.lengthis big-endian (network byte order). Same forop. There is no little-endian variant of the wire format anywhere in the v0 protocol.content_typeis a single byte selecting the payload encoding:0x01(CT_JSON) — JSON. The only encoding required in v0.0x02(CT_MSGPACK) — MessagePack. Reserved on the TCP transport for v0.1+; servers MAY accept it but clients MUST NOT rely on it being available.
- The HTTP transport always uses JSON for v0; the
content_typebyte is implicit (it lives in the HTTPContent-Type: application/jsonheader). - Maximum frame size is configurable; the server default is 4 MiB (
DEFAULT_TCP_MAX_FRAME_BYTES = 4 * 1024 * 1024). Frames that declarelength > maxare rejected withOP_ERROR_RESPONSEcarrying codeframe_too_large, and the connection is closed. - Correlation: Redis-style strict-FIFO on a connection. Clients send N requests, then read N responses in the same order. There is no
request_idfield in either request or response bodies. Pipelining is supported (multiple in-flight requests on one connection). - Errors use the dedicated opcode
OP_ERROR_RESPONSE = 0xFFFF. The payload is a JSON object matchingerror.schema.json. The connection stays open after a single-frame error (only that frame is rejected); fatal protocol errors close the connection.
The HTTP transport mirrors this opcode set: each opcode has a corresponding verb-style POST route (e.g., POST /push/<event_name> for OP_PUSH). The full HTTP route table lives in docs/http-api.md (Plan 13.0-03).
Opcode Table
| Opcode | Name | Direction | Body shape (JSON) | Notes |
|---|---|---|---|---|
0x0000 |
OP_PING |
client → server | {} |
Health probe. Response carries {server_version, registry_version}. |
0x0001 |
OP_REGISTER |
client → server | DAG payload | Discriminated union on kind: event | table | derivation. |
0x0010 |
OP_PUSH |
client → server | {fields: object} |
Sync push. Default ack is acks=1 (Kafka-style: durable on this server). The event name comes from the URL path (HTTP) or routing prefix (TCP). |
0x0011 |
OP_PUSH_SYNC |
RESERVED | — | Reserved for acks=all (multi-replica) push in v0.1+. v0 servers reply with op_not_implemented. |
0x0012 |
OP_PUSH_MANY |
RESERVED | — | Reserved for batch push in v0.1+. v0 servers reply with op_not_implemented. |
0x0020 |
OP_GET |
client → server | {table, key, features?} |
Single-row read. Returns row-shape (dict of feature → value). Cold-start returns {}. |
0x0023 |
OP_GET_RESPONSE |
server → client | row-shape body | Response opcode for OP_GET and OP_BATCH_GET. |
0x0024 |
OP_BATCH_GET |
client → server | {requests: [{table, key, features?}, ...]} |
Heterogeneous batch lookup. Response order matches request order. NEW in v0 (post-12.7) per ROADMAP §13.4. |
0x0030 – 0x003F |
(reserved) | — | — | Reserved range for future direct-feature-write opcodes (set / mset / similar). v0 servers reply with op_not_implemented. |
0x0040 |
OP_RESET |
client → server | {} |
Wipes all in-memory state and truncates WAL. Useful for tests and bv.test.fixture. Destructive — only call on a beava instance bound to test data. Per Phase 13.0 Q7. |
0xFFFF |
OP_ERROR_RESPONSE |
server → client | error envelope | Universal error reply. Payload schema: error.schema.json. |
The 6 v0 client-initiated opcodes (OP_PING, OP_REGISTER, OP_PUSH, OP_GET, OP_BATCH_GET, OP_RESET) are documented per-opcode in the sections below.
Content Types
Two content type bytes are defined for the TCP transport:
| Byte | Constant | Encoding | Status |
|---|---|---|---|
0x01 |
CT_JSON |
UTF-8 JSON | Implemented in v0. Required. Both transports. |
0x02 |
CT_MSGPACK |
MessagePack | Reserved for v0.1+ on the TCP transport. v0 servers MAY accept it (Phase 18-09 wired the codec); clients MUST NOT depend on it. |
The HTTP transport in v0 accepts only application/json request bodies and emits application/json responses. Content-Type other than JSON is rejected with unsupported_content_type.
Frames with an unknown content_type byte (anything other than 0x01 or 0x02) are rejected with the structured error code unsupported_content_type. The connection stays open — only the offending frame is rejected.
Per-opcode body shapes
Each section below declares the request and response body shape for a v0 opcode, cross-links to the JSON Schema, and points to a worked example fixture.
OP_PING (0x0000)
Health probe. Useful for liveness checks, transport-level keepalive, and version discovery.
Request body shape:
{}
JSON Schema: examples/wire/schemas/ping.request.schema.json
Worked example: examples/wire/ping-request.json
Response body shape (success):
{
"status": "ok"
}
JSON Schema: examples/wire/schemas/ping.response.schema.json
Worked example: examples/wire/ping-response.json
v0 ping is a minimal liveness probe — it returns {"status": "ok"} if the
server is reachable and processing requests. Version + registry-version
discovery (server_version, registry_version) is reserved for v0.1+;
for now, derive registry_version from any POST /register response or
the GET /registry admin endpoint.
Errors: OP_PING does not validate any input, so it has no per-opcode error codes. Connection-level errors (e.g., framing) still apply.
OP_REGISTER (0x0001)
Register one or more descriptors with the beava server. A descriptor is one of three kinds, disambiguated by the JSON kind field:
kind: "event"— declares a@bv.eventsource (an event type with a typed schema; events of this name push fields matching the schema).kind: "derivation"withoutput_kind: "table"— declares an aggregation-output table. Per ADR-001, the shorterkind: "table"form is the post-13.4 target; v0 emits thederivationform. The table receives rows materialised by the chainedgroup_by+aggops; it has noapp.upsert/app.delete/app.retractpaths in v0 (those stay killed byproject_v0_events_only_scope).kind: "derivation"withoutput_kind: "event"— declares a pure event-to-event transform (filter / select / with_columns chains). Theoutput_kindfield disambiguates whether the derivation emits events (push-shaped) or a table (key-shaped row materialisation, see above).
Per ADR-002, op names inside aggregation specs use the new Polars conventions (mean, var, std, n_unique, quantile) — not the old SQL-prose names (avg, variance, stddev, count_distinct, percentile).
Request body shape:
{
"nodes": [
{"kind": "event", "name": "Txn", "schema": {"fields": {...}, "optional_fields": []}},
{"kind": "derivation", "name": "UserFeatures",
"output_kind": "table",
"upstreams": ["Txn"],
"ops": [{"op": "group_by", "keys": ["user_id"], "agg": {...}}],
"schema": {"fields": {...}, "optional_fields": []},
"table_primary_key": ["user_id"]}
],
"force": false,
"dry_run": false
}
Top-level field name is
nodes(notdescriptors). The engine's JSON-prelude validator rejects payloads keyed underdescriptors. The Python SDK and the TS / Go SDKs all emit{"nodes": [...]}.
Per-entity table shape (v0 — what the engine accepts today): aggregation outputs are emitted as
kind: "derivation"withoutput_kind: "table", notkind: "table". Thegroup_by+agglive inside theopschain; the primary key lives at the node's top level astable_primary_key. ADR-001 partial-overturn reviveskind: "table"for aggregation-output in v0.1+ (the JSON-prelude shim amendment lands in a future phase); v0 ships thekind: "derivation"shape that the SDK already emits.
force=trueallows destructive schema changes (e.g., changing a field's type); the server accepts the change and zeroes affected aggregations. Default isfalse— destructive changes are rejected withregistration_conflict.dry_run=trueruns the validator and computes the diff without applying anything. The response carries the diff (added,removed,changed) but no state is mutated;registry_versionis unchanged.
JSON Schema: examples/wire/schemas/register.request.schema.json
Worked examples:
examples/wire/register-fraud-team.request.json— fraud-team-style payload using NEW op names (mean,n_unique,quantile).examples/wire/register-dry-run.request.json—dry_run=true.examples/wire/register-force.request.json—force=truefor destructive change.
Response body shape (success):
{
"status": "ok",
"registry_version": 1,
"added": ["Txn", "UserTxnFeatures"],
"already_present": [],
"registered_descriptors": ["Txn", "UserTxnFeatures"]
}
On idempotent re-register (same payload), added is empty and the
existing nodes appear in already_present. registered_descriptors
always lists every node currently in the registry (the union of added
and already_present for the just-applied payload).
Response body shape (dry-run):
{
"diff": {
"additive": [],
"destructive": []
},
"would_apply": false
}
diff.additive lists nodes that would be appended (no destructive impact);
diff.destructive lists nodes whose application would require force=true
(field-type changes, key changes, removals). would_apply is true iff
the diff is empty or entirely additive.
JSON Schema: examples/wire/schemas/register.response.schema.json
Worked example: examples/wire/register-fraud-team.response.json
Errors:
| Code | When | HTTP status |
|---|---|---|
unsupported_node_kind |
Body has kind="table" (pre-12.7 form) or kind="upsert"/"delete"/"retract" etc. — handled at the JSON-prelude validator. |
400 |
registration_conflict |
A descriptor changes a field type or removes a field without force=true. |
409 |
schema_invalid |
Descriptor structure does not conform to its schema (missing required field, wrong type). | 400 |
unknown_op |
agg.<feature>.op references an op name not in the operator catalogue. |
400 |
Worked example: examples/wire/register-conflict.error.json
Global tables (key = []) — per ADR-003
A register payload with key: [] (empty array) declares a global table — a single output row, no per-entity dimension. The sentinel key = "" (empty string) routes global state on the GET wire. Per ADR-003, every operator works in both per-entity and global modes — semantics are identical, only the state-keying dimension differs.
Use cases: monitoring dashboards (total throughput, current entity count, global p95), anomaly detection on global rates ("is the GLOBAL signup rate spiking?"), top-K-globally features ("top 10 hottest pages on the platform"), cross-entity aggregations ("total spend across all users").
Wire-level register payload (v0 derivation form — what the engine accepts today):
{
"kind": "derivation",
"name": "GlobalCounter",
"output_kind": "table",
"upstreams": ["Click"],
"ops": [{
"op": "group_by",
"keys": [],
"agg": {"click_count": {"op": "count", "params": {"window": "forever"}}}
}],
"schema": {"fields": {"click_count": "i64"}, "optional_fields": []},
"table_primary_key": []
}
The shorter
kind: "table"form (top-levelkey, top-levelagg) is the post-ADR-001 target shape; the JSON-prelude shim that accepts it lands in a later phase. v0 SDKs emit thekind: "derivation"form shown above.
Wire-level GET request for a global table:
{ "table": "GlobalCounter", "key": "" }
Cold-start GET response (no events landed yet):
{}
GET response after events land (flat row-shape per get.response.schema.json):
{ "click_count": 12345 }
See examples/wire/register-global-counter.request.json, examples/wire/get-global.request.json, and examples/wire/get-global.response.json for the full fixture set. The examples/wire/schemas/register.request.schema.json JSON Schema accepts key: [] (the minItems: 1 constraint is relaxed to minItems: 0 per ADR-003); examples/wire/schemas/get.request.schema.json accepts the empty-string key: "" sentinel for global GET.
Validation contract: key MUST be either non-empty (per-entity table) or empty array (global table) — never null. The server rejects null key at the JSON-prelude validator with schema_invalid. The corresponding GET MUST send key: "" for a global table; sending a non-empty key against a global table raises KeyError-style rejection (or returns {} cold-start, depending on the SDK convention; per Phase 13.5 the Python SDK raises). Symmetric: sending key: "" against a per-entity table is a misuse and the server returns {} cold-start (no error — the empty entity simply has no state).
OP_BATCH_GET accepts mixed per-entity + global lookups in the same batch (heterogeneous batches can include both shapes — global lookups simply set key to ""). See examples/wire/batch_get-heterogeneous.request.json for the per-entity variant; the global variant inside the same batch is {"table": "GlobalCounter", "key": ""}.
Implementation deferred to Phase 13.4 (engine sentinel routing — ~30 LOC; the existing &str key path handles "" natively, so this is mostly the absence of a special-case rejection) + Phase 13.5 (Python SDK no-key form: @bv.table no key=, events.group_by() empty, events.agg(**aggs) shorthand, App.get(table_name) 1-arg overload) + Phase 13.6 (TS + Go SDK overloads). Acceptance gate: python/tests/v0/test_global.py (Plan 13.0-16, 8 tests gated by _engine_available() SKIP until 13.4 + 13.5 land together).
OP_PUSH (0x0010)
Push a single event into a registered event source. Default ack semantics is acks=1 — the server returns success after the event is durably written to the local WAL (per the active sync mode; default is periodic fsync per Phase 6.1 SyncMode::Periodic).
The event name comes from the URL path on HTTP (POST /push/<event_name>) or from a routing prefix on the TCP transport. The wire body itself carries only the fields dict.
Request body shape:
{
"fields": {
"user_id": "alice",
"card_id": "card_001",
"amount": 42.50,
"merchant": "amazon",
"ip": "203.0.113.42"
}
}
The fields object MUST match the registered event's schema — same field names, compatible types. Type-coercion is allowed on the boundary (string "42" for an i64 field is accepted in v0 if the source is HTTP/JSON; TCP/JSON-encoded payloads follow the same rule).
ADR-002 op-rename note: pushed events are events, not aggregations, so op renames have no effect on push body shapes.
JSON Schema: examples/wire/schemas/push.request.schema.json
Worked example: examples/wire/push-success.request.json
Response body shape (success):
{
"ack_lsn": 12345,
"idempotent_replay": false,
"registry_version": 1
}
ack_lsn is the server-assigned monotonic Log Sequence Number for this event; clients can persist it as an idempotency anchor. idempotent_replay is true iff the push matched a prior dedupe_key within the registered dedupe_window — in that case the server returns the original push's ack_lsn instead of writing a new entry. registry_version is the server's monotonic register counter, useful as a staleness sentinel for SDK schema caches.
JSON Schema: examples/wire/schemas/push.response.schema.json
Worked example: examples/wire/push-success.response.json
Errors:
| Code | When | HTTP status |
|---|---|---|
schema_mismatch |
A field has the wrong type and cannot be coerced (e.g., string "abc" for a f64 field). |
400 |
missing_field |
A required field is missing from fields. |
400 |
event_not_found |
The event name (URL path or TCP routing prefix) is not registered. (Earlier docs called this code unknown_event; the engine emits event_not_found.) |
404 |
dedupe_replay |
A dedupe key matched a recent push within the dedupe window — server returns the prior ack_lsn with idempotent_replay: true (this is not an error in the operational sense; documented here for completeness). |
200 |
Worked example: examples/wire/push-validation-error.error.json
OP_GET (0x0020)
Single-row feature read. Returns the row-shape — a flat dict of feature name → value — for the requested (table, key) pair. Cold-start (no events have ever been pushed for that key) returns {} — not an error.
Request body shape:
{
"table": "UserTxnFeatures",
"key": "alice",
"features": ["tx_count_1h", "tx_sum_1h"]
}
tableis the table name registered withOP_REGISTER.keyis either a string (single-key tables) or a homogeneous array of[string|number|boolean]for composite-key tables. Composite keys are rendered into the array in the same order as the table'skeyfield.features(optional) — limits the response to a subset of the table's features. Omitting it returns all features for the row.
JSON Schema: examples/wire/schemas/get.request.schema.json
Worked example: examples/wire/get-found.request.json
Response body shape (success):
The response is the row-shape itself — a JSON object with feature names as keys.
{
"tx_count_1h": 7,
"tx_sum_1h": 312.45,
"tx_mean_1h": 44.64,
"tx_p99_1h": 89.99,
"tx_unique_merchants_1h": 3
}
Cold-start returns {}. The TCP-transport response opcode is OP_GET_RESPONSE = 0x0023; HTTP returns the same body with status 200.
JSON Schema: examples/wire/schemas/get.response.schema.json
Worked examples:
examples/wire/get-found.response.json— populated row.examples/wire/get-not-found.response.json— cold-start ({}).
Errors:
| Code | When | HTTP status |
|---|---|---|
unknown_table |
table is not a registered table name. |
404 |
feature_not_in_table |
features[i] is not a feature of the named table. |
400 |
key_shape_mismatch |
Composite key length / element types do not match the table's declared key. | 400 |
OP_BATCH_GET (0x0024)
Heterogeneous batch lookup. NEW in v0 (post-12.7) per ROADMAP §13.4. Equivalent to N parallel OP_GET calls in a single round-trip; the server processes them in order, and the response array preserves request-order.
Different table values can appear within the same batch. This is what makes the opcode heterogeneous — it is not a same-table-different-keys batch; it is a fully general batch.
Request body shape:
{
"requests": [
{ "table": "UserTxnFeatures", "key": "alice" },
{ "table": "UserTxnFeatures", "key": "bob" },
{ "table": "CardTxnFeatures", "key": "card_001", "features": ["tx_count_1h"] }
]
}
JSON Schema: examples/wire/schemas/batch_get.request.schema.json
Worked example: examples/wire/batch_get-heterogeneous.request.json
Response body shape (success):
{
"results": [
{ "tx_count_1h": 7, "tx_sum_1h": 312.45 },
{},
{ "tx_count_1h": 3 }
]
}
results[i] corresponds to requests[i]. Per-entry cold-start is {}, not an error. Per-entry errors (e.g., one bad key in an otherwise valid batch) DO turn the whole frame into OP_ERROR_RESPONSE — there is no partial success in v0; clients re-issue with the bad request removed.
JSON Schema: examples/wire/schemas/batch_get.response.schema.json
Worked example: examples/wire/batch_get-heterogeneous.response.json
Errors: Same set as OP_GET (unknown_table, feature_not_in_table, key_shape_mismatch), with the per-entry path field in the error envelope identifying which request entry tripped (e.g., "requests[2].table").
OP_RESET (0x0040)
Wipe all in-memory state and truncate the WAL. Destructive. Per Phase 13.0 Q7 the value is 0x0040, leaving 0x0030–0x003F reserved for future direct-feature-write opcodes (set, mset, etc.).
Use case: testing fixtures. bv.test.fixture and the BeavaTestServer harness reset between tests so the next test sees a clean slate. Production operators MUST NOT call OP_RESET on a beava instance bound to live data.
Request body shape:
{}
JSON Schema: examples/wire/schemas/reset.request.schema.json
Worked example: examples/wire/reset-request.json
Response body shape (success):
{
"registry_version": 0,
"reset": true
}
The server replies after the WAL truncation completes — the call is synchronous. The next event push to the same connection observes the cleared state. reset: true confirms the operation succeeded; registry_version reflects the post-reset registry counter (zeroed unless the registry persists across resets, which it does NOT in v0).
JSON Schema: examples/wire/schemas/reset.response.schema.json
Worked example: examples/wire/reset-response.json
Errors:
| Code | When | HTTP status |
|---|---|---|
reset_disabled_in_production |
Server is not in test mode. v0 OP_RESET requires the server to be booted with BEAVA_TEST_MODE=1 (or Config { test_mode: true }). Production operators leave this off; the call returns 403 with the structured code reset_disabled_in_production. |
403 |
wal_truncate_failed |
I/O error during WAL truncation. The server's state is undefined after this; restart recommended. | 500 |
Error Envelope
Every OP_ERROR_RESPONSE (and every HTTP non-2xx response) carries a JSON body conforming to error.schema.json:
{
"code": "<structured-code-string>",
"path": "<JSON-path-or-DAG-path>",
"message": "<human-readable-string>"
}
codeis a structured machine-readable identifier. Stable across releases. The canonical alphabetised list lives atdocs/error-codes.md(Plan 13.0-12).pathis an optional JSON path or DAG path locating the offending element. Examples:"nodes[1].schema.amount"(during register validation),"fields.amount"(during push),"requests[2].table"(during batch_get). Optional.messageis a human-readable explanation. Forward-looking framing per Phase 12.7 D-02 — messages say "X is not supported in v0", not "X has been removed" or "X was deprecated". The framing avoids implying a previous-version reference for users who never saw older revisions.
The error envelope is the SAME on both transports — TCP wraps it in a frame with op = 0x000A... no actually op = 0xFFFF (OP_ERROR_RESPONSE) and content_type = 0x01; HTTP returns it as the response body with the appropriate status code.
Worked example: examples/wire/register-conflict.error.json
ADR cross-references
This wire spec is shaped by the following Architecture Decision Records:
-
ADR-001 —
@bv.tableaggregation-output revival (partial overturn of v0 events-only scope). The post-13.4 wire form useskind=tablein register payloads per ADR-001; v0 today useskind=derivationwithoutput_kind=table, which the SDK already emits. Mutation paths (upsert/delete/retract) and MVCC remain killed. -
ADR-002 — Polars op renames. Register payloads use the new op-string names (
mean,var,std,n_unique,quantile). The Rust engine's internalAggKindenum variant names (AggKind::Avg,AggKind::Variance, etc.) are unchanged — only the public string mapping changes. SDKs in 13.5 (Python deprecation aliases) and 13.6 (TS + Go, no aliases) implement the full rename.
Validation harness (Phase 13.4)
The schemas under examples/wire/schemas/ and the worked examples under examples/wire/ are validated by a CI test that ships in Phase 13.4. Specifically:
-
Engine-side (Rust):
crates/beava-server/tests/wire_spec_validates.rs(lands in 13.4) loads everyexamples/wire/schemas/*.schema.jsonand asserts everyexamples/wire/*.json(excluding theschemas/subdirectory) validates against its corresponding schema. The Rust validator crate isboon— chosen because it has full Draft 2020-12 support; the olderjsonschemaRust crate has only partial 2020-12 coverage. -
Python SDK (Phase 13.5): the SDK test suite runs the same fixtures through Python's
jsonschemalibrary (Draft202012Validator) as part of its unit tests. The harness lives atexamples/wire/_validate_examples.pyand is the authoritative cross-language validation reference. -
TypeScript SDK (Phase 13.6): uses Ajv v8+ via
import Ajv2020 from "ajv/dist/2020"(Ajv splits Draft 2020-12 into a separate import to avoid bundling bloat). -
Go SDK (Phase 13.6): uses
santhosh-tekuri/jsonschema/v6, which supports Draft 2020-12.
Phase 13.0 (this phase) ships the schemas + examples + Python validator. The Rust engine harness ships in 13.4. The TS + Go validators ship in 13.6 alongside the SDK ports themselves.
Stable contract guarantees
- Frame layout is locked. Adding a request_id, changing endianness, or reordering header bytes is a breaking wire-format change requiring a
FORMAT_VERSIONbump; the v0 commitment isFORMAT_VERSION = 1. - Opcode values are locked. Opcodes assigned in this spec (PING, REGISTER, PUSH, GET, BATCH_GET, RESET, GET_RESPONSE, ERROR_RESPONSE) keep their values across all v0 minor releases.
- Body field names within a given opcode are locked once shipped. Adding optional fields is non-breaking; removing fields or changing their types is breaking.
- Error codes in
docs/error-codes.mdare stable identifiers. Renaming a code (e.g.,schema_mismatch→field_type_mismatch) is a breaking change. - JSON Schema dialect is
draft/2020-12for v0. Migrating to a future dialect requires explicit ADR.
What is not part of the stable contract:
- Internal wire details below the application layer (TCP keepalive cadence, HTTP/1.1 header set, connection-pool sizing).
- The exact HTTP status code for every error code beyond the broad 4xx-vs-5xx distinction (the structured
codefield is the contract; HTTP status is a hint). - The
server_versionreturned byOP_PING(semver discipline applies once v0 ships, but the value itself is informational, not contractual).
Plan-level traceability
This document is authored by Plan 13.0-02 (Wave 1). Downstream plans consume it:
- Plan 13.0-03 (
docs/http-api.md) writes the verb-style HTTP route table that mirrors this opcode set. - Plan 13.0-04 (
docs/sdk-api/*.md) writes per-language SDK API specs that target this wire format. - Plan 13.0-12 (
docs/error-codes.md) writes the alphabetised structured-code list referenced by thecodefield above. - Plan 13.0-14 (vertical examples) reuses the fixtures here as mock-backend response data.
- Phase 13.4 ships the engine and the Rust validator that asserts every fixture validates.
- Phase 13.5 / 13.6 ship the SDKs that send / receive frames matching this spec.
For the full Phase 13.0 plan tree, see .planning/phases/13.0-design-contract-spec-docs/13.0-PLAN.md.