← 1.0.0 scope SAPE-13

An artifact describing the shape of a request body, so a client can be wrong and find out

Status
To do
Component
protocol
Priority
High — the gap that let a wrong field name reach a published fixture
Blocked by
Blocks

Description

Publish an artifact that specifies the body of each request frame — field names, types, which are required — alongside the existing fixture, which specifies the envelope. A client author must be able to check their encoder against something other than a sample, so that a wrong field name fails their build instead of passing it.

Why now

This has already happened once, and it was invisible. The fixture is published as the conformance specification other languages build against, so a defect in it travels outward into every client written from it — and it travels while every test on both sides is green. That is a broken promise on a public surface, and the reason it is 1.0.0 work is that the fixture becomes the contract at the tag: after that, the wrong field name is not a bug, it is the protocol.

Measured 2026-09-20. fixtures/frames.json specifies the envelope precisely — a header of u8 version | u8 type | u32be id | u32be length | payload[length], headerBytes: 10, the thirteen type numbers, and for each case a payloadHex and a frameHex. It does not specify the body at all: the body appears only as one sample json object per case.

The invoke case carried {"op": "orders.list", …} while the server's struct field has always been tagged json:"command". Both sides' byte-level tests stayed green, because both sides were decoding the same wrong file — encode and decode round-tripped perfectly against each other. A client written only from the fixture would have sent op, been ignored by the server, and passed its own conformance suite.

Both halves of the fix are in: the fixture now reads "command": "orders.list" (fixtures/frames.json:35), and internal/server/fixture_decode_test.go (112 lines) now decodes every request fixture into the struct that actually serves it, with DisallowUnknownFields at :92 and a table that fails by name when a request frame has no decoder. That guard is server-side. Nothing equivalent exists for a client in another language, which is the half this ticket is.

Coverage is also thinner than it looks: the fixture holds 10 cases over 8 distinct frame types. Types 1 (hello), 2 (welcome), 4 (pong), 8 (subscribe) and 10 (goodbye) have no case at all.

The row half of the shape needs its own rule

Measured 2026-09-20, separately from the body defect above. An operation's arguments are fully described by its declaration — Input gives each one a name, a type and whether it is required, and a generator can always emit a typed argument list from it. Its returned rows are a different story: they are describable only if the operation declares a projection. packages/ecosy-sapedb's generator (src/types/index.ts) is honest about this rather than guessing — verified in the generated output itself, tests/typecheck/ledger.d.ts: every operation without a projection emits row: Record<string, unknown>, with the comment "The schema does not describe documents, so the shape of this row is genuinely not known here." right next to it.

The project's own worked fixture teaches the untyped path exclusively. fixtures/ledger.schema.json declares five operations — orders.place, orders.pay, orders.get, payments.of_order, entries.of_account — and a count of "projection" across the file returns zero. projection is a real, implemented field (it appears in src/types/index.ts, src/commander/index.ts and src/client/index.ts); nothing about the fixture is missing a feature, it simply never uses the one field that would make a read's row typed. This is ISS-19.

The rule this ticket's artifact has to publish, in one sentence: an operation does not carry a shape, but it decides which shape a client can derive — the argument shape unconditionally, the row shape only when the operation declares a projection. Without that sentence on the page, a bundle author reads the worked example, copies its silence on projection, and publishes an external operation whose surface is callable but not bindable — exactly the gap SAPE-12's worked module has to avoid.

Acceptance criteria

  1. An artifact exists describing the body of every request frame, in a form a program can read — not prose, and not a sample.
  2. The artifact is generated from, or checked against, the server's own structs. A hand-maintained second copy reproduces exactly the failure this ticket is about. A test fails when a struct field's json tag changes and the artifact does not.
  3. The op/command defect is a regression test: a body using the old field name is rejected when validated against the artifact. Run as a case, so the specific mistake cannot return.
  4. A client written in one of the three languages validates a body it is about to send against the artifact, and a deliberately wrong field name turns that client's own suite red — measured in the client's repository, not in the server's.
  5. Every frame type a client sends has an entry. Asserted the way fixture_decode_test.go already asserts its table: a frame type with no entry fails by name, rather than being quietly absent.
  6. The row-shape rule above is written on whatever page or artifact documents an operation's shape for client authors, in the same place the argument rule is — not left to be inferred from reading the generator's source.

Out of scope