@jarenjs/contract 0.43.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +508 -0
  2. package/dist/types/adapters/fetch.d.ts +27 -0
  3. package/dist/types/adapters/node.d.ts +47 -0
  4. package/dist/types/app/binding.d.ts +122 -0
  5. package/dist/types/app/effect.d.ts +77 -0
  6. package/dist/types/app/index.d.ts +31 -0
  7. package/dist/types/app/subscription.d.ts +82 -0
  8. package/dist/types/bundle.d.ts +43 -0
  9. package/dist/types/cli.d.ts +15 -0
  10. package/dist/types/client/http.d.ts +242 -0
  11. package/dist/types/client/outcome.d.ts +289 -0
  12. package/dist/types/compat.d.ts +36 -0
  13. package/dist/types/compile.d.ts +196 -0
  14. package/dist/types/describe.d.ts +115 -0
  15. package/dist/types/diff.d.ts +91 -0
  16. package/dist/types/errors.d.ts +205 -0
  17. package/dist/types/http/dispatch.d.ts +148 -0
  18. package/dist/types/http/serve.d.ts +154 -0
  19. package/dist/types/http/wire.d.ts +334 -0
  20. package/dist/types/index.d.ts +39 -0
  21. package/dist/types/ledger.d.ts +207 -0
  22. package/dist/types/local/index.d.ts +127 -0
  23. package/dist/types/messages.d.ts +63 -0
  24. package/dist/types/path.d.ts +119 -0
  25. package/dist/types/pipeline.d.ts +157 -0
  26. package/dist/types/port/client.d.ts +142 -0
  27. package/dist/types/port/frame.d.ts +195 -0
  28. package/dist/types/port/serve.d.ts +102 -0
  29. package/dist/types/project/index.d.ts +34 -0
  30. package/dist/types/project/markdown.d.ts +28 -0
  31. package/dist/types/project/openapi.d.ts +102 -0
  32. package/dist/types/project/tools.d.ts +57 -0
  33. package/dist/types/project/typescript.d.ts +59 -0
  34. package/dist/types/public.d.ts +73 -0
  35. package/dist/types/revision.d.ts +36 -0
  36. package/dist/types/stream/client.d.ts +104 -0
  37. package/dist/types/stream/server.d.ts +106 -0
  38. package/dist/types/stream/sse.d.ts +62 -0
  39. package/docs/APP-INTEGRATION.md +301 -0
  40. package/docs/CONTRACT-FORMAT.md +1923 -0
  41. package/package.json +110 -0
  42. package/schemas/jaren-contract-port.draft-07.schema.json +241 -0
  43. package/schemas/jaren-contract-port.schema.json +241 -0
  44. package/schemas/jaren-contract.draft-07.schema.json +287 -0
  45. package/schemas/jaren-contract.schema.json +287 -0
  46. package/src/adapters/fetch.js +109 -0
  47. package/src/adapters/node.js +238 -0
  48. package/src/app/binding.js +426 -0
  49. package/src/app/effect.js +190 -0
  50. package/src/app/index.js +26 -0
  51. package/src/app/subscription.js +130 -0
  52. package/src/bundle.js +168 -0
  53. package/src/cli.js +264 -0
  54. package/src/client/http.js +1150 -0
  55. package/src/client/outcome.js +364 -0
  56. package/src/compat.js +62 -0
  57. package/src/compile.js +1162 -0
  58. package/src/describe.js +109 -0
  59. package/src/diff.js +610 -0
  60. package/src/errors.js +236 -0
  61. package/src/http/dispatch.js +1054 -0
  62. package/src/http/serve.js +301 -0
  63. package/src/http/wire.js +469 -0
  64. package/src/index.js +33 -0
  65. package/src/ledger.js +225 -0
  66. package/src/local/index.js +363 -0
  67. package/src/messages.js +68 -0
  68. package/src/path.js +471 -0
  69. package/src/pipeline.js +241 -0
  70. package/src/port/client.js +518 -0
  71. package/src/port/frame.js +196 -0
  72. package/src/port/serve.js +442 -0
  73. package/src/project/index.js +29 -0
  74. package/src/project/markdown.js +244 -0
  75. package/src/project/openapi.js +564 -0
  76. package/src/project/openapi.jslt.json +149 -0
  77. package/src/project/tools.js +139 -0
  78. package/src/project/typescript.js +152 -0
  79. package/src/project/typescript.jtlt.json +72 -0
  80. package/src/public.js +206 -0
  81. package/src/revision.js +90 -0
  82. package/src/stream/client.js +212 -0
  83. package/src/stream/server.js +306 -0
  84. package/src/stream/sse.js +67 -0
