docs(schema): the schema described a wire we stopped sending

d05974f moved headers and trailers from a JSON map to length-prefixed
pairs and regenerated the golden vectors. schema/zap_http.zap — which
calls itself the source of truth — was never updated, so the one file a
reimplementer reads first described a JSON map<Text, List(Text)> and a
`List(Header)` struct list that no encoder has emitted since.

Three things were wrong, all of them the kind that only surface as a
byte mismatch in someone else's language:

  - headers/trailer are Data slots holding `count` then
    {keyLen,key,valLen,value}*, all little-endian. A repeated name is
    repeated pairs; there is no values list. An empty block is a null
    {0,0} slot, NOT a count=0 block.
  - streaming was "reserved for a follow-up slice"; it shipped.
    FrameResponseHead/FrameData/FrameEnd are 0x03/0x04/0x05, so the
    closing note's "today there are exactly two" type IDs was off by
    three.
  - the preamble credited a zap-proto/go runtime dependency that
    codec.go replaced by being its own encoder and decoder.

Also written down because a port gets them wrong once each: relOffset
is measured from the slot's own position, relOffset==0 is NULL whatever
the length word says, the frame is little-endian but the transport's
length prefix is big-endian, and Host/Content-Length are frame-owned so
they must never appear as pairs.

No code changes — the schema is documentation, not a codegen input
(Makefile: "no code-generation step"). wire.go's offsets remain the
binding; this makes the document agree with them.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-27 20:46:49 -07:00
co-authored by Hanzo Dev
parent 05e4490213
commit 6c01f84d7c
+84 -29
View File
@@ -3,33 +3,71 @@
# Authored in ZAP schema language (whitespace-significant, no curly braces,
# field ordinals inferred from declaration order).
#
# This is the source of truth for the ZAP-HTTP wire format. The Go
# codec is hand-written against the github.com/zap-proto/go runtime in
# ../wire.go — each struct below maps to a zap-proto/go object whose
# fields are packed at fixed 8-byte-aligned slots (text/bytes) or
# natural-width scalar offsets. The field offsets in ../wire.go are the
# binding; this file documents the logical shape they implement.
# ../wire.go and ../codec.go are the BINDING — there is no external
# codec dependency, codec.go is both encoder and decoder — and this
# file documents the logical shape they implement. Each struct below is
# an object whose fixed payload packs every text/bytes field into an
# 8-byte slot of {relOffset UInt32, length UInt32}, and scalars at
# their natural-width offset. A slot's relOffset is measured from THE
# SLOT'S OWN position; relOffset == 0 is NULL regardless of the length
# word, and an empty field keeps a zeroed {0,0} slot contributing no
# tail bytes. Slot offsets, from ../wire.go:
#
# Request method 0 target 8 proto 16 headers 24 body 32 trailer 40 (48 total)
# Response status 0 reason 8 proto 16 headers 24 body 32 trailer 40 (48 total)
# Chunk data 0 ( 8 total)
#
# Byte order is little-endian everywhere INSIDE the frame. The only
# big-endian quantity on the connection is the transport's 4-byte
# length prefix that precedes each frame.
#
# The wire is one ZAP frame per HTTP message. The frame type (request
# vs response) rides in the message header flags (flags>>8), not a
# union struct. A request frame carries the request line + headers +
# body; a response frame carries status + headers + body. Both sides
# may carry trailers (RFC 9110 §6.5). Headers and trailers are encoded
# as a JSON map<Text, List(Text)> rather than a nested struct list,
# matching http.Header's native shape losslessly.
# may carry trailers (RFC 9110 §6.5).
#
# Streaming (chunked-style transfer) is reserved for a follow-up
# protocol slice and not represented here. v0.1 assumes the entire body
# fits in one frame; the transport (zap-proto/go) negotiates the
# maximum frame size on connection setup.
# Headers and trailers are LENGTH-PREFIXED PAIRS in a single bytes
# slot — not a JSON map, and not a nested struct list. A JSON encoder
# inside a zero-copy binary protocol bought escaping, sorting and a
# grammar nobody needed, and forced two implementations to be kept
# byte-identical; see ../wire.go's "Wire" note. The golden-wire tests
# pin the current bytes. Peers must agree on the version.
#
# Streaming is represented: a body too large for one frame is sent as
# FrameResponseHead (status + headers, no body), then zero or more
# FrameData chunks, then FrameEnd (optional trailers) — the shape
# HTTP/2 gets from HEADERS + DATA + END_STREAM. A NON-streamed body
# must still fit in one frame; the transport negotiates the maximum
# frame size on connection setup.
# Header is one HTTP header name and its values. RFC 9110 allows the
# same name to appear multiple times; we represent that as a list of
# values rather than concatenating with commas, so callers see what
# was actually sent.
struct Header
name Text
values List(Text)
# The header block encoding, written out because it is a byte layout
# rather than a struct the schema language can express. `headers` and
# `trailer` below are Data slots holding exactly this, all integers
# little-endian:
#
# count UInt32 -- number of pairs that follow
# pair* -- repeated `count` times:
# keyLen UInt32
# key keyLen bytes
# valLen UInt32
# value valLen bytes
#
# An EMPTY block writes no bytes at all and leaves a null {0,0} slot —
# it is not a `count=0` block. Decoders must treat a zero-length slot
# as "no headers" and stop; a block shorter than its own declared
# pairs is a truncated-header error, not a silent short read.
#
# RFC 9110 allows a name to repeat. There is no values list: a repeated
# name is simply REPEATED PAIRS, in the order sent, so callers still
# see what was actually on the wire without comma-concatenation.
#
# Some fields are FRAME-OWNED and must not appear as pairs, because the
# frame already carries the information and sending it would put two
# sources of truth on the wire: Content-Length (the body slot's length
# is authoritative) and, on requests, Host (reconstructed from the
# target). Encoders skip them; decoders ignore Content-Length and let
# the last Host value win.
# Request is one HTTP request. `target` is the request-target as it
# would appear on an HTTP/1.1 request line — origin-form (`/path?q=v`),
@@ -39,9 +77,9 @@ struct Request
method Text
target Text
proto Text
headers List(Header)
headers Data
body Data
trailer List(Header)
trailer Data
# Response is one HTTP response. `status` is the numeric status code
# (200, 404, 503, …); `reason` is the optional reason phrase; the
@@ -51,13 +89,30 @@ struct Response
status UInt16
reason Text
proto Text
headers List(Header)
headers Data
body Data
trailer List(Header)
trailer Data
# Chunk is the body of a FrameData or FrameEnd frame: one bytes slot
# holding the chunk, or — for FrameEnd — the trailer pairs in the same
# encoding as `headers`. FrameEnd with a null slot terminates a stream
# that declared no trailers.
struct Chunk
data Data
# Frame discrimination is carried by the ZAP message header flags
# (flags>>8), not a union struct: FrameRequest=0x01 tags a Request,
# FrameResponse=0x02 tags a Response (see ../wire.go). A Request object
# is the message root when the flag is FrameRequest; a Response object
# is the root when it is FrameResponse. Some sub-protocols may add
# Notify or Stream type IDs in future; today there are exactly two.
# (flags>>8), not a union struct (see ../wire.go). A Request object is
# the message root when the flag is FrameRequest; a Response object is
# the root when it is FrameResponse; a Chunk is the root for the two
# streaming data frames. There are five type IDs:
#
# FrameRequest 0x01 -- Request is the root
# FrameResponse 0x02 -- Response is the root, body in-frame
# FrameResponseHead 0x03 -- Response is the root, body slot null,
# FrameData/FrameEnd follow
# FrameData 0x04 -- Chunk is the root: one body chunk
# FrameEnd 0x05 -- Chunk is the root: trailer pairs, or null
#
# A decoder that reads only single-frame replies must REFUSE 0x03 by
# name rather than return a body-less 200 — a truncated success is
# worse than a named error.