Part 1 · Chapter 1.3
The protocol package
You will produce: The shared wire contract — frame types, error codes, and schemas that reject bad input · about 75 minutes including the exercise
Source: SRS — Software Requirements Specification · SAD — Software Architecture Document
Chapter 1.1 made an argument and drew a picture: one shared package feeding the gateway, the API service, and the SDK, so that a frame type change is one commit and drift between the two ends of the wire "becomes a compile error instead of a production incident." Two chapters of groundwork later, the box in that picture is still empty. Today we fill it. No services exist yet, and that is exactly the point — the contract comes first, and everything built afterwards has to speak it.
The contract comes first
Every distributed system has a wire contract. The only question is whether it
is written down once, or reconstructed from reading two codebases and hoping
they agree. Relay's documents chose the first path long before this chapter:
the SRS declares that "all frames shall be JSON objects carrying a type
discriminator and a payload" (EIR-WS-02), names the handshake
acknowledgment and its one-second deadline (EIR-WS-03), and requires the
whole protocol — reconnection, ordering, backfill — to be documented
(EIR-WS-07). The SAD's sequence diagrams already speak in frames:
message.send {idem_key, channel, text} going up, message.ack {seq} coming
back.
The vocabulary, derived
Almost none of what goes into this package is ours to invent. The documents name the frames; our job is to transcribe them — and to say so explicitly on the rare line where the documents leave a gap.
From the handshake: connection.ack, carrying "the resolved user
identity and a resume cursor" (EIR-WS-03). The cursor is ADR-03's per-channel
map — { channel_id: highest seq seen } — the same sequence numbers that
make resume and deduplication trivial. And because the SAD's resume sequence
(§5.2) fetches backfill before sending the ack, the ack is also where
truncation lives: any channel whose backfill would exceed 500 messages is
flagged so the client refetches history instead of streaming the backlog
(FR-RTM-04). That carrier choice — truncation on the ack — is the documents'
ordering plus our decision, and we record it as such.
From the send path: message.send {idem_key, channel, text} and
message.ack {seq}, exactly as SAD §5.1 draws them. The idempotency key is
client-supplied (FR-SDK-06) and deduplicated server-side within 24 hours
(FR-MSG-04). The documents fix no key format, so we decide one and record it:
a non-empty string of at most 255 characters.
From the event stream: FR-RTM-05 names six real-time event kinds —
message creation, edit, deletion, membership change, presence change, typing.
The kinds are the SRS's; the type-string spellings are ours, recorded here:
message.created, message.updated, message.deleted,
membership.changed, presence.changed, typing — following the
noun.verb pattern the documents' own connection.ack and message.send
already use. The message events carry the message itself, and its shape is
derived from the SAD's own messages table (§6.1): id, channel, seq,
user, text, created_at — the wire spellings following §5.1's frame
line, and the remaining columns (metadata, attachments, edit and tombstone
markers) explicitly deferred to the parts that implement them. Presence
states are online and offline (FR-RTM-06); typing expires after five
seconds and is never persisted (FR-RTM-08).
From the failure paths: an error frame reusing the REST error shape —
machine-readable code, human-readable message, a docs_url, an optional
field (EIR-API-04). That reuse is our decision; so is what's missing:
the constitution's error envelope also carries a request_id, and we defer
it deliberately — no gateway exists yet to mint one. It joins the frame in
Part 2. One more decision, and the smallest: the SRS requires a server ping
every 30 seconds (EIR-WS-04), and we satisfy it with native WebSocket
ping/pong control frames rather than protocol frames — browsers answer those
automatically, so the vocabulary doesn't need to.
flowchart LR
client["Client<br/>(SDK, later)"]
server["Server<br/>(gateway, 1.4 →)"]
client -- "message.send" --> server
server -- "connection.ack · message.ack" --> client
server -- "message.created · message.updated · message.deleted" --> client
server -- "membership.changed · presence.changed · typing" --> client
server -- "error {code, message, docs_url}" --> client
codes["close codes<br/>4001 auth · 4002 protocol ·<br/>4008 quota · 4009 shutdown"]
server -.-> codesThe first runtime dependency
Everything the workspace has installed so far — TypeScript, ESLint, Vitest — is a devDependency: present while we build, absent when code runs. This chapter takes the workspace's first runtime dependency, and it goes in the package that needs it, not at the root:
{
"name": "@relay/protocol",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"zod": "^4.4.3"
}
}{
"extends": "../../tsconfig.base.json",
"include": ["src"]
}Two familiar moves and one new one. The tsconfig extends the one base — 1.1's
rule, still holding. The dependency is pinned within a major — the same
discipline as 1.2's image tags, and the same caveat: versions in this prose
will age; the chapter tag holds the exact state. And the dependency is
package-local: the root package.json doesn't change, because zod is
@relay/protocol's business. Dependencies live where they're used —
consumers of this package will get validation through it, which is the
whole idea.
The schemas
Create the package and its source. One file for the frames:
mkdir -p packages/protocol/srcimport { z } from "zod";
// The wire contract, one home (ADR-01). Every frame is a JSON object with a
// `type` discriminator and a `payload` (EIR-WS-02). Schemas are the single
// source of truth: every exported static type is inferred from its schema,
// so the types and the validation cannot drift — there is no second
// definition. Payloads are strict: unknown fields are rejected.
/** Per-channel resume cursor: { channel_id: highest seq seen } (ADR-03). */
export const cursorSchema = z.record(z.string(), z.number().int().positive());
/** The message on the wire — derived from the SAD §6.1 `messages` columns.
* Wire spellings follow SAD §5.1's own frame line (`channel`, `seq`).
* metadata/attachments/edit/tombstone fields arrive with Part 2/4. */
export const messageSchema = z.strictObject({
id: z.string().min(1),
channel: z.string().min(1),
seq: z.number().int().positive(),
user: z.string().min(1),
text: z.string(),
created_at: z.iso.datetime(), // UTC, RFC 3339 (constitution: timestamps)
});
/** Server → client on successful handshake (EIR-WS-03, SAD §5.2). Sent after
* backfill is fetched, so it can also carry the per-channel truncation list
* (FR-RTM-04) — channels where backfill exceeded 500 and the client must
* refetch history instead. */
export const connectionAckSchema = z.strictObject({
type: z.literal("connection.ack"),
payload: z.strictObject({
user: z.string().min(1),
cursor: cursorSchema,
resume_ok: z.boolean(),
truncated: z.array(z.string().min(1)),
}),
});
/** Client → server send (SAD §5.1: `message.send {idem_key, channel, text}`).
* The idempotency key is client-supplied (FR-SDK-06), deduplicated
* server-side within 24 h (FR-MSG-04). */
export const messageSendSchema = z.strictObject({
type: z.literal("message.send"),
payload: z.strictObject({
idem_key: z.string().min(1).max(255),
channel: z.string().min(1),
text: z.string(),
}),
});
/** Server → sender after commit — never before (SAD §5.1, FR-MSG-05). */
export const messageAckSchema = z.strictObject({
type: z.literal("message.ack"),
payload: z.strictObject({
seq: z.number().int().positive(),
}),
});
// The six real-time event kinds (FR-RTM-05). The kinds are the SRS's; the
// `noun.verb` spellings are this chapter's recorded decision, following the
// documents' own connection.ack / message.send naming.
export const messageCreatedSchema = z.strictObject({
type: z.literal("message.created"),
payload: messageSchema,
});
export const messageUpdatedSchema = z.strictObject({
type: z.literal("message.updated"),
payload: messageSchema,
});
export const messageDeletedSchema = z.strictObject({
type: z.literal("message.deleted"),
payload: messageSchema,
});
export const membershipChangedSchema = z.strictObject({
type: z.literal("membership.changed"),
payload: z.strictObject({
channel: z.string().min(1),
user: z.string().min(1),
change: z.enum(["added", "removed"]),
}),
});
/** Presence states per FR-RTM-06; delivery scope is FR-RTM-07's concern. */
export const presenceChangedSchema = z.strictObject({
type: z.literal("presence.changed"),
payload: z.strictObject({
user: z.string().min(1),
state: z.enum(["online", "offline"]),
}),
});
/** Expires after 5 s without renewal and is never persisted (FR-RTM-08). */
export const typingSchema = z.strictObject({
type: z.literal("typing"),
payload: z.strictObject({
channel: z.string().min(1),
user: z.string().min(1),
}),
});
/** Protocol-level error — EIR-API-04's error shape, reused on the socket
* (this chapter's recorded decision). `request_id` joins in Part 2, when a
* gateway exists to mint one. */
export const errorFrameSchema = z.strictObject({
type: z.literal("error"),
payload: z.strictObject({
code: z.string().min(1),
message: z.string().min(1),
docs_url: z.string().min(1),
field: z.string().min(1).optional(),
}),
});
/** Every frame either end may legally utter. */
export const frameSchema = z.discriminatedUnion("type", [
connectionAckSchema,
messageSendSchema,
messageAckSchema,
messageCreatedSchema,
messageUpdatedSchema,
messageDeletedSchema,
membershipChangedSchema,
presenceChangedSchema,
typingSchema,
errorFrameSchema,
]);
// The static types ARE the schemas — z.infer, never a hand-written twin.
export type Cursor = z.infer<typeof cursorSchema>;
export type Message = z.infer<typeof messageSchema>;
export type ConnectionAck = z.infer<typeof connectionAckSchema>;
export type MessageSend = z.infer<typeof messageSendSchema>;
export type MessageAck = z.infer<typeof messageAckSchema>;
export type Frame = z.infer<typeof frameSchema>;
/** Parse anything the wire delivers. Hostile input is an expected value, not
* an exception: this returns zod's safeParse result and never throws. */
export function parseFrame(raw: unknown) {
return frameSchema.safeParse(raw);
}Read it from the bottom up and the design shows itself. parseFrame returns
zod's safeParse result — success with typed data, or failure with a
structured error, and never a thrown exception, because on a network
boundary malformed input is not exceptional, it is Tuesday. The type exports
are all z.infer: the schema is the one definition, and the static type is
derived from it, so the two literally cannot disagree. And every payload is a
strict object — a frame smuggling an extra field is rejected, which is the
same discipline the constitution demands of write endpoints ("unknown fields
are rejected").
flowchart TB
schema["ONE zod schema<br/>messageSendSchema"]
runtime["runtime validation<br/>parseFrame(raw) →<br/>accepts or rejects, never throws"]
types["static type<br/>type MessageSend = z.infer<…><br/>(no hand-written twin)"]
schema --> runtime
schema --> types
note["Types erase at runtime.<br/>Schemas are types that survive —<br/>and they cannot drift apart:<br/>there is no second definition."]
schema ~~~ noteThe failure vocabulary
Contracts are not just happy paths. The SRS requires close codes that distinguish "authentication failure, quota exhaustion, server shutdown, and protocol violation" (EIR-WS-06) — two of them already numbered by the documents, two numbered here, and said so:
// Close codes and protocol error codes — the contract's failure vocabulary.
// EIR-WS-06 requires close codes to distinguish authentication failure,
// quota exhaustion, server shutdown, and protocol violation. Two numbers are
// document-fixed (4001: EIR-WS-05; 4009: SAD §7); the other two classes are
// numbered here — chapter 1.3's recorded decision.
export const CLOSE_CODES = {
4001: "invalid or expired token",
4002: "protocol violation",
4008: "quota exhausted",
4009: "server shutdown (drain)",
} as const;
export type CloseCode = keyof typeof CLOSE_CODES;
// Protocol-level error codes carried by the `error` frame (EIR-API-04's
// shape). A starter registry — endpoints and services add their own codes in
// their chapters; uniqueness is test-enforced from day one.
export const ERROR_CODES = {
invalid_frame: "the frame failed schema validation",
unknown_frame_type: "the type discriminator names no known frame",
unauthorized: "the connection is not authorized for this action",
rate_limited: "too many frames; slow down and retry",
} as const;
export type ErrorCode = keyof typeof ERROR_CODES;And the public face of the package — one import for everything:
// @relay/protocol — the shared wire contract (ADR-01's payoff, chapter 1.3).
// One home for frame schemas, their inferred types, and the failure
// vocabulary. Consumed by the gateway and API service from 1.4, and by the
// SDK in a later part.
export * from "./frames.js";
export * from "./codes.js";Tests that bite
A schema that accepts garbage is worse than no schema — it certifies garbage. So the test suite's job is mostly to say no: for every frame, one valid specimen that parses and round-trips, and a table of near-misses that must every one be rejected.
import { describe, expect, it } from "vitest";
import { parseFrame } from "./frames.js";
// The contract must bite: for every frame, one specimen that parses and a
// table of malformed near-misses that MUST reject. A schema that accepts
// garbage is worse than no schema — it certifies garbage.
const message = {
id: "m1",
channel: "c1",
seq: 42,
user: "u1",
text: "hello",
created_at: "2026-08-01T09:00:00.000Z",
};
const valid: Record<string, unknown> = {
"connection.ack": {
type: "connection.ack",
payload: { user: "u1", cursor: { c1: 42 }, resume_ok: true, truncated: [] },
},
"message.send": {
type: "message.send",
payload: { idem_key: "k-1", channel: "c1", text: "hi" },
},
"message.ack": { type: "message.ack", payload: { seq: 43 } },
"message.created": { type: "message.created", payload: message },
"message.updated": { type: "message.updated", payload: message },
"message.deleted": { type: "message.deleted", payload: message },
"membership.changed": {
type: "membership.changed",
payload: { channel: "c1", user: "u2", change: "added" },
},
"presence.changed": {
type: "presence.changed",
payload: { user: "u1", state: "online" },
},
typing: { type: "typing", payload: { channel: "c1", user: "u1" } },
error: {
type: "error",
payload: {
code: "invalid_frame",
message: "no",
docs_url: "https://docs.example/errors/invalid_frame",
},
},
};
describe("every frame parses its valid specimen and round-trips", () => {
for (const [name, frame] of Object.entries(valid)) {
it(name, () => {
const result = parseFrame(frame);
expect(result.success).toBe(true);
if (result.success) expect(result.data).toEqual(frame);
});
}
});
describe("malformed frames reject", () => {
const rejects: Array<[string, unknown]> = [
["not an object", "message.send"],
["unknown type discriminator", { type: "message.destroy", payload: {} }],
["missing payload", { type: "message.ack" }],
[
"missing payload field",
{ type: "message.send", payload: { channel: "c1", text: "hi" } },
],
[
"wrong primitive (seq as string)",
{ type: "message.ack", payload: { seq: "43" } },
],
["zero seq", { type: "message.ack", payload: { seq: 0 } }],
["negative seq", { type: "message.ack", payload: { seq: -1 } }],
[
"empty idem_key",
{ type: "message.send", payload: { idem_key: "", channel: "c1", text: "hi" } },
],
[
"oversized idem_key",
{
type: "message.send",
payload: { idem_key: "k".repeat(256), channel: "c1", text: "hi" },
},
],
[
"unknown extra payload field",
{
type: "message.send",
payload: { idem_key: "k-1", channel: "c1", text: "hi", admin: true },
},
],
[
"invalid presence state",
{ type: "presence.changed", payload: { user: "u1", state: "away" } },
],
[
"non-RFC3339 timestamp",
{ type: "message.created", payload: { ...message, created_at: "yesterday" } },
],
];
for (const [name, frame] of rejects) {
it(name, () => {
expect(parseFrame(frame).success).toBe(false);
});
}
});import { describe, expect, it } from "vitest";
import { CLOSE_CODES, ERROR_CODES } from "./codes.js";
// The failure vocabulary stays coherent: EIR-WS-06's four classes are all
// present, exactly once, with distinct meanings — and error codes never
// collide or go blank as chapters add to the registry.
describe("close codes cover EIR-WS-06's four classes", () => {
it("contains exactly 4001, 4002, 4008, 4009", () => {
expect(Object.keys(CLOSE_CODES).map(Number).sort()).toEqual([
4001, 4002, 4008, 4009,
]);
});
it("gives every code a distinct, non-empty meaning", () => {
const meanings = Object.values(CLOSE_CODES);
expect(new Set(meanings).size).toBe(meanings.length);
for (const meaning of meanings) expect(meaning.length).toBeGreaterThan(0);
});
});
describe("error codes stay unique and described", () => {
it("has no duplicate or empty descriptions", () => {
const descriptions = Object.values(ERROR_CODES);
expect(new Set(descriptions).size).toBe(descriptions.length);
for (const d of descriptions) expect(d.length).toBeGreaterThan(0);
});
it("uses snake_case machine-readable keys (EIR-API-04)", () => {
for (const code of Object.keys(ERROR_CODES)) {
expect(code).toMatch(/^[a-z][a-z_]*$/);
}
});
});Install the dependency and walk the gate:
pnpm install
pnpm lint
pnpm typecheck
pnpm testThirty-two tests now — the six from chapters past, and twenty-six that
interrogate this contract from both sides. Note what the reject table is
really testing: not zod, but our claims. "Unknown fields are rejected" is a
sentence until a test proves a smuggled admin: true bounces.
flowchart TB
proto["@relay/protocol ✓ BUILT<br/>frame schemas · inferred types ·<br/>error + close codes (this chapter)"]
gw["Gateway service<br/>(1.4 →)"]
apisvc["API service<br/>(1.4 →)"]
sdk["The JS SDK<br/>(a later part)"]
proto -.-> gw
proto -.-> apisvc
proto -.-> sdk
note["1.1 promised it, 1.3 built it:<br/>a frame change is ONE commit —<br/>drift is a compile error,<br/>not a production incident"]
proto ~~~ noteYour turn
The exercise is the build: create packages/protocol from this chapter,
typing the schemas yourself. Then interrogate what you built:
- Add your own malformed specimen to the reject table — a
message.sendwhose payload is an array, say — and watch the suite catch it. If you can invent a near-miss the schemas accept, you have found a real bug: tighten the schema and keep the specimen as a test. - Hand-write the
MessageSendtype as a TypeScript interface, then compare it with whatz.inferproduced (hover it in your editor). Now delete your interface and consider: every field you typed was a chance to disagree with the schema. That deletion is the design. - Change
seqto accept any number and run the tests. Two suites object — the reject table here, and nothing else yet. By Part 2, ordering invariants will make that one-character change fail a dozen tests across services. That growing blast radius is the contract doing its job.
If you are stuck, the tag holds the answer key: part1-ch3.
Takeaways
If you read nothing else in this chapter, keep these:
- The contract comes first — services conform to the protocol package, never the reverse; that is what makes drift a compile error instead of a production incident (ADR-01, paid in full).
- The vocabulary is derived, not invented: frames, cursors, truncation, and close codes come from the SRS and SAD; the handful of gaps the documents leave are decided out loud and recorded.
- Schemas are types that survive runtime — every static type is
z.inferfrom the schema that validates it; one definition, nothing to drift. - Validation lives with the types: one library, one home, zero per-service copies — the drift disease doesn't return through the back door.
- Tests must bite: a suite that only parses valid frames certifies nothing; the reject table is where the contract earns its keep.