@@ -0,0 +1,1923 @@
1
+ # The jaren-contract format — normative
2
+
3
+ The contract between this document and the code: `compileContract` in
4
+ `@jarenjs/contract` implements exactly what is written here, and a test
5
+ holds the two together (every ```json block below is a complete document
6
+ that must validate against the published grammar and compile; the error
7
+ table in §6 must equal the package's `CONTRACT_CODES`). Section numbers
8
+ are stable — later sections are appended, never renumbered.
9
+
10
+ ## §1 Purpose
11
+
12
+ A **contract** is a Jaren document — the sibling of `$model`, `$fsm` and
13
+ `jaren-app` — that declares the *operations* two Jaren ends may exchange:
14
+ JSON in, JSON out, each with a kind, an input schema, an output schema,
15
+ declared errors, a behavior policy and an HTTP binding. It is compiled
16
+ **once** into per-operation validators, transport normalizers and one
17
+ path matcher, and it is the single source every artifact around it is
18
+ projected from.
19
+
20
+ Format 0.1 covers the document, its compilation, the HTTP binding's
21
+ *shape* (§2–§6), the HTTP **server** binding that carries it (§7–§9:
22
+ the request pipeline and its wire errors, idempotency and the ledger
23
+ interface, the `fetch` and `node` adapters), the HTTP **client** binding
24
+ (§10: outcomes, the client half of idempotency, retry, negotiation), the
25
+ `@jarenjs/app` binding (§11: the generated documents and the one
26
+ effect) and the **projections** (§12: the public projection every other
27
+ artifact is built on, OpenAPI 3.1, TypeScript, Markdown, the AI tool
28
+ definitions, and the `jaren-contract` CLI). The in-process, message-port
29
+ and stream bindings, the revision hash and the locale catalogs are the
30
+ coming lines of this package and will append their sections here.
31
+
32
+ ### §1.1 What the format is not
33
+
34
+ - Not a second schema language: `input`, `output` and error schemas are
35
+ ordinary JSON Schema, compiled by `@jarenjs/validate`.
36
+ - Not a router DSL: the path template dialect is RFC 6570 level 1 (`{name}`),
37
+ what OpenAPI uses; `:name` is accepted and canonicalized.
38
+ - Not a place to describe bytes: a non-JSON `media` marks an operation
39
+ **opaque** — routed and matched, its path/query still decoded, its body
40
+ neither decoded nor validated by the contract.
41
+
42
+ ## §2 The document
43
+
44
+ ```json
45
+ {
46
+ "$contract": "0.1",
47
+ "id": "shop",
48
+ "version": "5",
49
+ "compat": ["4"],
50
+ "$defs": {
51
+ "Product": {
52
+ "type": "object",
53
+ "required": ["id", "name", "price"],
54
+ "properties": {
55
+ "id": { "type": "integer" },
56
+ "name": { "type": "string", "minLength": 1 },
57
+ "price": { "type": "number", "minimum": 0 }
58
+ }
59
+ },
60
+ "Catalog": {
61
+ "type": "object",
62
+ "required": ["revision", "products"],
63
+ "properties": {
64
+ "revision": { "type": "integer" },
65
+ "products": { "type": "array", "items": { "$ref": "#/$defs/Product" } }
66
+ }
67
+ },
68
+ "Conflict": { "type": "object", "properties": { "current": { "$ref": "#/$defs/Product" } } }
69
+ },
70
+ "operations": {
71
+ "catalog.load": {
72
+ "kind": "read",
73
+ "input": { "type": "object", "properties": { "since": { "type": "string", "format": "date-time" } } },
74
+ "output": { "$ref": "#/$defs/Catalog" },
75
+ "errors": { "stale": { "status": 409 } },
76
+ "policy": { "task": "switch", "cache": "revision" },
77
+ "http": { "method": "GET", "path": "/api/catalog" },
78
+ "doc": "The whole catalog snapshot."
79
+ },
80
+ "product.save": {
81
+ "kind": "command",
82
+ "input": {
83
+ "type": "object",
84
+ "required": ["id", "revision", "product"],
85
+ "properties": {
86
+ "id": { "type": "integer" },
87
+ "revision": { "type": "integer" },
88
+ "product": { "$ref": "#/$defs/Product" }
89
+ }
90
+ },
91
+ "output": { "$ref": "#/$defs/Product" },
92
+ "errors": {
93
+ "conflict": { "status": 409, "schema": { "$ref": "#/$defs/Conflict" } },
94
+ "not-found": { "status": 404 }
95
+ },
96
+ "policy": { "task": "exhaust", "idempotency": "required", "revision": "input:/revision" },
97
+ "http": {
98
+ "method": "PUT",
99
+ "path": "/api/products/{id}/master",
100
+ "in": { "revision": "body", "product": "body" }
101
+ }
102
+ },
103
+ "image.bytes": {
104
+ "kind": "read",
105
+ "input": { "type": "object", "required": ["id"], "properties": { "id": { "type": "integer" } } },
106
+ "output": true,
107
+ "http": { "method": "GET", "path": "/api/images/{id}", "media": "application/octet-stream" }
108
+ }
109
+ }
110
+ }
111
+ ```
112
+
113
+ ### §2.1 Members
114
+
115
+ | member | required | meaning |
116
+ |---|---|---|
117
+ | `$contract` | yes | MUST be `"0.1"`. |
118
+ | `id` | no | An identifier for the contract (`[A-Za-z_][A-Za-z0-9_-]*`): a tool prefix, a file name. |
119
+ | `version` | no | The consumer's version string — a compatibility claim, unrelated to any content hash. |
120
+ | `compat` | no | Peer `version` strings this contract accepts. |
121
+ | `$defs` | no | Named schemas the operations reference as `#/$defs/<name>`. Every value MUST be a schema (an object or a boolean). |
122
+ | `operations` | yes | Operation id → operation. MUST carry at least one. |
123
+
124
+ **Every object in this format has a closed vocabulary.** The root, an
125
+ operation, `policy` and its `limits`/`errors`/`retry`/`stream`, `http`,
126
+ and an error declaration accept exactly the members listed for them; an
127
+ unknown member is `JC0013` at that member. A silently ignored `policy` is a
128
+ behavior bug, so this format refuses rather than ignores.
129
+
130
+ The document MUST be JSON: a member that is a function, a symbol, a
131
+ bigint, a non-finite number, a class instance, or part of a cycle is
132
+ `JC0001` at that member; a member whose accessor throws is `JC0001` at
133
+ that member too. `undefined` members are absent. `compileContract` reads
134
+ the document once, through guarded access, into a plain snapshot; the
135
+ `Contract` keeps that snapshot (deep-frozen) as `doc`, and the caller's
136
+ object is neither frozen nor mutated.
137
+
138
+ ### §2.2 Operation ids
139
+
140
+ An operation id MUST match `^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*$` —
141
+ dotted lowercase words (`catalog.load`, `product.save`). The same string
142
+ is a valid JSON member, a tool name, a file name and a URL path segment,
143
+ so it is stable across every projection. In a `docPath` an id is one
144
+ reference token: `/operations/product.save/http/path`.
145
+
146
+ ### §2.3 `$ref`
147
+
148
+ A `$ref` inside `input`, `output`, an error schema or `$defs` resolves
149
+ **within the document** (`#`, `#/…` JSON Pointer, `#anchor`) or against
150
+ a schema passed as `compileContract(doc, { schemas })` by its `$id`
151
+ (with an optional pointer/anchor fragment). Anything unresolved is
152
+ `JC0007` at compile — never at request time. The compiler registers the
153
+ document with its validator under a synthetic id and compiles every
154
+ operation schema as a reference into it, so `#/$defs/Product` inside an
155
+ operation means the contract's own `$defs`, exactly as a reader expects.
156
+ That registration is **one per compile**: the document is added to the
157
+ (possibly host-injected, `compileContract(doc, { validator })`) validator
158
+ under `urn:jaren:contract:<n>`, `n` a process-wide counter, and is never
159
+ removed — a host sharing one validator across many compiles keeps them
160
+ all; a host that compiles many documents passes a validator per compile
161
+ or accepts the growth.
162
+
163
+ ## §3 Operations, policy and the defaults
164
+
165
+ An operation is `{ kind, input?, output, errors?, policy?, http?, doc? }`.
166
+
167
+ | member | required | meaning |
168
+ |---|---|---|
169
+ | `kind` | yes | `"read"`, `"command"` or `"subscribe"`. A subscribe operation's `output` is its **snapshot** schema and its emissions travel the stream binding (§17–§19). |
170
+ | `input` | no | A JSON Schema whose **effective type is `object`** — `"type": "object"` on the schema itself or on the schema a `$ref` chain reaches (`JC0005`). Its top-level `properties` are the members the HTTP binding places (§4). Absent means the operation takes no input. |
171
+ | `output` | yes | Any JSON Schema, `true` included (`JC0006` when absent). |
172
+ | `errors` | no | `code → { status?, schema? }`. A code matches `^[a-z][a-z0-9-]*$`; `status` is an integer in 100–599 (default **400**); `schema` a JSON Schema for the error's details (`JC0011`). |
173
+ | `policy` | no | The declared behavior — the table below. |
174
+ | `http` | no | The REST binding (§4). Absent means the **canonical binding**. |
175
+ | `doc` | no | A string for projections. |
176
+
177
+ ### §3.1 Policy
178
+
179
+ Every policy member has a default. **Defaults are materialized into the
180
+ compiled operation, never into the source document**; `describe()` shows
181
+ the resolved value and marks it inferred.
182
+
183
+ | member | values | default | meaning |
184
+ |---|---|---|---|
185
+ | `task` | `switch` \| `exhaust` \| `concat` \| `parallel` | `switch` for a read or a subscribe, `exhaust` for a command | Which task mode a host effect runs the operation in: replace an in-flight attempt, let the first one finish, queue, or run concurrently. A **subscribe MUST be `switch`** (`JC0018`) — a subscription slot is replaced, never queued. |
186
+ | `idempotency` | `none` \| `optional` \| `required` | `none` | Whether a command carries an idempotency key. A **read MUST be `none`** (`JC0014`); a **subscribe MUST be `none`** (`JC0020`). |
187
+ | `revision` | `"input:<json-pointer>"` | absent (`null`) | Where in the input the revision a command asserts lives, as an RFC 6901 pointer after `input:` (`JC0014` on malformed). The operation MUST declare `input`, and the pointer's **first reference token** MUST name a member of `input.properties` (`JC0014` otherwise — "revision points at '/x' but input declares no member 'x'"); deeper tokens are not checked (a member's schema may be a `$ref` or open), and the empty pointer (`"input:"`) addresses the whole input. |
188
+ | `cache` | `none` \| `revision` | `none` | Whether a read's result may be cached by revision. |
189
+ | `limits.maxBodyBytes` | positive integer | `1048576` | The request-body ceiling a server binding enforces. |
190
+ | `errors.details` | `none` \| `paths` \| `full` | `paths` | How much of a validation failure crosses the wire: nothing, instance path + keyword, or the raw validator errors. |
191
+ | `retry` | `{ max: integer ≥ 0, on: [codes] }` | absent (`null`) | Which error codes a client may retry (declared codes, or `JC2xxx` taxonomy codes), and how often beyond the first attempt; a network failure is always retried under a declared `retry`. A **command MUST declare `idempotency: "required"` to carry `retry`** (`JC0014`) — a retried command without a key the server deduplicates on runs twice; a read is idempotent by nature. |
192
+ | `stream` | `{ resume?, heartbeatMs?, maxPatchBytes? }` | `{ resume: "snapshot", heartbeatMs: 15000 }` on a subscribe | The stream policy of a **subscribe** operation (`JC0014` on any other kind): `resume` is `snapshot` \| `replay` (§18's resumption rule), `heartbeatMs` an integer ≥ 1000 (the SSE heartbeat interval; a client treats `2 × heartbeatMs` of silence as `JC2094`), `maxPatchBytes` a positive integer — an emission whose serialized patch exceeds it is replaced by a fresh `snapshot` event (§18). |
193
+ | `audience` | `public` \| `server` | `public` | Who may see the operation. A `server` operation is served and handled like any other, but is **kept out of the public projection** (§12.1) and therefore out of every projection built on it — OpenAPI, TypeScript, Markdown, the AI tools — and out of the revision. |
194
+
195
+ The compiled `policy` is always
196
+ `{ task, idempotency, revision, cache, limits: { maxBodyBytes }, errors: { details }, retry, stream, audience }` —
197
+ `stream` the materialized `{ resume, heartbeatMs, maxPatchBytes }` on a
198
+ subscribe operation and `null` on every other kind.
199
+
200
+ ### §3.2 `describe()`
201
+
202
+ `contract.describe()` is a pure-JSON summary with a stable member order:
203
+
204
+ ```jsonc
205
+ {
206
+ "$contract": "0.1", "id": "shop", "version": "5", "compat": ["4"],
207
+ "revision": null, // reserved for the revision hash
208
+ "operations": [{
209
+ "id": "product.save", "kind": "command",
210
+ "method": "PUT", "path": "/api/products/{id}/master",
211
+ "status": 200, "media": "application/json", "opaque": false,
212
+ "in": { "id": "path", "revision": "body", "product": "body" },
213
+ "body": null,
214
+ "task": "exhaust", "idempotency": "required", "cache": "none",
215
+ "inferred": { "http": false, "status": true, "media": true, "in": [],
216
+ "task": false, "idempotency": false, "cache": true }
217
+ }]
218
+ }
219
+ ```
220
+
221
+ `inferred` tells declared from defaulted: `http` is true when the whole
222
+ binding is the canonical one; `in` lists the members whose location the
223
+ compiler chose; the booleans mark defaulted `status`, `media`, `task`,
224
+ `idempotency`, `cache`. Operations appear in document order. A subscribe
225
+ operation additionally shows its resolved `stream` policy (`{ resume,
226
+ heartbeatMs, maxPatchBytes }`) and its forced `media`
227
+ (`text/event-stream`).
228
+
229
+ ## §4 The HTTP binding and member locations
230
+
231
+ `http` is `{ method, path, in?, body?, status?, media? }`.
232
+
233
+ | member | required | rule | code |
234
+ |---|---|---|---|
235
+ | `method` | yes | An **uppercase** token of `GET HEAD POST PUT PATCH DELETE OPTIONS`. | `JC0012` |
236
+ | `path` | yes | A path template (§4.2). | `JC0008` |
237
+ | `in` | no | Input member → `path` \| `query` \| `header` \| `body`. | `JC0009` |
238
+ | `body` | no | The name of the input member whose value **is** the request body — how a route whose body is a raw array or scalar is described. | `JC0009` |
239
+ | `status` | no | The success status, an integer in 200–299. Default `200`. | `JC0012` |
240
+ | `media` | no | The response media type. Default `application/json`. | `JC0012` |
241
+
242
+ ### §4.1 One input schema; the binding says where members travel
243
+
244
+ The `input` schema is the contract; **location is metadata**. Every
245
+ top-level `input.properties` member gets exactly one location:
246
+
247
+ 1. a member named by a path variable → `path` (mapping it elsewhere is `JC0009`);
248
+ 2. the member named by `http.body` → `body` (no other member may be
249
+ body-located then — map the rest to `query` or `header`, `JC0009`);
250
+ 3. a member named in `http.in` → that location (`JC0009` when it names no
251
+ input member, an unknown location, or `path` for a member the template
252
+ does not declare);
253
+ 4. otherwise the default: `query` for a `read`, `body` for a `command`.
254
+
255
+ Path, query and header members arrive as strings and are decoded by a
256
+ normalizer compiled **over those members only** with `coerceTypes`
257
+ (`@jarenjs/validate/normalize`); **a body member is never coerced**. The
258
+ compiled operation carries this as
259
+ `input.transport = { normalize, members: { path, query, header, repeated }, schemas, required }`
260
+ (`null` when nothing travels as a string) beside `input.effective` —
261
+ the object schema the declared `input` resolves to (itself, or the end
262
+ of its `$ref` chain), whose `properties` are the operation's members;
263
+ `schemas` holds each
264
+ transport member's declared schema and `required` the transport members
265
+ the input requires (what a URL builder validates without the body), and
266
+ `repeated` lists the
267
+ query and header members whose effective schema type is `array` — a
268
+ decoder collects repeats of those into an array (a repeated query key; a
269
+ repeated header line or a comma-separated header list, RFC 9110 §5.3)
270
+ before normalizing; every other query member is last-wins and every
271
+ other header member is one line (§7.4). The server validates the
272
+ reassembled input object with the operation's compiled validator; the
273
+ client validates the same object before it splits it.
274
+
275
+ An operation bound to `GET` or `HEAD` MUST NOT carry a body-located
276
+ member (`JC0016`) — including a `command` whose members default to the
277
+ body: map them to `query` explicitly.
278
+
279
+ Members outside `input.properties` (a `patternProperties` or
280
+ `additionalProperties` match) have no location; they belong to the body
281
+ object.
282
+
283
+ ### §4.2 The path template
284
+
285
+ A template is a leading `/`, then non-empty segments separated by `/`.
286
+ The root template `/` has no segments and is the only empty path (a
287
+ trailing `/` anywhere else is an empty segment, `JC0008`). A **variable
288
+ is a whole segment**, `{name}` (RFC 6570 level 1) or `:name` — accepted
289
+ and **canonicalized to `{name}`**, which is what `describe()` and every
290
+ projection show — with `name` matching `[A-Za-z_][A-Za-z0-9_]*` and
291
+ declared once per template. A static segment is any run of characters
292
+ except `/ { } : * ? #`, whitespace and control characters; a `%` in it
293
+ MUST open a well-formed escape.
294
+
295
+ Reserved and refused by name (`JC0008` says which): the RFC 6570 operator
296
+ forms `{+name}` `{#name}` `{.name}` `{/name}` `{;name}` `{?name}` `{&name}`
297
+ `{=name}`, the modifiers `{name*}` `{name:3}` `{a,b}`, the `{name+}` tail,
298
+ the `*` wildcard, `:name?`/`:name*`/`:name+`, and a variable that is only
299
+ part of a segment (`{id}.json`).
300
+
301
+ Every path variable MUST be a member of `input.properties` (`JC0009`).
302
+
303
+ ### §4.3 The canonical binding
304
+
305
+ When `http` is absent, the operation is bound to **`POST /<op-id>`**,
306
+ every input member in the body, status `200`, `application/json`. A
307
+ declared `http` is used as written — a declared path is never rewritten
308
+ to fit the canonical form.
309
+
310
+ ### §4.4 Route shapes
311
+
312
+ The **shape** of a binding is its method plus its template with every
313
+ variable normalized to `{}`: `GET /api/products/{}`. Two operations MUST
314
+ NOT share a shape (`JC0010` at the second one, in document order); the
315
+ canonical binding takes part (`POST /<id>` may collide with a declared
316
+ `POST /<id>`).
317
+
318
+ ### §4.5 Opaque operations
319
+
320
+ A `media` other than `application/json` (or a `+json` structured-syntax
321
+ suffix, parameters ignored) marks the operation **opaque**: it is routed
322
+ and matched, its path/query still decoded, its body neither decoded nor
323
+ validated by the contract, and it is excluded from generated clients
324
+ except as a URL builder. `image.bytes` in §2 is one. Because its body is
325
+ bytes the contract never decodes, an opaque operation MUST NOT declare a
326
+ **body-located member** — neither through `http.body`, nor `http.in`,
327
+ nor the `command` default (`JC0017` at the member that placed it there,
328
+ or at `http.media` when the default did): map the member to `query` or
329
+ `header`, or make the operation JSON. Its transport members are
330
+ therefore always its whole input, and are validated like any other
331
+ input (§7.1).
332
+
333
+ ## §5 The path matcher
334
+
335
+ `contract.match(method, path)` resolves a request line to
336
+ `{ op, params } | null`, where `params` holds the path variables by name
337
+ as **decoded strings** (the transport normalizer of §4.1 turns them into
338
+ their declared types). It receives the path only; the binding splits the
339
+ query off first. Rules:
340
+
341
+ - One static-segment tree per method; a walk tries **statics first, then
342
+ the variable child, and backtracks** on a dead end. Therefore a static
343
+ segment beats a variable **regardless of registration order**:
344
+ `GET /api/production-runs/prefill` resolves to its own operation whether
345
+ it was declared before or after `GET /api/production-runs/{id}`.
346
+ - Matching is **exact on the trailing slash**: `/a/` is a different shape
347
+ from `/a`, and since no template has an empty segment it never matches.
348
+ A variable never binds an empty segment.
349
+ - Each segment is **percent-decoded once**; statics are compared in decoded
350
+ space; a decoded `/` (`a%2Fb` → `a/b`) never re-splits.
351
+ - A malformed escape makes `match` return `null` — it never throws across
352
+ the binding boundary (a request is hostile input; a binding maps `null`
353
+ to its not-found answer).
354
+ - The method is a case-sensitive token: `get` matches nothing.
355
+ - Variables bind through a prototype-safe setter, so a template variable
356
+ or a request can never write `__proto__`.
357
+
358
+ `contract.allowed(path)` is the matcher's second question — the methods
359
+ under which this path shape reaches an operation, sorted (`[]` for none,
360
+ for a malformed escape, or for a non-path) — what a server answers in a
361
+ 405's `Allow` (§7.2). It walks every method tree and is off the hot path.
362
+
363
+ The matcher is package-private (`compileRoutes` is not exported); it is
364
+ reached only through `contract.match` and `contract.allowed`. Its
365
+ measured cost on the reference 123-route table is published by the
366
+ benchmark suite when that lands.
367
+
368
+ ## §6 Error codes
369
+
370
+ Compile errors are `ContractCompileError` — `{ code, reason, message,
371
+ docPath }` on the coded-error contract of `@jarenjs/core` — thrown by
372
+ `compileContract`. `docPath` is an RFC 6901 pointer into the document
373
+ (`''` is the root; only `~` and `/` are escaped, so a dotted id is one
374
+ token). This table is the normative list; `CONTRACT_CODES` in the package
375
+ carries the same codes and a test holds them equal.
376
+
377
+ | code | condition |
378
+ |---|---|
379
+ | JC0001 | the document is not a well-formed contract object: not an object, `$contract` is not `"0.1"`, `$defs` is not a map of schemas, a member is not a JSON value, or a member threw when read |
380
+ | JC0002 | `operations` is not an object with at least one member, or an operation declaration is not an object |
381
+ | JC0003 | an operation id does not match `^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*$` |
382
+ | JC0004 | `kind` is none of `read`, `command`, `subscribe` |
383
+ | JC0005 | `input` is not a schema whose effective type is `object` |
384
+ | JC0006 | `output` is absent or not a schema |
385
+ | JC0007 | a `$ref` resolves neither within the document nor against the registered schemas |
386
+ | JC0008 | `http.path` is not a valid path template (§4.2; the message names the reserved form) |
387
+ | JC0009 | a path variable, `http.in` key or `http.body` names no input member, or a member is mapped to a location it cannot travel in (§4.1) |
388
+ | JC0010 | two operations share method and canonical path shape (§4.4) |
389
+ | JC0011 | `errors` is malformed: not an object, a code is not `^[a-z][a-z0-9-]*$`, a status is not a 100–599 integer, or a schema is not a schema |
390
+ | JC0012 | `http.method` is not an uppercase token of the supported set, `http.status` is not a 200–299 integer, or `http.media` is not a media type |
391
+ | JC0013 | an unknown member in a closed object (the document root, an operation, `policy`, `limits`, `retry`, `policy.errors`, `http`, or an error declaration) |
392
+ | JC0014 | a `policy` member is mistyped or outside its declared set (§3.1), a read declares `idempotency`, or a command declares `retry` without `idempotency: "required"` |
393
+ | JC0015 | `id`, `version`, `compat` or an operation `doc` is mistyped |
394
+ | JC0016 | an operation bound to `GET` or `HEAD` carries a body-located member (a GET body) |
395
+ | JC0017 | an opaque operation (a non-JSON `http.media`) declares a body-located member — its body is bytes the contract never decodes, so the member could never be validated (§4.5) |
396
+ | JC0018 | a subscribe operation declares a `policy.task` other than `switch` — a subscription slot is replaced, never queued (§17) |
397
+ | JC0019 | a subscribe operation is bound to a method other than `GET` — a stream is fetched, not sent (§17) |
398
+ | JC0020 | a subscribe operation declares a `policy.idempotency` other than `none` — a subscription registers, it does not commit (§17) |
399
+
400
+ `JC0021–JC0049` are reserved for further document-level rules and are
401
+ appended to this table when they land; `JC0050–JC0069` are the binding-
402
+ declaration and projection compile codes (`JC0060`, the OpenAPI keyword
403
+ policy, is in §12.2's table). `JC1001–JC1049` are host
404
+ programming errors (`ContractHostError`, a thrown `TypeError` with `code`
405
+ and `reason`) and `JC2001–JC2049` the HTTP request-time errors
406
+ (`ContractRuntimeError` in-process, a wire error on the response) — both
407
+ tables are in §7. Later ranges: `JC2050–JC2069` client-side (§10.3),
408
+ `JC2070–JC2089` port/local (§15–§16), `JC2090–JC2109` stream (§18).
409
+
410
+ The three worked examples this document is tested against, complete:
411
+
412
+ ```json
413
+ {
414
+ "$contract": "0.1",
415
+ "operations": {
416
+ "health.check": { "kind": "read", "output": { "type": "object" }, "http": { "method": "GET", "path": "/api/health" } }
417
+ }
418
+ }
419
+ ```
420
+
421
+ ```json
422
+ {
423
+ "$contract": "0.1",
424
+ "id": "docs",
425
+ "operations": {
426
+ "doc.put": {
427
+ "kind": "command",
428
+ "input": {
429
+ "type": "object",
430
+ "required": ["id", "doc"],
431
+ "properties": {
432
+ "id": { "type": "string" },
433
+ "doc": { "type": "array", "items": { "type": "object" } },
434
+ "dry": { "type": "boolean" }
435
+ }
436
+ },
437
+ "output": true,
438
+ "policy": { "idempotency": "optional" },
439
+ "http": { "method": "PUT", "path": "/docs/:id", "body": "doc", "in": { "dry": "query" }, "status": 204 }
440
+ },
441
+ "doc.remove": {
442
+ "kind": "command",
443
+ "input": { "type": "object", "required": ["id"], "properties": { "id": { "type": "string" } } },
444
+ "output": true
445
+ }
446
+ }
447
+ }
448
+ ```
449
+
450
+ ## §7 The HTTP server binding
451
+
452
+ `serveHttp(contract, handlers, options)` (`@jarenjs/contract/http`) is a
453
+ **binding**, modeled like a `@jarenjs/db` driver: it has a `name`, a
454
+ frozen `capabilities` table that states what it cannot carry, and one
455
+ method a host calls per request — `dispatch(request) → Promise<response>`
456
+ over plain JSON-ish objects. It routes, decodes, normalizes, validates,
457
+ calls the handler, validates the output, applies idempotency and
458
+ entity-tag policy, and answers with the declared statuses and one stable
459
+ error body. **Everything a request can do wrong is a coded response;
460
+ nothing a request can do escapes as a throw** — `dispatch` rejects only
461
+ for a malformed request *object* (`JC1004`), which is an adapter author's
462
+ mistake.
463
+
464
+ ```jsonc
465
+ // HttpRequest — what an adapter builds and what a test hands to dispatch
466
+ { "method": "PUT", "url": "/api/products/12/master?dry=true", // origin-less path + optional ?query
467
+ "headers": { "content-type": "application/json", "idempotency-key": "k-1" }, // lowercase names; a value is a string, or an array when the adapter saw repeated field lines
468
+ "body": "{\"revision\":4,...}" } // string | Uint8Array | null, as received; an optional `signal` (AbortSignal) rides along
469
+ // HttpResponse — what dispatch answers
470
+ { "status": 200, "headers": { "content-type": "application/json; charset=utf-8", "x-jaren-trace": "…" }, "body": "{…}" }
471
+ ```
472
+
473
+ The dispatcher is `{ dispatch, capabilities, contract, describe(),
474
+ close() }` — `close()` ends every live SSE stream (§18.1);
475
+ `capabilities` is `{ name: "http", status: true, headers: true, media: true,
476
+ head, etag: true, idempotency: <ledger present>, validatedOutput:
477
+ <validateOutput === "always">, stream: true, cancel: "signal" }` — a
478
+ declared downgrade (`head: false`, `validatedOutput: false`,
479
+ `idempotency: false`) is reported here, never silent.
480
+
481
+ ### §7.1 Handlers and the request context
482
+
483
+ `handlers` maps operation id → `Handler = (input, ctx) => value |
484
+ Promise<value> | ContractFailure`. `input` is the reassembled, validated
485
+ input object (`null` when the operation declares no `input`); the value
486
+ is the operation's output. `ctx` is frozen per request:
487
+
488
+ | member | meaning |
489
+ |---|---|
490
+ | `op` | the compiled operation |
491
+ | `trace` | the server trace id of this request (also `x-jaren-trace` on the response and `requestId` in an error body) |
492
+ | `method`, `path` | the request line, path without the query |
493
+ | `params` | the raw decoded path strings, frozen |
494
+ | `headers` | **declared header members only** (by header name, string values) plus `if-match`/`if-none-match` when present — the binding reads no other request header on a handler's behalf |
495
+ | `body` | the raw request body of an **opaque** operation (`string | Uint8Array | null`); `null` for a JSON operation, whose body was decoded into `input` |
496
+ | `signal` | the request's `AbortSignal` when the adapter has one (the node adapter aborts it when the client goes away before the response finished), else `null` |
497
+ | `idempotency` | `{ key, scope }` when this request runs under an idempotency key, else `null` |
498
+ | `fail(code, params?, details?, { retryable? }?)` | a declared failure by code — returns a `ContractFailure` value the handler returns; `params` feed the message catalog, `details` become the wire `details` (validated against the declaration's schema when it has one), `retryable` overrides the default taken from `policy.retry.on` |
499
+ | `etag(tag, { strong? }?)` | arm the entity-tag path (§7.5); `tag` is the opaque tag without quotes |
500
+ | `status(n)` | override the success status; must be an integer in 200–299 (`JC1006` otherwise — a host error the handler boundary settles into `JC2008`, seen by `onError`) |
501
+
502
+ An **opaque** operation (`http.opaque`) takes a *raw* handler: `(input,
503
+ ctx) => { status, headers?, body? }` with the bytes in `ctx.body`; it
504
+ bypasses media, parse, body assembly, idempotency and output validation.
505
+ Its transport members are decoded and normalized into `input` and
506
+ validated like any other input (`JC2006`) — they **are** its whole
507
+ input, since an opaque operation cannot declare a body-located member
508
+ (`JC0017`, §4.5). `input` is `null` when the operation
509
+ declares none. Its response is passed through verbatim plus
510
+ `x-jaren-trace`; a value that is not `{ status, headers?, body? }` is
511
+ `JC2010`. It may still `ctx.fail` a declared code (answered as JSON like
512
+ every other error).
513
+
514
+ A handler may also **throw** a `ContractRuntimeError` whose `code` the
515
+ operation declares — that is a declared failure too (`params` and
516
+ `retryable` are read from it). Any other throw, rejection or hostile value
517
+ is `JC2008`.
518
+
519
+ ### §7.2 The pipeline, in order
520
+
521
+ 1. **The request object.** `method`/`url` strings, `headers` an object,
522
+ `body` a string, `Uint8Array` or `null` (an absent body is `null`) — a
523
+ malformed object is `JC1004`, **rejected**, never a response.
524
+ 2. **Route.** `url` is split at the first `?`; the path goes to
525
+ `contract.match(method, path)`; under `HEAD` with `head` on, `HEAD`
526
+ is tried, then `GET`. No match: an undecodable path (a malformed
527
+ percent-escape) is `JC2011`; the `wellKnown` path answers `describe()`
528
+ under GET/HEAD (405 otherwise); `contract.allowed(path)` non-empty
529
+ (with `HEAD` added beside `GET` when `head` is on) is `JC2002` with
530
+ `Allow`; else `JC2001`. A matched operation without a handler (a
531
+ `partial` server) is `JC2013`.
532
+ 3. **The body limit.** A `content-length` above `policy.limits.maxBodyBytes`
533
+ is `JC2003` **before** any read (the adapters honor this too, §9); a
534
+ body whose byte length exceeds the limit is `JC2003` after. Applies to
535
+ every matched operation, opaque and body-less included.
536
+ 4. **Opaque** → the transport input validated as in step 8 when no
537
+ member is body-located (`JC2006`), then the raw handler through the
538
+ same boundary as step 10; done.
539
+ 5. **Media.** A body-carrying operation (a body-located member or a
540
+ whole-body member) with a non-empty body requires a `content-type`
541
+ whose `type/subtype` is the operation's `http.media` (parameters
542
+ ignored, case-insensitive; a `+json` structured-syntax suffix is
543
+ accepted for `application/json`); else `JC2004`. A body-less
544
+ operation with a body **ignores** the body. An empty body needs no
545
+ media.
546
+ 6. **Parse.** Bytes are decoded as strict UTF-8 first (invalid → `JC2005`;
547
+ a leading BOM is stripped by the decoder); then `JSON.parse` (a failure
548
+ is `JC2005`).
549
+ 7. **Assemble** the input object through a prototype-safe setter only, in
550
+ this order: path members (raw decoded strings), query members
551
+ (`URLSearchParams` semantics — `+` is a space; a member listed in
552
+ `transport.members.repeated` collects every occurrence into an array,
553
+ every other member is last-wins; an **undeclared query key is
554
+ ignored, never merged**; an undecodable query is `JC2012`), declared
555
+ header members (by their lowercased name; §7.4), then the transport
556
+ normalizer over exactly those members (`coerceTypes`), then the body,
557
+ **never coerced**: `http.body` names a member → the parsed value is
558
+ that member; otherwise the parsed value must be an object (`JC2006`
559
+ with `path: ""` otherwise) and each of its own members is set unless
560
+ it names a path/query/header member (which is **ignored** — the body
561
+ cannot override a location the request already answered); an
562
+ undeclared body member is set and left for the validator to judge
563
+ under the schema's own `additionalProperties`. A JSON body's own
564
+ `__proto__` member becomes an own data property, never a prototype.
565
+ 8. **Validate** the input with the operation's compiled validator →
566
+ `JC2006`, `details` by `policy.errors.details` (§7.3). A
567
+ **subscribe** operation branches here: with `accept:
568
+ text/event-stream` the response is the SSE stream, without it the
569
+ one-shot snapshot read (§18.1); the steps below never run for it
570
+ (its `policy.idempotency` is `none` by construction).
571
+ 9. **Idempotency** when `policy.idempotency !== "none"` (§8): a missing
572
+ `Idempotency-Key` is `JC2007` under `required` and runs plainly under
573
+ `optional`; otherwise the input is hashed and the ledger claimed.
574
+ 10. **The handler**, through **one uniform promise boundary** — a
575
+ synchronous throw, a non-promise return and a rejection settle
576
+ alike. A `ContractFailure` (from `ctx.fail`) or a thrown
577
+ `ContractRuntimeError` with a declared code → the declared error
578
+ response; anything else → `JC2008`, its cause handed to `onError`.
579
+ A value whose `then` accessor throws is a rejection here — the
580
+ hostile-value case of `JC2008`.
581
+ 11. **Output validation** (`validateOutput: "always"`, the default): the
582
+ value against the operation's output validator → `JC2010` on failure
583
+ or on a throwing accessor; the validator's errors reach `onError`,
584
+ never the wire. `"never"` is a declared downgrade
585
+ (`capabilities.validatedOutput: false`).
586
+ 12. **Entity tags** when the handler armed one (§7.5): `If-Match` first
587
+ (strong comparison; mismatch → `JC2014`), then `If-None-Match` (weak
588
+ comparison; match → `304` on GET/HEAD, `JC2014` on other methods).
589
+ Because the tag is known only after the handler runs, both are
590
+ evaluated **after** step 10 and only when a tag was armed — a
591
+ handler that wants a pre-execution precondition compares
592
+ `ctx.headers["if-match"]` itself.
593
+ 13. **Serialize.** `JSON.stringify(value)`; a value JSON cannot carry
594
+ (a cycle, a BigInt) is `JC2010`; `undefined` answers no body. Status
595
+ is `ctx.status()` or `http.status`; headers `content-type: <media>;
596
+ charset=utf-8` (when a body), `x-jaren-trace`, `etag` when armed;
597
+ a `204` carries no body; a HEAD carries the `content-length` of the
598
+ body it dropped and no body. Then the ledger claim is settled (§8).
599
+
600
+ Every step's failure path returns a response. `dispatch` never rejects
601
+ for request content; a defect of the binding itself is caught last and
602
+ answered `JC2008` too, so a server never sees an unhandled rejection.
603
+
604
+ ### §7.3 The wire error
605
+
606
+ Every non-2xx JSON response body is:
607
+
608
+ ```jsonc
609
+ { "code": "JC2006", // a JC2xxx code, or the DECLARED error code ("conflict")
610
+ "message": "the input of operation product.save is invalid",
611
+ "requestId": "3f2c…", // the server trace, equal to the x-jaren-trace header
612
+ "details": [ { "path": "/revision", "keyword": "type" } ], // by policy.errors.details; absent under "none"
613
+ "retryable": false }
614
+ ```
615
+
616
+ `message` is rendered from the msgid through the catalog (the host
617
+ `catalog` option first, the English catalog in the package second) and
618
+ **never interpolates a request value** — its parameters are the operation
619
+ id, a declared limit, a media type, a method list, a declared header
620
+ member name or a declared error code. `details` follows
621
+ `policy.errors.details` for a validation failure: `none` → absent;
622
+ `paths` → `[{ path, keyword }]` (instance path + keyword, no values, no
623
+ schema); `full` → the validator's own error records with their `params`.
624
+ For a **declared** failure, `details` is what the handler gave to
625
+ `ctx.fail`, validated against `errors[code].schema` when the declaration
626
+ has one (a mismatch is `JC2010` — the handler broke its own error
627
+ contract; without a schema, the details must still be a JSON value) and
628
+ crossing regardless of `policy.errors.details` (that policy governs
629
+ validation failures; a declared error's details are the operation's own
630
+ contract). A declared failure's message is `contract/error/<code>` from
631
+ the host catalog when it defines one, else the generic
632
+ `contract/handler-error` (`operation {op} failed with {code}`); its
633
+ `retryable` is the failure's own, or whether `policy.retry.on` names the
634
+ code. The `errorBody` option projects the wire record (the body plus
635
+ `status`) into another JSON shape for legacy consumers; a projector that
636
+ throws or answers non-JSON falls back to the shape above.
637
+
638
+ Every response carries `x-jaren-trace: <trace>`; every error response
639
+ also `cache-control: no-store`; a 405 carries `Allow`; a 409
640
+ `in-progress` carries `retry-after: 1`; a 412 from `If-None-Match`
641
+ carries the `etag`. Header names are lowercase.
642
+
643
+ The taxonomy — code, status, msgid, retryable — is the normative table
644
+ below; `HTTP_ERRORS` (`@jarenjs/contract/http`) is the same table as data,
645
+ `CONTRACT_CODES` lists every code, the English catalog
646
+ (`contractMessagesEn`) has exactly these msgids plus
647
+ `contract/handler-error`, and a test holds the four equal.
648
+
649
+ | code | status | msgid | retryable | when |
650
+ |---|---:|---|---|---|
651
+ | `JC2001` | 404 | `contract/not-found` | no | no operation matches method + path (a lowercase method token, an unknown path, a trailing slash) |
652
+ | `JC2002` | 405 | `contract/method-not-allowed` | no | the path shape is served under other methods; `Allow` lists them (`HEAD` beside `GET` when `head` is on) |
653
+ | `JC2003` | 413 | `contract/body-too-large` | no | `content-length` or read length > `policy.limits.maxBodyBytes` |
654
+ | `JC2004` | 415 | `contract/unsupported-media` | no | a body-carrying operation with a non-empty body whose `content-type` is not the declared media (parameters ignored, `+json` accepted for JSON) |
655
+ | `JC2005` | 400 | `contract/malformed-json` | no | body present and not valid JSON, or not valid UTF-8 |
656
+ | `JC2006` | 400 | `contract/invalid-input` | no | the reassembled input fails the operation's input validator; also a non-object body under `body:"*"` (`path: ""`), and a body the canonicalizer refuses (a lone surrogate; keyword `canonical`) |
657
+ | `JC2007` | 400 | `contract/idempotency-key-required` | no | `policy.idempotency: "required"` and no (or an empty) `idempotency-key` header |
658
+ | `JC2008` | 500 | `contract/handler-failed` | no | the handler threw a non-declared error, rejected, answered an undeclared code or a hostile value, or the binding itself faulted; `onError(err, ctx)` sees the cause |
659
+ | `JC2009` | 409 | `contract/idempotency-conflict` | see §8 | the ledger says `in-progress` (retryable, `retry-after: 1`) or `mismatch` (not retryable, `details: [{ "kind": "mismatch" }]`) |
660
+ | `JC2010` | 500 | `contract/invalid-output` | no | the handler value fails the output validator, cannot be serialized, a raw response is malformed, or a declared error's details fail their schema — the server broke the contract |
661
+ | `JC2011` | 400 | `contract/malformed-path` | no | the path carries a malformed percent-escape |
662
+ | `JC2012` | 400 | `contract/malformed-query` | no | the query string is not decodable (a malformed escape, invalid UTF-8) |
663
+ | `JC2013` | 501 | `contract/not-implemented` | no | a `partial` server has no handler for the operation |
664
+ | `JC2014` | 412 | `contract/precondition-failed` | no | `If-Match` does not match the armed tag (strong comparison), or `If-None-Match` matches on a non-GET/HEAD |
665
+ | `JC2015` | 400 | `contract/invalid-header` | no | a declared scalar header member arrived repeated, or a header value is not a string |
666
+
667
+ `JC2016–JC2049` are reserved for later http-side codes; a new one is
668
+ added to `CONTRACT_CODES`, this table and the English catalog in one
669
+ change.
670
+
671
+ Host programming errors are `ContractHostError` — a `TypeError` with
672
+ `code` and `reason`, **thrown** at construction or from `ctx`, never a
673
+ wire response:
674
+
675
+ | code | when |
676
+ |---|---|
677
+ | `JC1001` | `serveHttp`, `serveLocal` or `servePort`: `handlers` is not an object, a key names no operation of the contract, a value is not a function, or an option (a channel without `postMessage`, say) is malformed |
678
+ | `JC1002` | `serveHttp`, `serveLocal` or `servePort`: an operation has no handler (on `serveHttp`, unless `partial` is set; on the status-less bindings an opaque operation is exempt — §15) |
679
+ | `JC1003` | a binding cannot carry a declared feature: an operation declares `policy.idempotency` and no `ledger` was given (say so, never degrade) |
680
+ | `JC1004` | `dispatch` received a malformed request object |
681
+ | `JC1005` | a client (`invoke`, `url`) or the contract effect was asked for an operation the contract does not declare, or `invoke` for an opaque operation (§10; on `local`/`port` the binding cannot carry it at all — §15, §16) |
682
+ | `JC1006` | `ctx.status(n)` with `n` not an integer in 200–299 |
683
+ | `JC1007` | `contractAppBinding`: `ops` names an operation the contract does not declare, or `namespace`/`statePath` is malformed (§11) |
684
+ | `JC1008` | `openHttpClient`, `openPortClient`, `client.url`, `createContractEffect`, `createContractSubscription` or a projection (`publicProjection`, `toOpenApi`, `toTypeScript`, `toMarkdown`, `contractTools`): an argument or option is malformed (§10, §11, §12, §16) |
685
+ | `JC1009` | the stream wire's SSE encoder was handed text the frame cannot carry: a bare carriage return inside `data`, a line terminator inside `event` or `id` (§18) |
686
+ | `JC1010` | `client.subscribe` was asked for an operation that is not a subscribe operation (§19) |
687
+
688
+ ### §7.4 Headers
689
+
690
+ The binding reads, on the request: `content-type` and `content-length`
691
+ (steps 3, 5), `idempotency-key` (§8), `if-match` and `if-none-match`
692
+ (§7.5), and the **declared header members** — an input member mapped to
693
+ `header` travels as the header named by the member's name lowercased
694
+ (declare the member `x-tenant` to read `X-Tenant`). A scalar member takes
695
+ one line (a repeated line is `JC2015`; the `fetch` adapter cannot see
696
+ repeats — the platform combines them — while the `node` adapter passes
697
+ distinct lines as an array); an array-typed member (listed in
698
+ `transport.members.repeated`) collects repeated lines, or splits one line
699
+ on commas (RFC 9110 list syntax). No other request header is read, and
700
+ none is echoed. `x-jaren-trace` on a request is never read — the trace is
701
+ the server's; `x-attempt` or any client attempt id is never read either.
702
+
703
+ ### §7.5 Entity tags and conditionals
704
+
705
+ `ctx.etag(tag)` arms a weak tag (`etag: W/"tag"`), `ctx.etag(tag, {
706
+ strong: true })` a strong one (`etag: "tag"`). `If-None-Match` is
707
+ compared weakly (`*` matches; `W/` indicators are ignored) → `304` with
708
+ the `etag` header and no body on GET/HEAD, `412` (`JC2014`) on other
709
+ methods; `If-Match` is compared strongly (`*` matches; a weak candidate
710
+ or a weak armed tag never matches) → `412` on a mismatch. Both are
711
+ evaluated after the handler ran, and only when it armed a tag.
712
+
713
+ ### §7.6 HEAD, the well-known path, options
714
+
715
+ `head: true` (default) answers `HEAD` for every `GET` operation by
716
+ running the handler and dropping the body (a declared `HEAD` operation
717
+ wins); with `head: false` a HEAD is a 405 listing `GET`. The `wellKnown`
718
+ path (`/.well-known/jaren-contract`, or another absolute path, or `false`)
719
+ answers `describe()` — `revision: null` until the revision lands, `compat`
720
+ present — for negotiation. `trace` (default `crypto.randomUUID`) generates
721
+ the server trace; `scope(ctx)` derives the idempotency scope (§8);
722
+ `partial` allows missing handlers; `validateOutput` is `"always" |
723
+ "never"`; `errorBody(wire, ctx)` and `onError(err, ctx)` are the two host
724
+ hooks (`ctx` is `null` before an operation is matched); `catalog` is a
725
+ message catalog (templates or compiled renderers) consulted before the
726
+ English one; `now` is the clock stamped into ledger claims.
727
+
728
+ ## §8 Idempotency and the ledger
729
+
730
+ An operation with `policy.idempotency` of `optional` or `required` runs
731
+ under an **idempotency key**: the caller's, sent as `Idempotency-Key`,
732
+ scoped by the host's `scope(ctx)` (an installation, a principal — never a
733
+ rotating token) — one of the three identities this format keeps apart
734
+ (the **trace** is the server's per request; the **attempt** id is the
735
+ caller's per dispatch and never crosses). `serveHttp` refuses (`JC1003`)
736
+ an idempotent operation without a `ledger`.
737
+
738
+ The **request hash** is the lowercase hex SHA-256 over the RFC 8785
739
+ canonical bytes of the validated input (`canonicalSha256` in
740
+ `@jarenjs/json/canonical`) — so a body with its members in another order
741
+ is the same request. The binding then calls the ledger:
742
+
743
+ ```jsonc
744
+ // the Ledger interface — every method may return its value or a promise of it
745
+ { "claim": "({ op, scope, key, hash, now }) → { state: 'new', ref } | { state: 'replay', response } | { state: 'in-progress' } | { state: 'mismatch' }",
746
+ "commit": "(ref, response) → void",
747
+ "fail": "(ref, retryable, response?) → void",
748
+ "lookup": "({ op, scope, key }) → record | null" }
749
+ ```
750
+
751
+ Semantics the binding relies on: same key + same hash → `replay` — the
752
+ stored `{ status, headers, body }` **verbatim** with a fresh
753
+ `x-jaren-trace` and `idempotent-replayed: true`; same key + different
754
+ hash → `mismatch` (409, `details: [{ "kind": "mismatch" }]`, not
755
+ retryable); `started` and not expired → `in-progress` (409, `retry-after:
756
+ 1`, retryable); `failed` with `retryable: true` → treated as `new` (the
757
+ key may be retried); `failed` and not retryable → `replay` of the stored
758
+ failure. After the handler: a success **commits** the response; a
759
+ declared failure is recorded as **failed** with its response and its
760
+ `retryable`; a server fault (`JC2008`, `JC2010`, `JC2014`) **releases**
761
+ the key as retryable with no response. `now` on a claim is the binding's
762
+ clock (`options.now`), which a ledger may prefer to its own. Opaque
763
+ operations bypass the ledger; reads never carry a key. A ledger that
764
+ throws or rejects is reported to `onError` and the response still goes
765
+ out (a throwing `claim` is `JC2008`).
766
+
767
+ `createMemoryLedger({ ttlMs = 86_400_000, now })` (`@jarenjs/contract/ledger`)
768
+ is the reference implementation over a `Map`: synchronous,
769
+ single-process, expiring on `claim` and `lookup`, with `sweep()` for a
770
+ host timer and `size`. The record it keeps is:
771
+
772
+ ```jsonc
773
+ { "id": "product.save|tenant-a|k-1", // "<op>|<scope>|<key>"
774
+ "op": "product.save", "scope": "tenant-a", "key": "k-1",
775
+ "hash": "9f2a…", // 64 lowercase hex characters
776
+ "status": "committed", // started | committed | failed
777
+ "response": { "status": 200, "headers": { "…": "…" }, "body": "{…}" }, // or null
778
+ "retryable": null, // of a failed record
779
+ "createdAt": 1755000000000, "updatedAt": 1755000000000, "expiresAt": 1755086400000 }
780
+ ```
781
+
782
+ Two documents ship the same shape as **data**, for a host that wants
783
+ durability (this package imports neither `@jarenjs/db` nor
784
+ `@jarenjs/flow`): `idempotencyLedgerModel` is a `$model` 0.1 document —
785
+ collection `ledger`, key `/id`, that record as its schema (closed),
786
+ indexes on `expiresAt` and `status` — a host opens it with `openStore`
787
+ and implements the interface over the collection; `commandLifecycleFsm`
788
+ is a `$fsm` 0.1 document — `idle → started` on `claim`, `started →
789
+ committed` on `commit`, `started → failed` on `fail`, `failed → started`
790
+ on `claim` guarded by `$.context.retryable` — which the memory ledger
791
+ walks exactly.
792
+
793
+ ## §9 Adapters
794
+
795
+ Two dependency-free, structurally typed adapters put a dispatcher behind
796
+ the platform:
797
+
798
+ - **`toFetchHandler(dispatcher)`** (`@jarenjs/contract/fetch`) →
799
+ `(Request) => Promise<Response>` — Bun.serve, Deno, service workers,
800
+ Cloudflare-style hosts, and Hono. It lowercases the headers into a
801
+ plain object, matches the operation first (cheap) to decide how the
802
+ body is read — `text()` for a JSON operation, `arrayBuffer()` for an
803
+ opaque one, and **not at all** for an unmatched request or a declared
804
+ `content-length` above the operation's limit (the dispatcher answers
805
+ the 413 from the header) — forwards `request.signal`, and builds the
806
+ `Response` from the dispatcher's status, headers and body.
807
+ - **`toNodeHandler(dispatcher)`** (`@jarenjs/contract/node`) → `(req,
808
+ res)` — `http.createServer`'s listener and Express middleware. It
809
+ collects the body chunk by chunk up to the operation's limit; on
810
+ overflow it stops reading, answers the 413 with `connection: close`
811
+ and destroys the request once the response has flushed; a declared
812
+ `content-length` above the limit is never read; an unmatched request's
813
+ body is never read. Bytes reach the dispatcher as received (its strict
814
+ UTF-8 decode decides `JC2005`); repeated header lines arrive as arrays
815
+ (`headersDistinct`); `ctx.signal` aborts when the client goes away
816
+ before the response finished; `content-length` is set on every body.
817
+
818
+ Fastify, Hono and Express are recipes in the README, each ≤15 lines and
819
+ executed by a test that imports the framework from the benchmark
820
+ workspace only — no framework is a dependency of this package.
821
+
822
+ ## §10 The HTTP client binding
823
+
824
+ `openHttpClient(contract, options)` (`@jarenjs/contract/client`) is the
825
+ client half of the http driver pair: `open(contract, options) → Client`
826
+ with `Client = { invoke, url, negotiate, pending, capabilities, contract,
827
+ describe(), close() }`. It is **binding-agnostic in shape** — the app
828
+ binding (§11) and later the AI tools read only `invoke`, `contract` and
829
+ `capabilities` — and **total in behavior**: `invoke` resolves an
830
+ **outcome** for everything a server or a network can do and rejects
831
+ only for the host's own mistake (`JC1005`: an operation the contract
832
+ does not declare, or an opaque one — `invoke` carries JSON; an opaque
833
+ operation is reached through `url`).
834
+
835
+ ```jsonc
836
+ // options — every one has a default
837
+ { "fetch": "globalThis.fetch", // (url, init) => Promise<Response>; injectable (a toFetchHandler, a recorder)
838
+ "baseUrl": "", // prefixed to every path; '' = relative
839
+ "headers": {}, // static headers, merged UNDER per-call ones
840
+ "keys": "crypto.randomUUID", // the idempotency key generator
841
+ "storage": null, // { read(), write(value) } — the @jarenjs/app docstore adapter shape — for durable key records
842
+ "timeoutMs": 0, // per request; 0 = none; composed with ctx.signal
843
+ "sleep": "(ms, signal) => Promise", // the retry backoff sleeper (injectable)
844
+ "catalog": null, // a message catalog consulted before the English one
845
+ "wellKnown": "/.well-known/jaren-contract", // where negotiate() asks
846
+ "now": "Date.now" } // the clock stamped into key records
847
+ ```
848
+
849
+ `capabilities` is `{ name: "http", status: true, headers: true, media:
850
+ true, etag: true, idempotency: true, durableKeys: <storage given>,
851
+ stream: true, cancel: "signal" }` — frozen, the driver rule; `stream`
852
+ is the `subscribe` half (§19).
853
+
854
+ ### §10.1 The outcome and the three identities
855
+
856
+ `invoke(op, input, ctx) → Promise<Outcome>` with `ctx = { signal?,
857
+ attempt?, idempotencyKey?, headers?, ifNoneMatch?, ifMatch? }`. An
858
+ outcome is **JSON** (no `Error`, `Response`, `Headers` or
859
+ `AbortController` ever), tagged:
860
+
861
+ ```jsonc
862
+ { "ok": true, "value": { "...": "the output, validated" }, "meta": { "...": "" } }
863
+ { "ok": false, "kind": "failure" | "network" | "contract" | "cancelled",
864
+ "error": { "code": "conflict", "message": "…", "status": 409, "details": null, "retryable": false },
865
+ "meta": { "op": "product.save", "attempt": 3, "trace": "3f2c…", "revision": null, "etag": null, "notModified": false } }
866
+ ```
867
+
868
+ - `failure` — the server answered a **declared** operation error, or a
869
+ `JC2xxx` taxonomy error (§7.3): the peer spoke the contract and said no;
870
+ - `network` — the transport rejected or timed out (`JC2051`, retryable);
871
+ - `contract` — the peer (or, pre-send, the input) violated the contract:
872
+ invalid input (`JC2050`), an invalid success body (`JC2053`), a key
873
+ store that threw (`JC2054`), an undeclared response (`JC2055`);
874
+ - `cancelled` — `ctx.signal` aborted or `close()` was called (`JC2052`).
875
+
876
+ **The fixed shapes — every binding (D6).** An outcome never carries an
877
+ `undefined` member: an optional member that is absent is `null`
878
+ (`isJsonValue` — the predicate `@jarenjs/app`'s task effect and state
879
+ honor — rejects `undefined`, and an outcome with one would fall back to
880
+ a string in the task effect). `error` is always
881
+
882
+ `{ code, message, status, details, retryable }` — `OUTCOME_ERROR_MEMBERS`
883
+
884
+ and `meta` is always
885
+
886
+ `{ op, attempt, trace, revision, etag, notModified }` — `OUTCOME_META_MEMBERS`
887
+
888
+ in that member order, on **every** binding — http, and the `local`/
889
+ `port`/`stream` bindings that follow. A binding that cannot carry a
890
+ member carries `null` (`status` on a binding without statuses, `etag`,
891
+ `trace`) or `false` (`notModified`) and says so in its `capabilities`;
892
+ it **never omits the member**. The two lists are exported from
893
+ `@jarenjs/contract/client` (frozen arrays) so a binding asserts against
894
+ them rather than restating them, and `isOutcome` refuses a value whose
895
+ `error` or `meta` lacks a member or carries `undefined` in one.
896
+
897
+ `meta` keeps the three identities apart, by construction: **`attempt`** is
898
+ the caller's (`ctx.attempt`, echoed verbatim, `null` when none) — it is
899
+ never sent and **never read from any response header**; **`trace`** is
900
+ the server's `x-jaren-trace` (`null` when the wire carried none) — it is
901
+ never generated here; the **idempotency key** (§10.3) is the client's
902
+ and travels only as `Idempotency-Key`. `revision` is reserved for the
903
+ contract revision; `etag` carries a success's entity tag; `notModified`
904
+ is true exactly for a 304.
905
+
906
+ ### §10.2 What `invoke` does, in order
907
+
908
+ 1. **Route.** An unknown `op` or an opaque one throws `JC1005`.
909
+ 2. **Validate** `input` with the operation's compiled input validator —
910
+ the SAME validator the server runs. `null`/`undefined` is `{}` for an
911
+ operation with input; an input-less operation refuses any non-null
912
+ input. A failure resolves `kind: "contract"` `JC2050` with `details`
913
+ by `policy.errors.details`; **nothing was sent**.
914
+ 3. **Split by location** (`http.in`): path variables → `encodeURIComponent`
915
+ per segment into the canonical template; query members →
916
+ `URLSearchParams` (an array-typed member repeats the key per element;
917
+ `null`/`undefined` members are omitted; a scalar is its string, a
918
+ non-scalar its JSON); header members → the header named by the
919
+ member lowercased (an array as a `, `-joined list); the body: `http.body`
920
+ names a member → `JSON.stringify` of that member's value; otherwise
921
+ the object of the body-located members, stringified. `method` from
922
+ `http.method`; `content-type: <http.media>` only when a body is sent.
923
+ Static `headers`, then `ctx.headers`, then the declared header
924
+ members, then the protocol headers the client owns (`if-none-match`,
925
+ `if-match`, `content-type`, `idempotency-key`) — later wins. A value
926
+ JSON or a URL cannot carry is `JC2050` (`keyword: "encoding"`).
927
+ 4. **Idempotency** (§10.3) when `policy.idempotency` is `optional` or
928
+ `required`: key = `ctx.idempotencyKey ?? keys()`, sent as
929
+ `Idempotency-Key`; with `storage`, recorded before the send (`JC2054`
930
+ when the store throws — nothing is sent blind).
931
+ 5. **Send** through `fetch` with the composed signal (`ctx.signal`,
932
+ `timeoutMs`, the client's `close()`). A rejection is `kind:
933
+ "cancelled"` when the caller's signal aborted (or the rejection is an
934
+ `AbortError`), else `kind: "network"` `JC2051` whose message carries
935
+ the error's **name only** — never its text, which may embed the URL
936
+ and credentials. A timeout is therefore `network`, not `cancelled`.
937
+ 6. **Assemble** the outcome from status + headers + body (the table
938
+ below); `meta.trace` from `x-jaren-trace`, `meta.etag` from `etag`.
939
+ 7. **Retry** (§10.4) only under a declared `policy.retry`.
940
+ 8. Settle the durable record (§10.3) and resolve.
941
+
942
+ The assembly, row by row — `assembleOutcome` in the package is this
943
+ table as code; the test column names the test case that pins the row
944
+ (`test/contract/client-outcomes.test.js`, checked against this table):
945
+
946
+ | status | body | kind | code | retryable | test |
947
+ |---|---|---|---|---|---|
948
+ | 2xx | empty; the output schema accepts `null` | ok, `value: null` | — | — | `2xx empty body is a null value when the output allows it` |
949
+ | 2xx | empty; the output schema rejects `null` | contract | `JC2053` | no | `2xx empty body against a non-null output is JC2053` |
950
+ | 2xx | JSON that validates against `output` | ok | — | — | `2xx valid JSON is ok with meta.etag from the header` |
951
+ | 2xx | JSON that fails `output` | contract | `JC2053` (details by policy) | no | `2xx invalid output is JC2053 with details by policy` |
952
+ | 2xx | not JSON | contract | `JC2053` | no | `2xx non-JSON is JC2053` |
953
+ | 304 | — | ok, `value: null`, `meta.notModified`, `meta.etag` | — | — | `304 is ok null with notModified and the etag` |
954
+ | other | JSON `{ code }` the operation declares | failure | the declared code; `status`, `details`, `message` from the body (else rendered), `retryable` from the body (else `policy.retry.on`) | body | `an error body with a declared code is a failure` |
955
+ | other | JSON `{ code }` a `JC2xxx` taxonomy code | failure | the taxonomy code; `status`, `retryable` from the body | body | `an error body with a taxonomy code is a failure` |
956
+ | other | JSON with an unknown/undeclared `code` | contract | `JC2055` (`status` kept) | 5xx/429 | `an undeclared code is JC2055` |
957
+ | other | JSON without a string `code` | contract | `JC2055` | 5xx/429 | `an error body without a code is JC2055` |
958
+ | other | not JSON, or empty | contract | `JC2055` | 5xx/429 | `a non-JSON error body is JC2055` |
959
+ | none | the transport rejected | network | `JC2051` | yes | `a transport rejection is a network outcome named by the error name only` |
960
+ | none | the caller aborted | cancelled | `JC2052` | no | `an abort is a cancelled outcome` |
961
+ | none | the input failed pre-send | contract | `JC2050` | no | `invalid input is JC2050 and nothing is sent` |
962
+ | none | the key store threw pre-send | contract | `JC2054` | no | `a throwing key store is JC2054 and nothing is sent` |
963
+
964
+ "other" is every status that is neither 2xx nor 304 — 1xx, a redirect
965
+ the platform did not follow, 4xx, 5xx. A status outside 100–599 on the
966
+ response object is `JC2053` (the transport is broken, not the server).
967
+
968
+ ### §10.3 The client codes and the durable keys
969
+
970
+ The client codes — `CLIENT_ERRORS` (`@jarenjs/contract/client`) is this
971
+ table as data; `CONTRACT_CODES` lists every code; the English catalog
972
+ has exactly these msgids beside §7's; a test holds them equal:
973
+
974
+ | code | kind | msgid | retryable | when |
975
+ |---|---|---|---|---|
976
+ | `JC2050` | contract | `contract/client-invalid-input` | no | the input fails the operation's input validator (or cannot be encoded) before anything was sent |
977
+ | `JC2051` | network | `contract/network` | yes | the transport rejected or the per-request timeout fired; the message names the error's name only |
978
+ | `JC2052` | cancelled | `contract/cancelled` | no | `ctx.signal` aborted, the client was closed, or an abort interrupted a retry backoff |
979
+ | `JC2053` | contract | `contract/invalid-response` | no | a 2xx body is not JSON or fails the output validator; a response object whose status is not 100–599 |
980
+ | `JC2054` | contract | `contract/key-storage-failed` | no | the durable key store threw before the send |
981
+ | `JC2055` | contract | `contract/undeclared-response` | 5xx/429 | an error response whose body is not a declared or taxonomy code; `error.status` is kept |
982
+ | `JC2056` | — | `contract/not-a-contract` | no | `negotiate`: no description at the well-known path, not a `$contract: "0.1"` description, or another contract `id` |
983
+ | `JC2057` | — | `contract/incompatible` | no | `negotiate`: a version neither end declares compatible |
984
+ | `JC2058` | contract | `contract/host-failed` | no | the contract effect (§11) projected a thrown host value into an outcome |
985
+
986
+ `JC2059–JC2069` are reserved for later client-side codes.
987
+
988
+ **The idempotency key**, client half. For an operation whose
989
+ `policy.idempotency` is `optional` or `required` the client **always**
990
+ sends a key — `ctx.idempotencyKey` when given (a caller that retries by
991
+ hand keeps the same key), else `keys()` — so the server's ledger (§8)
992
+ can deduplicate every command the client sends, and a declared `retry`
993
+ on a command is safe (the compiler refuses `retry` on a command whose
994
+ idempotency is not `required`, `JC0014`). With a `storage` (the
995
+ `@jarenjs/app` `createDocStore` adapter shape, `{ read(), write(value) }`,
996
+ sync or async) the client records `{ op, key, hash, at }` under
997
+ `<store>["jaren-contract"][<contract id>][<op>][<key>]` **before the
998
+ request** — `hash` is the request hash of §8 (the same SHA-256 over the
999
+ canonical input the server computes), `at` is `now()`, and the input
1000
+ itself is **never stored** — and drops the record after a terminal
1001
+ outcome: `ok`, any `failure`, or a `contract` outcome that is not
1002
+ retryable. A `network` or `cancelled` outcome **leaves it**, so a
1003
+ process that restarts can ask `client.pending() → [{ op, key }]` and
1004
+ reconcile each with the server (the ledger's `lookup` by op/scope/key
1005
+ is the server-side half). A store that throws on the pre-send write is
1006
+ `JC2054` and nothing is sent; a store that throws on the drop leaves
1007
+ the record (the conservative side) and the outcome is unaffected.
1008
+
1009
+ ### §10.4 Retry
1010
+
1011
+ Only under a declared `policy.retry` (`{ max, on }`); never for an
1012
+ operation without one. Retried: a `network` outcome, and a `failure`
1013
+ outcome whose `error.code` is in `retry.on` (declared codes and
1014
+ `JC2xxx` taxonomy codes alike — `JC2009` in-progress is a natural
1015
+ member). At most `max` further attempts; the backoff before attempt
1016
+ `n+1` is `min(1000 · 2^n, 8000)` ms plus up to 250 ms of jitter through
1017
+ `sleep(ms, signal)`; an abort during the backoff resolves `cancelled`.
1018
+ The idempotency key stays the same across the attempts (that is what
1019
+ makes them safe); `meta.attempt` stays the caller's — retries are
1020
+ inside one attempt, not new ones.
1021
+
1022
+ ### §10.5 `url(op, input)` and `negotiate()`
1023
+
1024
+ `url(op, input)` builds `baseUrl + path + ?query` for **any** operation,
1025
+ opaque ones included, validating only the path/query members of the
1026
+ input (against their declared schemas, compiled on first use);
1027
+ `JC1005` for an unknown operation, `JC1008` for a non-object input or
1028
+ members that fail their schema. It is what an `<img src>` or a link uses
1029
+ for an opaque operation; the bytes themselves are the host's to fetch.
1030
+
1031
+ `negotiate({ signal })` fetches the server's well-known description
1032
+ (§7.6) and answers `{ compatible, reason, server, error }` with `reason`
1033
+ one of:
1034
+
1035
+ | reason | when | compatible | error |
1036
+ |---|---|---|---|
1037
+ | `same-version` | `server.version === contract.version` (both `null` included) | yes | — |
1038
+ | `server-accepts` | `contract.version ∈ server.compat` | yes | — |
1039
+ | `client-accepts` | `server.version ∈ contract.compat` | yes | — |
1040
+ | `version-mismatch` | none of the above | no | `JC2057` |
1041
+ | `not-a-contract` | no 200, not JSON, not a `$contract: "0.1"` description with an `operations` array, or the server's `id` differs from the client's (both non-null) | no | `JC2056` |
1042
+ | `unreachable` | the transport rejected | no | `JC2051` |
1043
+
1044
+ `server` is `{ id, version, compat, revision }` as the server described
1045
+ itself (`null` when unreachable or not a contract). **Nothing else is
1046
+ inferred**: an unrelated service on the port is exactly `not-a-contract`;
1047
+ two unversioned contracts are `same-version`.
1048
+
1049
+ ## §11 The app binding
1050
+
1051
+ `contractAppBinding(contract, { namespace = "contract/", statePath =
1052
+ "/contract", ops = contract.ids })` (`@jarenjs/contract/app`) returns
1053
+ **pure JSON** — `{ slice, actions, subs, schema, effect: "contract",
1054
+ subscription: "contract-stream" }` — the `fsmToApp`/`liveAppBinding`
1055
+ shape: a state slice the host mounts at `statePath`, action documents
1056
+ it spreads into its `actions`, subscription entries it spreads into its
1057
+ `subs` (one per subscribe operation, `[]` otherwise — §11.4), and the
1058
+ slice's JSON Schema for `validateState`. Neither package imports the
1059
+ other; the documents cross as JSON and the task-effect factory
1060
+ (`createTaskEffect` from `@jarenjs/app`) crosses as a function the host
1061
+ passes to `createContractEffect`. `ops` may name a subset (`JC1007` for
1062
+ an operation the contract does not declare).
1063
+
1064
+ ### §11.1 The generated document
1065
+
1066
+ Per operation, **one task slot** in the slice — `{ id: 0, status: "idle",
1067
+ kind: null, value: null, error: null, meta: null }` — and **three
1068
+ actions**, in the async-task convention of `@jarenjs/app`'s TASKS.md
1069
+ (state-side identity, guard-first completion):
1070
+
1071
+ ```jsonc
1072
+ // <namespace><op>/start — $payload is the operation's input
1073
+ { "patch": [
1074
+ { "op": "replace", "path": "/contract/catalog.load/id", "value": { "$add": ["$.contract['catalog.load'].id", 1] } },
1075
+ { "op": "replace", "path": "/contract/catalog.load/status", "value": "loading" },
1076
+ { "op": "replace", "path": "/contract/catalog.load/kind", "value": null },
1077
+ { "op": "replace", "path": "/contract/catalog.load/error", "value": null } ],
1078
+ "effects": [ { "run": "contract", "with": {
1079
+ "op": "catalog.load", "input": "$payload",
1080
+ "id": { "$add": ["$.contract['catalog.load'].id", 1] }, // the SAME increment as the patch — everything evaluates pre-transition
1081
+ "done": "contract/catalog.load/done", "slot": "catalog.load" } } ] }
1082
+
1083
+ // <namespace><op>/start of an operation whose policy.task is exhaust — the same document behind a state-side guard
1084
+ { "$if": [ { "$ne": ["$.contract['product.save'].status", "loading"] }, // a $if without else is the empty sequence (APP-FORMAT §3.2):
1085
+ { "patch": [ "…the four replaces…" ], "effects": [ "…the one contract effect…" ] } ] } // no patch, no effect, no render while loading
1086
+
1087
+ // <namespace><op>/done — $payload is { id, result: outcome } (or { id, error: outcome } for a projected host throw)
1088
+ { "$if": [ { "$eq": ["$payload.id", "$.contract['catalog.load'].id"] }, // the id guard: a stale response is the empty sequence
1089
+ { "$let": { "outcome": { "$coalesce": ["$payload.result", "$payload.error"] } },
1090
+ "$return": { "$if": [ { "$eq": ["$outcome.ok", true] },
1091
+ { "patch": [ { "op": "replace", "path": "/contract/catalog.load/status", "value": "done" },
1092
+ { "op": "replace", "path": "/contract/catalog.load/kind", "value": null },
1093
+ { "op": "replace", "path": "/contract/catalog.load/value", "value": "$outcome.value" },
1094
+ { "op": "replace", "path": "/contract/catalog.load/meta", "value": "$outcome.meta" },
1095
+ { "op": "replace", "path": "/contract/catalog.load/error", "value": null } ] },
1096
+ { "patch": [ { "op": "replace", "path": "/contract/catalog.load/status", "value": "error" },
1097
+ { "op": "replace", "path": "/contract/catalog.load/kind", "value": "$outcome.kind" },
1098
+ { "op": "replace", "path": "/contract/catalog.load/error", "value": "$outcome.error" },
1099
+ { "op": "replace", "path": "/contract/catalog.load/meta", "value": "$outcome.meta" } ] } ] } } ] }
1100
+
1101
+ // <namespace><op>/reset — releases the slot; id, value and meta are untouched
1102
+ { "patch": [ { "op": "replace", "path": "/contract/catalog.load/status", "value": "idle" },
1103
+ { "op": "replace", "path": "/contract/catalog.load/kind", "value": null },
1104
+ { "op": "replace", "path": "/contract/catalog.load/error", "value": null } ] }
1105
+ ```
1106
+
1107
+ Rules the generator keeps:
1108
+
1109
+ - The slot id read is `$<statePath>['<op>']` — a dotted operation id is
1110
+ a bracketed RFC 9535 member name, so `statePath` MUST be a chain of
1111
+ identifier-safe segments (`JC1007` otherwise); the patch paths are
1112
+ plain JSON Pointers (`/contract/catalog.load/id`). Action names carry
1113
+ the operation id verbatim (`contract/catalog.load/start`); the app
1114
+ places no restriction on action names.
1115
+ - **The id guard is the guarantee.** The `done` action compares
1116
+ `$payload.id` with the slot's id first; a payload whose id is not the
1117
+ current one yields the empty sequence — no state change, no render, no
1118
+ subscriber — so an out-of-order older response can never overwrite a
1119
+ newer one. Cancellation (below) is the optimization.
1120
+ - **The state slot and the effect agree in every mode (D10).** A task
1121
+ mode is never *named* in the document or in the effect props; the
1122
+ generator derives the document's state-side guards from `policy.task`
1123
+ exactly as it derives the document's shape from `kind`. With one slot
1124
+ per operation, what a second `start` does while the slot is `loading`:
1125
+
1126
+ | `policy.task` | in state | in the effect | which completion lands |
1127
+ |---|---|---|---|
1128
+ | `switch` | the id increments (newest wins) | the predecessor is aborted (its cancelled outcome dispatches nothing) | the newest; a predecessor's completion that arrives anyway is rejected by the guard |
1129
+ | `exhaust` | **nothing** — the `start` is wrapped in `$if: [{ $ne: [<slot>.status, "loading"] }, …]`, so the id stays, no effect runs, no render | the duplicate start would be ignored (TASKS.md); it never reaches the effect | the one in flight: it carries the current id and **lands** — a double-click runs once and its result reaches state |
1130
+ | `concat` | the id increments | the start queues and runs after its predecessors, in order | **latest wins**: only the completion carrying the current id lands; earlier results and errors are dropped from state; `status` stays `loading` until the newest lands |
1131
+ | `parallel` | the id increments | the start runs concurrently | **latest wins**, as `concat` — the newest id lands whenever it arrives, the others are provable no-ops |
1132
+
1133
+ A consumer that needs every result of a `parallel` fan-out needs a
1134
+ slot per key (one slot per operation is the rule here; keyed slots
1135
+ are a roadmap candidate).
1136
+ - **`reset` releases a slot.** It writes `status: "idle"`, `kind: null`,
1137
+ `error: null` and leaves `id`, `value` and `meta` alone — the id stays
1138
+ monotonic so a late completion of a cancelled attempt is still
1139
+ rejected, and the last good value survives a reset as it survives an
1140
+ error. It is the one way out of a slot a host `cancel(slot)` left
1141
+ `loading` on an `exhaust` operation (a cancelled outcome dispatches
1142
+ nothing, and the guarded `start` is a no-op while `loading`), and the
1143
+ ordinary "dismiss the error" action, mode-independent.
1144
+ - **A failed reload keeps the last good `value`**: the error branch
1145
+ writes `status`, `kind`, `error`, `meta` and touches `value` not at all.
1146
+ - **`kind` says which class of failure the slot holds** — the outcome's
1147
+ `kind` (`"failure"` a declared or taxonomy error, `"network"`,
1148
+ `"contract"`; `"cancelled"` never lands) while `status` is `"error"`,
1149
+ `null` otherwise (`start`, the ok branch and `reset` write `null`) —
1150
+ so a view shows "you are offline" beside "the server refused this"
1151
+ without parsing `error.code`.
1152
+ - `schema` is the slice's JSON Schema: per operation `id` (integer ≥ 0),
1153
+ `status` (`idle | loading | done | error`), `kind` (`null | failure |
1154
+ network | contract` — the enum is pinned, the cross-member invariant
1155
+ "`kind` is `null` exactly when `status` is not `error`" is not: a JSON
1156
+ Schema `if/then` would cost every transition and the generated actions
1157
+ are the only writer), `value` (the operation's output schema **or
1158
+ null**), `error` (the §10.1 error object or null), `meta` (the §10.1
1159
+ meta or null); when the contract has `$defs` the schema carries them
1160
+ under its own `$defs` and declares an `$id`
1161
+ (`urn:jaren:contract-app:<contract id>`) so the output schemas'
1162
+ `#/$defs/…` references resolve wherever the host mounts the slice
1163
+ schema inside its `validateState` schema.
1164
+
1165
+ ### §11.2 The effect
1166
+
1167
+ `createContractEffect(client, { createTaskEffect, projectError?, catalog? })`
1168
+ returns **one** effect handler — register it as `effects: { contract: … }`
1169
+ — carrying `cancel(slot)`, `cancelAll()` and `dispose()` (which
1170
+ `app.destroy()` calls). It owns one `createTaskEffect` per distinct
1171
+ `policy.task` mode the client's contract uses, built lazily on the
1172
+ first descriptor of that mode, each with `run = (props, signal) =>
1173
+ client.invoke(props.op, props.input, { signal, attempt: props.id })`;
1174
+ a descriptor is routed by `props.op` to its operation's mode (a `switch`
1175
+ read and an `exhaust` command live in one effect; the mode never
1176
+ appears in the app document). An unknown `op` is `JC1005` — a
1177
+ `TypeError` the loop reports as `JA2007`. `props` reach the inner
1178
+ effect unchanged (`id`, `done`, `fail`, `slot` are TASKS.md's).
1179
+
1180
+ Settlement, exactly: `run` resolves the outcome → the task effect
1181
+ dispatches `done` with `{ id, result: outcome }`; a `kind: "cancelled"`
1182
+ outcome makes `run` throw an `AbortError` → **nothing is dispatched**
1183
+ (a superseded task is dead by design) — a slot left `loading` by a host
1184
+ `cancel(slot)` is released by `<namespace><op>/reset` (or by the next
1185
+ `start` on a non-`exhaust` operation); `dispose()` is terminal, nothing
1186
+ to release; a value that is not an outcome
1187
+ (a foreign client) and a **thrown** host value go through `projectError`
1188
+ — the host's own projector first, then the `JC2058` outcome — so the
1189
+ error member of `{ id, error }` is always an outcome, never a string,
1190
+ and the generated `done` action reads it through the same `$coalesce`.
1191
+ A `JC2058` message carries nothing of the thrown value.
1192
+
1193
+ ### §11.3 Composition
1194
+
1195
+ ```js
1196
+ import { createApp, createTaskEffect } from '@jarenjs/app';
1197
+ import { JarenValidator } from '@jarenjs/validate';
1198
+ import { openHttpClient } from '@jarenjs/contract/client';
1199
+ import { contractAppBinding, createContractEffect } from '@jarenjs/contract/app';
1200
+
1201
+ const client = openHttpClient(contract, { baseUrl });
1202
+ const { slice, actions, schema } = contractAppBinding(contract, { ops: ['catalog.load', 'product.save'] });
1203
+ const validate = new JarenValidator().compile({ type: 'object', required: ['contract'], properties: { contract: schema } });
1204
+
1205
+ const app = createApp({ state: { contract: slice, draft: null }, view, actions: { ...actions, ...own } }, {
1206
+ effects: { contract: createContractEffect(client, { createTaskEffect }) },
1207
+ validateState: (state) => validate(state),
1208
+ });
1209
+ app.dispatch('contract/catalog.load/start', { since: '2026-01-01T00:00:00Z' });
1210
+ // … later: app.getState().contract['catalog.load'] → { id: 1, status: 'done', kind: null, value: {…}, error: null, meta: {…} }
1211
+ ```
1212
+
1213
+ The runnable version of this composition, driven against a real
1214
+ `serveHttp` dispatcher, is [APP-INTEGRATION.md](APP-INTEGRATION.md),
1215
+ executed verbatim by the test suite.
1216
+
1217
+ ### §11.4 Subscribe operations: the generated subscription
1218
+
1219
+ A subscribe operation becomes a **subscription**, not a task slot. Its
1220
+ slice member is `{ id, status: "idle" | "live" | "error", kind, input,
1221
+ value, error, meta, seq }` — `input` is what `start` was dispatched
1222
+ with, kept in state so the generated subscription entry can resolve it;
1223
+ `value` is the maintained snapshot; `seq` the last applied emission's.
1224
+ The generated actions:
1225
+
1226
+ - `<ns><op>/start` — guarded on `status !== "live"` (the state-first
1227
+ rule of an exhaust command's start: no second subscription while one
1228
+ is live): increments `id`, sets `status: "live"`, clears
1229
+ `kind`/`error`, stores `$payload` as `input`. It emits **nothing** —
1230
+ the subscription below starts because its `when` sees the liveness.
1231
+ - `<ns><op>/stop` — `status: "idle"`; the app's own reconciliation runs
1232
+ the subscription's cleanup (the client's `stop()`, exactly once).
1233
+ - `<ns><op>/snapshot` — guarded on `$payload.id` against the slot id;
1234
+ sets `value` and `seq`.
1235
+ - `<ns><op>/patch` — guarded on the id **and** `$payload.seq` strictly
1236
+ greater than the slot's; replaces `value` with `$payload.value` and
1237
+ advances `seq`. The payload's `value` is the WHOLE patched document:
1238
+ an action's `patch` member is a literal op list whose members are
1239
+ query expressions — it cannot splice a runtime array of RFC 6902 ops
1240
+ — so the handler applies the emission with `@jarenjs/json/patch`
1241
+ (copy-on-write; structural sharing preserved) and the action replaces.
1242
+ - `<ns><op>/error` — guarded on the id; `status: "error"`, the
1243
+ outcome's `kind`/`error`/`meta`.
1244
+ - `<ns><op>/reset` — as for tasks: `status "idle"`, `kind`/`error`
1245
+ cleared, everything else kept.
1246
+
1247
+ The binding additionally returns `subs` — one entry per subscribe
1248
+ operation — and names its handler in `subscription`:
1249
+
1250
+ ```jsonc
1251
+ { "run": "contract-stream",
1252
+ "when": { "$eq": ["$.contract['data.live'].status", "live"] },
1253
+ "withQuery": { "op": "data.live", "id": "$.contract['data.live'].id",
1254
+ "input": "$.contract['data.live'].input",
1255
+ "snapshot": "contract/data.live/snapshot",
1256
+ "patch": "contract/data.live/patch",
1257
+ "error": "contract/data.live/error" } }
1258
+ ```
1259
+
1260
+ `withQuery` (not `with`) because the resolved props carry the slot's
1261
+ `id` and `input` from state — APP-FORMAT §5.3's restart rule then keys
1262
+ the instance by the resolved props by value. The handler is
1263
+ `createContractSubscription(client)` (`@jarenjs/contract/app`), a plain
1264
+ `(props, dispatch) => cleanup` — no task-effect factory is needed. It
1265
+ calls `client.subscribe`, applies each emission host-side, and
1266
+ dispatches the named actions with `{ id, … }` payloads; an `onError`
1267
+ outcome lands in the error action as-is, and a server `end` lands there
1268
+ as a `network`-kind outcome with the channel-closed code (`JC2074`) —
1269
+ the stream is gone and the slot says so; reconnection is a fresh
1270
+ `start`, never automatic. The slice schema pins the subscribe slot like
1271
+ the task slots (`status` to its three states, `input` to the
1272
+ operation's input schema or `null`, `seq` to a non-negative integer).
1273
+
1274
+ ## §12 Projections
1275
+
1276
+ Everything a consumer wants **beside** the runtime is a projection of the
1277
+ same compiled contract (`@jarenjs/contract/project` — the one subpath of
1278
+ this package that imports `@jarenjs/emit`, so a bundle that never
1279
+ projects never carries it): the public projection every other artifact
1280
+ is built on (§12.1), OpenAPI 3.1 (§12.2), TypeScript declarations
1281
+ (§12.3), Markdown reference documentation (§12.4) and AI tool
1282
+ definitions (§12.5), with the `jaren-contract` CLI (§12.6) writing and
1283
+ `--check`ing them in CI. A projection takes a **compiled** contract —
1284
+ never a raw document — so it renders resolved bindings and materialized
1285
+ policies, and every projection is **deterministic**: the same contract
1286
+ renders byte-identical artifacts.
1287
+
1288
+ ### §12.1 The public projection — normative, the revision hashes it
1289
+
1290
+ `publicProjection(contract, { ops? })` is a JSON document that is itself
1291
+ a **valid `$contract` 0.1** (it compiles, and projecting the compile
1292
+ yields the same bytes): the browser-safe subset a client needs and no
1293
+ more, and the exact bytes the contract revision will hash. Because the
1294
+ hash is a compatibility claim, the member order is **normative**:
1295
+
1296
+ - root: `$contract`, `id`, `version`, `compat`, `$defs`, `operations` —
1297
+ each present only when the contract carries it (`$defs` only when a
1298
+ retained schema reaches one);
1299
+ - operation (document order): `kind`, `input`, `output`, `errors`,
1300
+ `policy`, `http`, `doc` — `input`, `errors` and `doc` only when
1301
+ declared;
1302
+ - error declaration: `status` (always, resolved), `schema` (when
1303
+ declared);
1304
+ - policy: `task`, `idempotency`, `revision` (when declared), `cache`,
1305
+ `retry` (when declared), `audience` — every default materialized;
1306
+ **`limits` and `errors.details` are server-side knobs and never
1307
+ appear**;
1308
+ - http: `method`, `path` (canonical `{name}` template), `in` (every
1309
+ member's location), `body` (when a whole-body member is declared),
1310
+ `status`, `media` — the canonical binding is written out like any
1311
+ declared one;
1312
+ - `$defs`: exactly the entries reachable from the retained operations'
1313
+ `input`/`output`/error schemas by same-document `$ref` (transitively;
1314
+ an anchor or a pointer into an entry counts as the entry), in
1315
+ **first-reference order** — walking operations in document order,
1316
+ each operation's `input`, `output`, then its errors in declaration
1317
+ order.
1318
+
1319
+ Retained = the operations whose `policy.audience` is not `"server"`,
1320
+ narrowed further by `ops` when given (`JC1008` for an id the contract
1321
+ does not declare; a listed `server` operation is still not retained).
1322
+ Schema subtrees are the compiled document's own (frozen); the
1323
+ composition is fresh.
1324
+
1325
+ ### §12.2 OpenAPI 3.1
1326
+
1327
+ `toOpenApi(contract, { info?, servers?, lenient? })` →
1328
+ `{ document, dropped }`: a valid OpenAPI **3.1.0** document rendered by
1329
+ a JSLT stylesheet shipped as JSON (`src/project/openapi.jslt.json`,
1330
+ compiled once at module scope), built on the public projection. The
1331
+ test suite validates the rendered document against the vendored
1332
+ official OpenAPI 3.1 meta-schema with `JarenValidator`
1333
+ (`test/contract/fixtures/openapi-3.1.schema.json`, Apache-2.0 — the
1334
+ suite validating its own projection with its own validator is the
1335
+ point). The mapping:
1336
+
1337
+ | contract | OpenAPI |
1338
+ |---|---|
1339
+ | — | `openapi: "3.1.0"`, `jsonSchemaDialect: "https://json-schema.org/draft/2020-12/schema"`, `info` (title/version defaulting to the contract id/version), `servers` when given |
1340
+ | operation | `paths[<canonical path>][<lowercased method>]`, paths sorted by path then method; `operationId` = the id; `summary` = `doc`'s first line (`description` = the whole `doc` when it has more); `tags` = the id's first dotted segment |
1341
+ | `http.in` `path`/`query`/`header` members | `parameters` (name, `in`, `schema`; `required` from the effective input's `required`, a path parameter always required); an idempotent operation gains the `Idempotency-Key` header parameter (required under `"required"`) |
1342
+ | body members | `requestBody`: the object of the body-located members (their `required` intersection, the input's `additionalProperties`); the whole input schema when every member is body-located; the member's own schema under `http.body`; `content[<http.media>]` |
1343
+ | `output`, `http.status` | `responses[<status>]` with the output schema; no content on `204`; opaque → `content[<media>]: { type: "string", format: "binary" }` |
1344
+ | declared `errors` | one response per status: the D7 wire-error schema (`code` **enum-pinned** to the codes of that status, `details` the declared schema when present) |
1345
+ | the binding's own statuses | shared `components.responses` (`BadRequest` 400, `NotFound` 404, `IdempotencyConflict` 409 on idempotent operations, `PayloadTooLarge` 413, `UnsupportedMediaType` 415 on body-carrying operations, `InternalError` 500), `$ref`'d per operation unless a declared error already answers that status |
1346
+ | `$defs` | `components.schemas`, every `#/$defs/X…` rewritten `#/components/schemas/X…` |
1347
+ | policy | the `x-jaren-policy` extension: `{ task, idempotency, cache, revision?, retry? }` verbatim (an `x-` extension is OpenAPI's sanctioned place) |
1348
+
1349
+ An opaque operation never meets a `requestBody` here: the compiler
1350
+ refuses a body-located member on one (`JC0017`, §4.5), so the mapping
1351
+ needs no rule for it.
1352
+
1353
+ **The keyword policy — map-or-reject, nothing silent.** Every schema the
1354
+ projection carries is walked once:
1355
+
1356
+ | keyword | treatment |
1357
+ |---|---|
1358
+ | `nullable: true` | mapped: `"null"` added to `type` (dropped + reported when there is no `type` to widen) |
1359
+ | `nullable: false` | dropped + reported (asserts nothing in 3.1) |
1360
+ | boolean `required` | **rejected** `JC0060` at the keyword; under `lenient` dropped + reported (the array form is JSON Schema's own and passes) |
1361
+ | `$query`, `$data`, `errorMessage`, `x-form` and every `x-*` | dropped + reported (Jaren-side; the wire never enforced them for a peer) |
1362
+ | `components` inside a schema | **rejected** `JC0060`, `lenient` or not (a document member has no schema reading) |
1363
+ | a same-document `$ref` that lands outside the projection's `$defs` (an anchor, `#`, a pointer into an operation) | **rejected** `JC0060`; under `lenient` dropped + reported (the schema is honestly wider) |
1364
+ | `const`, `enum`, `default`, `examples`, `example` | data — copied verbatim, never walked |
1365
+ | everything else | copied, subschemas walked |
1366
+
1367
+ `dropped` is the audit trail: `[{ docPath, keyword, reason }]` with the
1368
+ `docPath` of the keyword **in the contract document**, in document
1369
+ order. The projection's own code, in the `JC0050–JC0069` range beside
1370
+ §6's table:
1371
+
1372
+ | code | condition |
1373
+ |---|---|
1374
+ | JC0060 | the OpenAPI projection met a schema keyword it cannot map honestly: a boolean `required` or a same-document `$ref` that lands outside `$defs` (both dropped and reported under `lenient`), or a `components` member inside a schema (`ContractCompileError`, `docPath` into the contract document) |
1375
+
1376
+ ### §12.3 TypeScript
1377
+
1378
+ `toTypeScript(contract, { banner? })` renders one `.d.ts` on
1379
+ `@jarenjs/emit`'s type model (`compileEmitModel` + `renderTypeScript`,
1380
+ the suite's one declaration renderer): per public operation
1381
+ `<PascalOp>Input` / `<PascalOp>Output` / `<PascalOp><PascalCode>Details`
1382
+ (collisions uniqued with a numeric suffix; a `$defs` entry keeps its own
1383
+ name), the reachable `$defs` once, then — fixed text in a JTLT
1384
+ stylesheet (`src/project/typescript.jtlt.json`) — `Operations` (the
1385
+ typed operation map: kind, input, output, the declared error codes as a
1386
+ literal union), `UrlOperations` (opaque operations included, for
1387
+ `Client.url`), and `Meta`, `WireError`, `Outcome<T>`, `InvokeContext`,
1388
+ `Client`, `Failure`, `HandlerContext`, `Handlers`. `Meta` and
1389
+ `WireError` spell **exactly** the fixed D6 shapes (§10.1) —
1390
+ `OUTCOME_META_MEMBERS`/`OUTCOME_ERROR_MEMBERS` are the runtime twins and
1391
+ a test holds the text to them; `details` is `unknown` and `status`
1392
+ `number | null`, never optional members. An input-less operation's
1393
+ `input` is `null`; an opaque operation appears only in `UrlOperations`.
1394
+
1395
+ ### §12.4 Markdown
1396
+
1397
+ `toMarkdown(contract, { title? })` renders one reference document: the
1398
+ title and version, an operations table (id, method, path, kind, task,
1399
+ idempotency), one section per public operation (its `doc`, the policy
1400
+ line, parameters, body, responses, declared errors) and a Types part
1401
+ rendered by emit's Markdown target over the **same** type model as
1402
+ §12.3 — so every type name a section links to is a heading that exists.
1403
+
1404
+ ### §12.5 AI tools
1405
+
1406
+ `contractTools(contract, client, { ops?, name? })` → an array of
1407
+ `{ name, description, inputSchema, execute }` — the `ToolDef` shape
1408
+ `@jarenjs/ai`'s `createToolbox().add` takes and WebMCP's `registerTool`
1409
+ reads, **without importing that package** (the generated-document rule:
1410
+ the shape is a plain object; the test suite registers them into a real
1411
+ toolbox). Per public, invokable operation (opaque and subscribe
1412
+ operations are skipped by the default set and refused when `ops` names
1413
+ one — a tool carries one invoke, not bytes and not a stream), in
1414
+ document order:
1415
+
1416
+ - `name`: the id with `.` → `_` (injective — an id carries no `_`), or
1417
+ `options.name(id)`; every name MUST match OpenAI's
1418
+ `^[a-zA-Z0-9_-]{1,64}$` and two operations mapping to one name is
1419
+ `JC1008`;
1420
+ - `description`: the `doc`, or a derived `<kind> operation <id>
1421
+ (<METHOD> <path>)`;
1422
+ - `inputSchema`: the operation's input schema made **self-contained** —
1423
+ the `$defs` it reaches inlined under the schema's own `$defs`
1424
+ (`bundleSameDocument`, the suite's one same-document bundler) — or a
1425
+ closed empty object schema for an input-less operation;
1426
+ - `execute`: `(args) => client.invoke(op, args)` (`null` for an
1427
+ input-less operation), resolving the outcome JSON — a model sees the
1428
+ same `{ ok, value | error, meta }` an app does, and a failed outcome
1429
+ is a resolved value, never a rejection.
1430
+
1431
+ Opaque operations are skipped by default and refused (`JC1008`) when
1432
+ `ops` names one — a tool carries JSON; an opaque operation is reached
1433
+ through `client.url`.
1434
+
1435
+ ### §12.6 The CLI
1436
+
1437
+ `jaren-contract <describe|public|openapi|types|docs> --contract <file>
1438
+ [--out <dir|file>] [--check] [--info-title T] [--info-version V]
1439
+ [--lenient]` (the package `bin`). `describe` prints `describe()`;
1440
+ `public`/`openapi` print or write JSON (two-space indent, trailing
1441
+ newline); `types`/`docs` the text artifacts; `--out` names a file, or a
1442
+ directory that gets `<contract id><extension>`
1443
+ (`.describe.json`/`.public.json`/`.openapi.json`/`.d.ts`/`.md`).
1444
+ `--check` writes nothing and exits **1** when the file differs from
1445
+ what the document projects today — the CI drift gate; exit **0** when
1446
+ current or written; exit **2** on a usage error, an unreadable document,
1447
+ or a compile refusal, printed as `code docPath reason`. `openapi`
1448
+ reports every dropped keyword on stderr; without `--lenient` a
1449
+ rejectable keyword is exit 2 with `JC0060` and its `docPath`.
1450
+
1451
+ ## §13 The breaking-change diff
1452
+
1453
+ `diffContracts(a, b)` (`@jarenjs/contract/diff`) classifies every change
1454
+ from contract `a` (what consumers hold today) to contract `b` (what they
1455
+ would meet) into `{ breaking, additive, neutral, unknown }` by the rule
1456
+ table below. It takes compiled contracts or raw documents (a document is
1457
+ compiled first, so a malformed one refuses with its own `JC00xx` before
1458
+ any comparison). Each entry is a
1459
+ `Change = { kind, op, docPath, from?, to?, rule, note? }` where `kind` is
1460
+ a stable slug, `rule` names the row, and `docPath` points into the
1461
+ document that carries the change — into `a` for a removal, into `b`
1462
+ otherwise — composed over the *resolved* structure, so a constraint
1463
+ reached through a bare `{ "$ref": "#/$defs/X" }` hop reports the path a
1464
+ validator error would name.
1465
+
1466
+ | # | Change | Class |
1467
+ |---|---|---|
1468
+ | R1 | operation removed | breaking |
1469
+ | R2 | operation added | additive |
1470
+ | R3 | `kind`, `http.method`, `http.path` (shape — variable *names* are not shape), `http.status`, `http.media` (opaqueness included), a member's `http.in` location, or `http.body` changed | breaking |
1471
+ | R4 | input: a member added to `required` (or a new required member) | breaking |
1472
+ | R5 | input: a member removed while the new input schema is `additionalProperties: false` (or the input removed entirely) | breaking; otherwise `neutral` with a note (the member is now ignored, not validated) |
1473
+ | R6 | input: a member's schema narrowed (type set shrinks, `enum`/`const` shrinks, `maximum` lowers, `minimum` rises, `maxLength` lowers, `minLength` rises, `pattern` added) | breaking |
1474
+ | R7 | input: a member's schema widened (the inverse of R6), an optional member added, or a member dropped from `required` | additive |
1475
+ | R8 | output: a member removed, made optional (dropped from `required`), or narrowed | breaking |
1476
+ | R9 | output: a new member (optional or required), a member added to `required`, or widened | additive |
1477
+ | R10 | error code removed, or its `status` changed | breaking |
1478
+ | R11 | error code added | additive |
1479
+ | R12 | `policy.idempotency` `none/optional → required` (a client must now send a key) | breaking; `required → optional/none` and `none ↔ optional` additive |
1480
+ | R13 | `policy.task`, `policy.retry`, `policy.cache`, `policy.revision` or `doc` changed | neutral |
1481
+ | R14 | `policy.audience` `public → server` | breaking; the reverse additive |
1482
+ | R15 | a schema construct the checker does not model differs between the two (`anyOf`/`oneOf`/`allOf`/`if`/`not`/`$dynamicRef`, a `format`, a *changed* `pattern`, an external or sibling-carrying `$ref`, a changed error `details` schema, …) | **unknown** — reported, never silently classed |
1483
+
1484
+ Riders the rows carry:
1485
+
1486
+ - **R3 covers the whole wire shape.** The order's five members plus a
1487
+ member's `in` location and the whole-body `body` member — a member
1488
+ that moves from `query` to `header` rewrites the request exactly like
1489
+ a moved path, so it classifies with the binding row. Renaming a path
1490
+ *variable* alone is not an R3 change (the shape compares with
1491
+ variables blanked); the renamed input member surfaces through
1492
+ R4/R5/R7 instead.
1493
+ - **R12 and `retry`.** The `required → optional` additive row holds for
1494
+ operations without `policy.retry`: an `optional`-idempotency command
1495
+ with `retry` cannot compile (`JC0014`, §3.1), so `diffContracts`
1496
+ never meets that pair.
1497
+ - **The schema walk models exactly R6 plus structure.** Object members
1498
+ (`properties`, `required`, `additionalProperties`) and `items`
1499
+ recurse; the R6 keyword set compares as constraints; pure annotations
1500
+ (`title`, `description`, `examples`, `$comment`, `deprecated`) never
1501
+ move a wire byte and are ignored; **everything else that differs is
1502
+ R15**. Nested member changes classify by narrowing/widening (adding a
1503
+ constrained optional member to an open nested object narrows it;
1504
+ removing one from an open object widens it); the *top-level* input
1505
+ members classify by R4/R5/R7, and output members by R8/R9 at every
1506
+ depth.
1507
+ - **Audience is the compatibility surface.** Operations whose
1508
+ `policy.audience` is `server` on **both** sides are skipped entirely;
1509
+ an audience flip is R14 and subsumes the operation's other changes.
1510
+ `policy.limits` and `policy.errors.details` are server-side knobs
1511
+ outside the public projection and are never reported.
1512
+
1513
+ `isCompatible(clientContract, serverContract)` (exported from
1514
+ `@jarenjs/contract/diff`, one implementation shared with the client's
1515
+ `negotiate()`) is the complementary *declared* answer: `true` when the
1516
+ two ends state the same `version`, or when either end's `compat` names
1517
+ the other's `version` — `compatReason` returns which rule held
1518
+ (`'same-version' | 'server-accepts' | 'client-accepts' | null`).
1519
+ `diffContracts` computes what changed; `isCompatible` reads what the
1520
+ authors claim. Never derive one from the other: a `version` is bumped
1521
+ by a person, a revision (§14) moves by itself.
1522
+
1523
+ The CLI's compatibility gate: `jaren-contract diff --from a.json --to
1524
+ b.json [--fail-on breaking[,unknown,…]]` prints the classified diff as
1525
+ JSON and exits **1** when any `--fail-on` class is non-empty (exit 0
1526
+ otherwise; exit 2 on a usage error, an unreadable file or a compile
1527
+ refusal).
1528
+
1529
+ ## §14 The revision
1530
+
1531
+ `contract.revision() → Promise<string>` is the lowercase hex SHA-256
1532
+ over the RFC 8785 canonical bytes of the **public projection** (§12.1)
1533
+ — computed with the platform's `crypto.subtle`, memoized per compiled
1534
+ contract, so `compileContract` stays synchronous and the digest is paid
1535
+ at most once per process (measured here: ~4–5 ms for a 123-operation,
1536
+ 160 KB document). Because it hashes the projection and the projection
1537
+ materializes defaults in a normative member order:
1538
+
1539
+ - two compiles of equal documents agree, across processes and platforms;
1540
+ - a change to any client-observable member — a public operation's
1541
+ schemas, binding, declared errors, client-facing policy — moves it;
1542
+ - a change a client cannot observe — a `server`-audience operation, a
1543
+ `policy.limits` value, an `errors.details` level, a `doc`-only edit
1544
+ *does* move it (`doc` is projected) but a declared binding rewritten
1545
+ to its own defaults does **not** (the projection materializes
1546
+ defaults, so two documents that behave alike hash alike).
1547
+
1548
+ `describe()` carries `revision: <hex | null>` — `null` until someone
1549
+ awaited `revision()`; `describe()` stays synchronous and never computes
1550
+ it. The HTTP server computes it **lazily on the first well-known
1551
+ request** (§7.6): `GET /.well-known/jaren-contract` awaits the digest
1552
+ once and answers the description with `revision` filled; the client's
1553
+ `negotiate()` reads it and carries it in `meta.revision` of every
1554
+ subsequent outcome — correlation data, never the compatibility decision
1555
+ (that is `version`/`compat`, §13). The revision is not a `version`:
1556
+ never bump `version` merely because the revision moved, and never
1557
+ compare revisions to decide compatibility.
1558
+
1559
+ A public projection that cannot be canonicalized has no revision:
1560
+
1561
+ | code | condition |
1562
+ |---|---|
1563
+ | JC0061 | the public projection is not canonicalizable, so no revision exists — a string member carrying an unpaired surrogate, say (`ContractCompileError` rejected from `revision()`; its `docPath` points at the offending value *inside the projection*) |
1564
+
1565
+ (The compiler's document snapshot already refuses non-finite numbers,
1566
+ functions and cycles at `JC0001`, so an unpaired surrogate in a string
1567
+ is the reachable case.) The well-known responder reports the refusal to
1568
+ `onError` once and honestly answers `revision: null`. The revision and
1569
+ the idempotency request hash (§8) are the same computation —
1570
+ `canonicalSha256` in `@jarenjs/json` — never a 32-bit content
1571
+ fingerprint, which collides.
1572
+
1573
+ ## §15 The local binding
1574
+
1575
+ `openLocalClient(contract, handlers, options) → Client` — the same
1576
+ operation pipeline as every server binding, with no wire: the test
1577
+ seam, SSR, a CLI calling its own operations. The client and the server
1578
+ are one object; `serveLocal` is the same factory under the serve name,
1579
+ for symmetry with the driver pairs of the other bindings. The `Client`
1580
+ is the binding-agnostic shape of §10 minus what cannot exist here:
1581
+ `invoke`, `capabilities`, `contract`, `describe`, `close` — no `url`,
1582
+ no `negotiate`, no `pending`.
1583
+
1584
+ `invoke(op, input, ctx)` with `ctx = { signal?, attempt? }`:
1585
+
1586
+ 1. an unknown operation, or an opaque one, throws `JC1005` — non-JSON
1587
+ media does not exist in-process (`capabilities.media: false`); so
1588
+ does a subscribe operation — this binding carries no streams
1589
+ (`capabilities.stream: false`), and needs no handler for one;
1590
+ 2. the input is validated with the operation's compiled validator; a
1591
+ refusal is the pre-send `JC2050` outcome (kind `contract`, details
1592
+ by `policy.errors.details`) and nothing ran — the same refusal every
1593
+ client binding shares;
1594
+ 3. the handler runs through the neutral pipeline with the frozen
1595
+ context `{ op, trace, signal, params: null, headers: {}, fail,
1596
+ idempotency: null }` — `trace` from the `trace` option (default
1597
+ `crypto.randomUUID`), `signal` the caller's composed with the
1598
+ client's closer. There is no `ctx.etag`, `ctx.status` or `ctx.body`:
1599
+ statuses, entity tags and bytes do not exist here, and a handler
1600
+ that reaches for them fails honestly (`JC2070`) instead of
1601
+ pretending;
1602
+ 4. the outcome (§10.1 shapes, assembled by the same assembler):
1603
+
1604
+ | settlement | outcome |
1605
+ |---|---|
1606
+ | the output, valid | `{ ok: true, value, meta }` — `meta.trace` the generated trace |
1607
+ | a declared failure (`ctx.fail`, or a thrown `ContractRuntimeError` whose code the operation declares) | kind `failure` with `{ code, message, status: null, details, retryable }` — `status` is `null` and PRESENT (D6: a binding that cannot carry a member carries `null`, never omits it); the message is `contract/error/<code>` from the host catalog or the generic `contract/handler-error` |
1608
+ | any handler fault — a throw, a rejection, an undeclared code, an output or error-details schema violation | kind `contract` `JC2070`, message `contract/local-handler-failed`; the distinguishing cause goes to `onError(error, { op, trace })`, never into the outcome |
1609
+ | `ctx.signal` aborted before or while running, or the client closed | kind `cancelled` `JC2052`; a handler that settles later settles into nothing |
1610
+
1611
+ Options: `trace`, `validateOutput` (`'never'` is a declared downgrade,
1612
+ reported in `capabilities.validatedOutput`; the output is validated
1613
+ ONCE, in the pipeline — the assembler does not re-validate what never
1614
+ crossed a wire), `catalog`, `onError`. Capabilities:
1615
+
1616
+ ```jsonc
1617
+ { "name": "local", "status": false, "headers": false, "media": false,
1618
+ "etag": false, "idempotency": false, "validatedOutput": true,
1619
+ "stream": false, "cancel": "signal" }
1620
+ ```
1621
+
1622
+ Two readings that keep one handler table serving http AND locally:
1623
+
1624
+ - **a declared `policy.idempotency` is allowed and inert.** The same
1625
+ contract must serve over http (where the ledger enforces it) and
1626
+ locally (where "the wire retried" cannot happen — a re-run is the
1627
+ caller's own hand). The binding does not refuse it (`JC1003` is for a
1628
+ feature a binding was asked to carry and cannot, like http without a
1629
+ ledger); it carries `capabilities.idempotency: false` so the
1630
+ downgrade is declared, never silent.
1631
+ - **an opaque or subscribe operation needs no handler and may still
1632
+ have one.** `serveLocal` exempts both from the `JC1002` handler
1633
+ requirement (neither can be invoked here), and accepts a handler
1634
+ table that carries them — so an http server's table is reusable
1635
+ verbatim; those handlers are simply never called.
1636
+
1637
+ The local codes (the table shared with §16; `PORT_LOCAL_ERRORS` in
1638
+ `@jarenjs/contract/local` and `/port` is this table as data):
1639
+
1640
+ | code | kind | msgid | retryable | when |
1641
+ |---|---|---|---|---|
1642
+ | `JC2070` | contract | `contract/local-handler-failed` | no | the serving host's handler failed in any class — a throw, an undeclared code, a broken output or error-details schema; `onError` sees the cause |
1643
+
1644
+ ## §16 The port binding
1645
+
1646
+ `servePort(contract, handlers, { channel, trace?, validateOutput?,
1647
+ catalog?, onError? })` and `openPortClient(contract, { channel,
1648
+ timeoutMs = 15000, catalog? })` — request/response over anything with
1649
+ `postMessage` and a message-listener surface: a `MessagePort` (started
1650
+ automatically), a `Worker`, a `BroadcastChannel`, a worker's own
1651
+ `self`, or a plain object of that shape. The server prepares the same
1652
+ pipeline routes as §7 and refuses the same host mistakes (`JC1001`,
1653
+ `JC1002` — opaque operations exempt exactly as in §15; a subscribe
1654
+ operation NEEDS its handler, it streams here — §18.2); the client is
1655
+ the §10 shape minus `url`/`negotiate`/`pending`, plus `subscribe`
1656
+ (§19). A request frame naming a subscribe operation is answered
1657
+ `JC2071` like an opaque one — a stream is never a request/response.
1658
+ Exactly ONE server should serve a shared channel — two would both
1659
+ answer every request.
1660
+
1661
+ ### §16.1 Frames
1662
+
1663
+ The grammar is `schemas/jaren-contract-port.schema.json` (draft-07 twin
1664
+ beside it), and every frame the binding emits validates against it.
1665
+ Frames are JSON-safe plain objects marked `jaren: "contract/0.1"`:
1666
+
1667
+ ```jsonc
1668
+ { "jaren": "contract/0.1", "id": "<clientId>:<seq>", "op": "data.rows",
1669
+ "input": { "collection": "notes" } } // request; attempt?/key? reserved
1670
+ { "jaren": "contract/0.1", "id": "<clientId>:<seq>", "ok": true,
1671
+ "value": [ /* … */ ], "trace": "3f2c…" } // success
1672
+ { "jaren": "contract/0.1", "id": "<clientId>:<seq>", "ok": false,
1673
+ "error": { "code": "conflict", "message": "…", "details": { }, "retryable": false },
1674
+ "trace": "3f2c…" } // error
1675
+ { "jaren": "contract/0.1", "cancel": "<clientId>:<seq>" } // cancel; never answered
1676
+ ```
1677
+
1678
+ **Id scoping is the correctness rule.** `id` is `"<clientId>:<seq>"` —
1679
+ `clientId` a UUID per client instance, `seq` a per-client counter — so
1680
+ two clients on one shared channel can never collide, and a client
1681
+ ignores every frame whose id does not start with its own `clientId +
1682
+ ":"` (one cheap prefix test before any map lookup). A late response
1683
+ for a cancelled or timed-out id finds no pending entry and is dropped
1684
+ silently. The request members `attempt` and `key` are reserved by the
1685
+ grammar and ignored by servers of this version: the attempt id stays
1686
+ caller-side in `meta` (D6 — the identities live in state, never in the
1687
+ transport) and idempotency is not carried on this binding.
1688
+
1689
+ **What the wire cannot carry, it does not pretend to.** No statuses,
1690
+ no headers, no entity tags, no non-JSON media (an opaque operation is
1691
+ `JC1005` at `invoke` and answered `JC2071` if some other client asks);
1692
+ a declared failure's `status` is `null` in the outcome, member present
1693
+ (D6). `input` is the whole input object (`null` for an input-less
1694
+ operation); an `undefined` handler value crosses as `null` (frames are
1695
+ JSON). Cancellation is `cancel: "message"`: an abort posts the cancel
1696
+ frame — an optimization that stops wasted work; the id scoping is the
1697
+ guarantee.
1698
+
1699
+ ### §16.2 The server, per request frame
1700
+
1701
+ A frame without the marker is IGNORED, never answered — other traffic
1702
+ may share the channel (an owner-discovery ping, a live push). So is a
1703
+ frame carrying `ok` (another server's response on a shared channel)
1704
+ and a marked frame without a usable string `id` (nothing to address).
1705
+ Then: an unknown or opaque `op` is answered `JC2071` **without echoing
1706
+ what was asked** (a request value never enters a message, §7.3's
1707
+ rule); an input failing its validator is `JC2006` with details by
1708
+ policy; otherwise the request runs through the pipeline under one
1709
+ `AbortController` per id — a `cancel` frame aborts it — and the
1710
+ settlement is posted back with the server `trace`: the §7.3
1711
+ classification with `JC2070` in place of §7's 500s (a declared failure
1712
+ keeps its code, message, details and retryability; every host fault is
1713
+ `JC2070` with the cause to `onError`). A response for an id whose
1714
+ controller was aborted is not posted. `close()` detaches the listener
1715
+ and aborts every in-flight request.
1716
+
1717
+ ### §16.3 The client, per outcome
1718
+
1719
+ Pre-send exactly as §15 steps 1–2 (`JC1005` thrown, `JC2050`
1720
+ pre-send). Then one request frame; the outcome:
1721
+
1722
+ | the channel answered | outcome |
1723
+ |---|---|
1724
+ | `ok: true` with `value` | validated against the output schema → `{ ok: true, value, meta }` (`JC2053` kind `contract` on a mismatch, as §10); `meta.trace` from the frame |
1725
+ | `ok: false` with a declared or §7 taxonomy `code` | kind `failure`, `status: null`, the frame's message/details/retryable (a peer's `JC2006` means the two ends validated differently — visible, not hidden) |
1726
+ | `ok: false` with `JC2070`/`JC2071` | kind `contract`, the code kept — a served-host fault or a contract the two ends disagree about is never dressed as a declared failure |
1727
+ | `ok: false` with any other code | kind `contract` `JC2055` (§10.3 — an undeclared response) |
1728
+ | a frame addressed to this client that does not match the grammar | kind `contract` `JC2073` |
1729
+ | nothing within `timeoutMs` | kind `network` `JC2072` (retryable); `0` disables the timer |
1730
+ | `postMessage` threw (closed, detached) | kind `network` `JC2074` |
1731
+ | `ctx.signal` aborted, or `close()` | kind `cancelled` `JC2052`; the cancel frame is posted when the channel still accepts one |
1732
+
1733
+ The port codes (with `JC2070` of §15; `PORT_LOCAL_ERRORS` is the table
1734
+ as data):
1735
+
1736
+ | code | kind | msgid | retryable | when |
1737
+ |---|---|---|---|---|
1738
+ | `JC2071` | contract | `contract/unknown-operation` | no | the server answered "no operation of that name is served on this channel" — unknown, or opaque |
1739
+ | `JC2072` | network | `contract/port-timeout` | yes | no answer within `timeoutMs` |
1740
+ | `JC2073` | contract | `contract/malformed-frame` | no | a response frame addressed to this client fails the frame grammar |
1741
+ | `JC2074` | network | `contract/channel-closed` | no | the channel refused the request frame |
1742
+
1743
+ ## §17 Subscribe operations
1744
+
1745
+ A **subscribe** operation declares a live query: its `output` is the
1746
+ **snapshot** schema — the document a subscriber holds — and after the
1747
+ snapshot the server streams LIVE-FORMAT emissions `{ patch, seq }`
1748
+ (RFC 6902 `add`/`remove`/`replace` only) that the consumer applies to
1749
+ it. `input` travels as for a read (path variables → `path`, the rest →
1750
+ `query` by default); `errors` are declared as usual and end the stream
1751
+ as an `error` event (§18). The compiler enforces the shape the wire
1752
+ requires: `policy.task` MUST be `switch` (`JC0018` — a subscription
1753
+ slot is replaced, never queued), the binding MUST be `GET` (`JC0019`;
1754
+ the canonical binding of a subscribe without `http` is `GET /<op-id>`
1755
+ with every member in the query), `policy.idempotency` MUST be `none`
1756
+ (`JC0020`), and `http.media` is **forced** to `text/event-stream` — a
1757
+ declared conflicting media is `JC0012`, and `describe()` shows the
1758
+ forced value. A subscribe operation is never opaque: its events are
1759
+ JSON the contract decodes and validates. `policy.stream` (§3.1) is its
1760
+ knob set — `resume`, `heartbeatMs`, `maxPatchBytes` — and is refused
1761
+ on any other kind.
1762
+
1763
+ ### §17.1 The handler — a duck-typed LIVE subscription
1764
+
1765
+ `handlers[op] = (input, ctx) => Subscription | Promise<Subscription>`
1766
+ where
1767
+
1768
+ ```
1769
+ Subscription = {
1770
+ result | snapshot(), // the current snapshot document; snapshot() preferred when both exist
1771
+ subscribe(cb) → stop, // cb receives LIVE-FORMAT emissions { patch, seq } or { error }
1772
+ close(), // release the registration
1773
+ replay?(seq), // optional: the emissions after seq, or null/undefined when it cannot
1774
+ mode?, // ignored by the binding
1775
+ }
1776
+ ```
1777
+
1778
+ — a `@jarenjs/db` `live()` object satisfies it **as returned** (`result`
1779
+ + `subscribe` + `close`; it has no `replay`), so a handler is one line:
1780
+ `(input) => store.collection('x').live(doc, { externals: input })`. No
1781
+ import of `@jarenjs/db` exists anywhere in the package; the shape is
1782
+ duck-typed. The handler runs through the same settlement boundary as
1783
+ every operation: a `ContractFailure` (or a thrown `ContractRuntimeError`
1784
+ with a declared code) is a declared failure, any other throw or hostile
1785
+ value is a host fault, and a settled value that does not carry
1786
+ `subscribe` + `close` + (`result` or `snapshot()`) is a host fault too.
1787
+ On HTTP these pre-stream failures answer as ordinary §7.3 responses
1788
+ (declared status, or 500); on `port` they answer as `error` push frames
1789
+ (§18). Once the stream is live, the binding reads the snapshot
1790
+ (`snapshot()` when present, else `result`), validates it against
1791
+ `output` (a failure ends the stream with an `error` event `JC2091` —
1792
+ the server broke the contract; the cause goes to `onError`, never the
1793
+ wire), forwards each emission verbatim (the binding never mutates a
1794
+ patch), and calls `stop()` then `close()` **exactly once** — on peer
1795
+ disconnect (`ctx.signal`), on an `unsubscribe`/stream cancel, on server
1796
+ close, and after an `error` emission ends the stream.
1797
+
1798
+ ## §18 The stream wire
1799
+
1800
+ ### §18.1 HTTP: Server-Sent Events
1801
+
1802
+ Request: `GET <path>` with `accept: text/event-stream`. **A client
1803
+ without that accept header gets the snapshot as plain JSON** — the same
1804
+ operation serves a one-shot read through the ordinary §7 pipeline
1805
+ (handler → subscription → snapshot validated → `stop()`/`close()` →
1806
+ JSON response), which is also what `invoke` on a subscribe operation
1807
+ does; `capabilities.stream: true` says the streaming half exists.
1808
+
1809
+ Response: `200`, `content-type: text/event-stream`, `cache-control:
1810
+ no-store`, `x-jaren-trace`. Events, in order:
1811
+
1812
+ - `snapshot` — `id: <seq>` (the stream's starting seq; `0` for a source
1813
+ that names none), data `{ "value": <snapshot>, "resumed": false }`.
1814
+ The envelope exists because a resume verdict cannot ride *inside* the
1815
+ snapshot value without breaking a closed output schema; `resumed:
1816
+ false` states this snapshot is a fresh document (a refused resume —
1817
+ `JC2095` — looks exactly like this, which is how the client learns).
1818
+ - `patch` — `id: <seq>`, data `{ "patch": [...], "seq": n }`: the
1819
+ LIVE-FORMAT emission verbatim.
1820
+ - heartbeat comment lines (`:`) every `policy.stream.heartbeatMs`.
1821
+ - `error` — data the §7.3 wire error (no `status` member matters here);
1822
+ the stream ends with it.
1823
+ - `end` — data `{ "reason": "closed" | "server-shutdown" }`.
1824
+
1825
+ `seq` is strictly increasing per stream; a violation is the client's
1826
+ `JC2092`. An emission whose serialized patch exceeds
1827
+ `policy.stream.maxPatchBytes` is replaced by a fresh `snapshot` event
1828
+ at that emission's seq — the consumer swaps its document instead of
1829
+ patching it; nothing is dropped.
1830
+
1831
+ **Resumption.** A request carrying `Last-Event-ID: <seq>` asks to
1832
+ resume. Under `resume: "replay"` the binding asks
1833
+ `subscription.replay?.(seq)`; when the handler answers an array of
1834
+ emissions, the stream starts with the `patch` events after that seq
1835
+ (no snapshot) and continues live. Otherwise — `resume: "snapshot"`, no
1836
+ `replay`, or a `replay` that answers `null` — the stream starts with a
1837
+ fresh `snapshot` whose data carries `resumed: false` (`JC2095`,
1838
+ informational, never an outcome).
1839
+
1840
+ `toNodeHandler` writes SSE with `flushHeaders()` + `res.write` and ends
1841
+ on close; `toFetchHandler` answers a `ReadableStream` body; both abort
1842
+ `ctx.signal` when the peer goes away (`request.signal`, `req` close),
1843
+ which runs the exactly-once `stop()`/`close()`. A dispatcher's
1844
+ `close()` ends every live SSE stream with `end` (`server-shutdown`)
1845
+ before releasing it.
1846
+
1847
+ ### §18.2 Port: push frames
1848
+
1849
+ The §16 frame family gains three shapes (the grammar artifact carries
1850
+ them):
1851
+
1852
+ ```jsonc
1853
+ { "jaren": "contract/0.1", "subscribe": "<clientId>:<seq>", "op": "data.live",
1854
+ "input": { "collection": "notes" }, "lastSeq": 41 } // client → server; lastSeq? resumes
1855
+ { "jaren": "contract/0.1", "unsubscribe": "<clientId>:<seq>" } // client → server; never answered
1856
+ { "jaren": "contract/0.1", "id": "<clientId>:<seq>", "event": "snapshot",
1857
+ "seq": 0, "data": { "value": { "rows": [] }, "resumed": false } } // server → client push
1858
+ ```
1859
+
1860
+ A push frame's `event` is `snapshot | patch | error | end` with the
1861
+ same data shapes as §18.1 (`error` data is the wire error; `end` data
1862
+ the reason record); `seq` mirrors the event's seq (`error`/`end` carry
1863
+ the last delivered seq). Ids are scoped exactly as §16's request ids,
1864
+ so two clients on one channel hold independent subscriptions and an
1865
+ `unsubscribe` from one cannot touch the other's. Pre-stream failures —
1866
+ unknown or non-subscribe `op` (`JC2071`), invalid input (`JC2006`),
1867
+ handler faults (`JC2070`), an invalid snapshot (`JC2091`) — arrive as
1868
+ `error` push frames. There is no heartbeat on a port (delivery is
1869
+ in-process); `server.close()` pushes `end` (`server-shutdown`) to every
1870
+ live subscription before stopping it.
1871
+
1872
+ ### §18.3 The stream codes
1873
+
1874
+ `STREAM_ERRORS` (`@jarenjs/contract/stream`) is the table as data;
1875
+ these rows join `CONTRACT_CODES` and the English catalog like every
1876
+ other range:
1877
+
1878
+ | code | kind | msgid | retryable | when |
1879
+ |---|---|---|---|---|
1880
+ | `JC2090` | contract | `contract/not-a-stream` | no | the server answered a subscribe request with a non-stream response (client-side) |
1881
+ | `JC2091` | contract | `contract/invalid-snapshot` | no | a snapshot fails the output validator — the server broke the contract; sent as the `error` event that ends the stream |
1882
+ | `JC2092` | contract | `contract/seq-regression` | no | an event's seq is not strictly greater than the last delivered (client-side) |
1883
+ | `JC2093` | contract | `contract/stream-error` | no | the stream ended with a server `error` event whose code the operation does not declare — a **declared** code lands as a `failure` outcome under its own code instead |
1884
+ | `JC2094` | network | `contract/heartbeat-missed` | yes | no bytes for `2 × heartbeatMs` (client-side, SSE only) |
1885
+ | `JC2095` | — | — | — | a requested resume was refused; informational, carried as `resumed: false` in the fresh snapshot's event data, never an outcome |
1886
+
1887
+ `JC2096–JC2109` are reserved for later stream codes. `JC1009` (an SSE
1888
+ data string the frame cannot carry) and `JC1010` (`subscribe` of a
1889
+ non-subscribe operation) are the stream's host programming errors
1890
+ (§7.3's host table).
1891
+
1892
+ ## §19 The client: `subscribe`
1893
+
1894
+ `client.subscribe(op, input, { onSnapshot, onPatch, onError, onEnd,
1895
+ signal, lastSeq }) → { stop() }` — on the `http` and `port` clients
1896
+ alike (`capabilities.stream: true`); `serveLocal` keeps
1897
+ `capabilities.stream: false` and needs no handler for a subscribe
1898
+ operation (`invoke` of one throws `JC1005` there). A non-subscribe
1899
+ operation is `JC1010`, thrown — the host named the wrong operation.
1900
+
1901
+ - `onSnapshot(value, { seq, resumed })` — a fresh, validated snapshot;
1902
+ the consumer replaces its document. `resumed` is `false` exactly as
1903
+ §18.1 defines it.
1904
+ - `onPatch({ patch, seq })` — the LIVE emission, **not applied**: the
1905
+ client forwards patches; the app binding (§11.4) and the consumer
1906
+ apply them (`@jarenjs/json/patch`). `seq` is strictly increasing or
1907
+ the stream ends with `JC2092`.
1908
+ - `onError(outcome)` — a D6 `ok: false` outcome (`failure` for a
1909
+ declared error event; `network` for a transport failure or a missed
1910
+ heartbeat; `contract` for `JC2090`/`JC2092`/`JC2093`, an invalid
1911
+ snapshot value, or a pre-send input refusal `JC2050`). Its `error`
1912
+ and `meta` carry every member (`status: null` where the wire has
1913
+ none). After `onError` the stream is finished and cleaned up.
1914
+ - `onEnd({ reason })` — the server's `end` event; a stream that ends
1915
+ without one is reported as `reason: "closed"`.
1916
+
1917
+ Every callback is optional and total for the client: a callback that
1918
+ throws does not break the stream machinery. `signal` aborts the
1919
+ subscription silently (the caller asked); `stop()` does the same and,
1920
+ on `port`, posts the `unsubscribe` frame. `lastSeq` is what a
1921
+ reconnect passes (§18's resumption). **Reconnection is not automatic**:
1922
+ the host decides — a `subscribe` that ends with a `network` outcome is
1923
+ re-entered by calling `subscribe` again with the last delivered seq.