@jarenjs/contract 0.49.2 → 0.66.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 (52) hide show
  1. package/README.md +191 -30
  2. package/dist/types/adapters/fetch.d.ts +11 -10
  3. package/dist/types/adapters/node.d.ts +24 -10
  4. package/dist/types/client/http.d.ts +77 -10
  5. package/dist/types/compat.d.ts +1 -1
  6. package/dist/types/errors.d.ts +3 -0
  7. package/dist/types/host.d.ts +179 -0
  8. package/dist/types/http/body.d.ts +147 -0
  9. package/dist/types/http/dispatch.d.ts +26 -2
  10. package/dist/types/http/serve.d.ts +46 -3
  11. package/dist/types/http/wire.d.ts +31 -17
  12. package/dist/types/ledger.d.ts +57 -12
  13. package/dist/types/local/index.d.ts +7 -1
  14. package/dist/types/messages.d.ts +2 -0
  15. package/dist/types/path.d.ts +4 -2
  16. package/dist/types/pipeline.d.ts +15 -1
  17. package/dist/types/port/client.d.ts +18 -1
  18. package/dist/types/port/serve.d.ts +38 -5
  19. package/dist/types/project/tools.d.ts +1 -1
  20. package/dist/types/project/typescript.d.ts +11 -0
  21. package/dist/types/runtime.d.ts +25 -0
  22. package/dist/types/stream/client.d.ts +14 -3
  23. package/dist/types/stream/server.d.ts +218 -44
  24. package/dist/types/stream/sse.d.ts +10 -0
  25. package/docs/APP-INTEGRATION.md +4 -2
  26. package/docs/CONTRACT-FORMAT.md +617 -145
  27. package/package.json +5 -5
  28. package/src/adapters/fetch.js +144 -25
  29. package/src/adapters/node.js +246 -82
  30. package/src/cli.js +22 -16
  31. package/src/client/http.js +588 -189
  32. package/src/compat.js +1 -1
  33. package/src/errors.js +3 -0
  34. package/src/host.js +319 -0
  35. package/src/http/body.js +337 -0
  36. package/src/http/dispatch.js +511 -75
  37. package/src/http/serve.js +39 -5
  38. package/src/http/wire.js +33 -14
  39. package/src/ledger.js +119 -36
  40. package/src/local/index.js +91 -35
  41. package/src/messages.js +2 -0
  42. package/src/path.js +9 -3
  43. package/src/pipeline.js +18 -1
  44. package/src/port/client.js +39 -6
  45. package/src/port/serve.js +207 -69
  46. package/src/project/tools.js +9 -2
  47. package/src/project/typescript.js +91 -1
  48. package/src/project/typescript.jtlt.json +39 -7
  49. package/src/runtime.js +36 -0
  50. package/src/stream/client.js +40 -6
  51. package/src/stream/server.js +573 -138
  52. package/src/stream/sse.js +2 -0
@@ -17,6 +17,15 @@ declared errors, a behavior policy and an HTTP binding. It is compiled
17
17
  path matcher, and it is the single source every artifact around it is
18
18
  projected from.
19
19
 
20
+ A contract document is written by hand, or by code: `@jarenjs/linq/
21
+ contract` is the pen that writes exactly this format — the same
22
+ operations, schemas, policies and bindings, in §12.1's member order,
23
+ with the operations' named schemas hoisted into `$defs` — and it types
24
+ the client, the handler table and the AI tools from the same builders,
25
+ without running the TypeScript projection of §12.3. The three worked
26
+ examples below are rebuilt through it, byte for byte, by its own test
27
+ suite.
28
+
20
29
  Format 0.1 covers the document, its compilation, the HTTP binding's
21
30
  *shape* (§2–§6), the HTTP **server** binding that carries it (§7–§9:
22
31
  the request pipeline and its wire errors, idempotency and the ledger
@@ -48,22 +57,22 @@ coming lines of this package and will append their sections here.
48
57
  "version": "5",
49
58
  "compat": ["4"],
50
59
  "$defs": {
60
+ "Catalog": {
61
+ "type": "object",
62
+ "properties": {
63
+ "revision": { "type": "integer" },
64
+ "products": { "type": "array", "items": { "$ref": "#/$defs/Product" } }
65
+ },
66
+ "required": ["revision", "products"]
67
+ },
51
68
  "Product": {
52
69
  "type": "object",
53
- "required": ["id", "name", "price"],
54
70
  "properties": {
55
71
  "id": { "type": "integer" },
56
72
  "name": { "type": "string", "minLength": 1 },
57
73
  "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
- }
74
+ },
75
+ "required": ["id", "name", "price"]
67
76
  },
68
77
  "Conflict": { "type": "object", "properties": { "current": { "$ref": "#/$defs/Product" } } }
69
78
  },
@@ -81,12 +90,12 @@ coming lines of this package and will append their sections here.
81
90
  "kind": "command",
82
91
  "input": {
83
92
  "type": "object",
84
- "required": ["id", "revision", "product"],
85
93
  "properties": {
86
94
  "id": { "type": "integer" },
87
95
  "revision": { "type": "integer" },
88
96
  "product": { "$ref": "#/$defs/Product" }
89
- }
97
+ },
98
+ "required": ["id", "revision", "product"]
90
99
  },
91
100
  "output": { "$ref": "#/$defs/Product" },
92
101
  "errors": {
@@ -102,7 +111,7 @@ coming lines of this package and will append their sections here.
102
111
  },
103
112
  "image.bytes": {
104
113
  "kind": "read",
105
- "input": { "type": "object", "required": ["id"], "properties": { "id": { "type": "integer" } } },
114
+ "input": { "type": "object", "properties": { "id": { "type": "integer" } }, "required": ["id"] },
106
115
  "output": true,
107
116
  "http": { "method": "GET", "path": "/api/images/{id}", "media": "application/octet-stream" }
108
117
  }
@@ -290,7 +299,8 @@ and **canonicalized to `{name}`**, which is what `describe()` and every
290
299
  projection show — with `name` matching `[A-Za-z_][A-Za-z0-9_]*` and
291
300
  declared once per template. A static segment is any run of characters
292
301
  except `/ { } : * ? #`, whitespace and control characters; a `%` in it
293
- MUST open a well-formed escape.
302
+ MUST open a well-formed escape, and escaped bytes MUST decode as UTF-8
303
+ (`JC0008` at the path when they do not).
294
304
 
295
305
  Reserved and refused by name (`JC0008` says which): the RFC 6570 operator
296
306
  forms `{+name}` `{#name}` `{.name}` `{/name}` `{;name}` `{?name}` `{&name}`
@@ -313,15 +323,22 @@ The **shape** of a binding is its method plus its template with every
313
323
  variable normalized to `{}`: `GET /api/products/{}`. Two operations MUST
314
324
  NOT share a shape (`JC0010` at the second one, in document order); the
315
325
  canonical binding takes part (`POST /<id>` may collide with a declared
316
- `POST /<id>`).
326
+ `POST /<id>`). Static segments compare in decoded space, so `/a` and
327
+ `/%61` share a shape, as do differently cased percent-escapes. An escaped
328
+ slash stays inside its segment, and escaped braces stay static text:
329
+ `/a%2Fb` differs from `/a/b`, and `/%7B%7D` differs from `/{id}`.
317
330
 
318
331
  ### §4.5 Opaque operations
319
332
 
320
333
  A `media` other than `application/json` (or a `+json` structured-syntax
321
334
  suffix, parameters ignored) marks the operation **opaque**: it is routed
322
335
  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
336
+ validated by the contract, and it is excluded from `invoke` and from the
337
+ generated `Operations` map an HTTP client reaches it through `bytes`
338
+ (§10.6), whose success owns the live response stream, and through `url`.
339
+ Its bytes are **streamed** in both directions: the handler pulls the
340
+ upload one chunk at a time and may answer a pull source of its own
341
+ (§7.1). `image.bytes` in §2 is one. Because its body is
325
342
  bytes the contract never decodes, an opaque operation MUST NOT declare a
326
343
  **body-located member** — neither through `http.body`, nor `http.in`,
327
344
  nor the `command` default (`JC0017` at the member that placed it there,
@@ -427,20 +444,20 @@ The three worked examples this document is tested against, complete:
427
444
  "kind": "command",
428
445
  "input": {
429
446
  "type": "object",
430
- "required": ["id", "doc"],
431
447
  "properties": {
432
448
  "id": { "type": "string" },
433
449
  "doc": { "type": "array", "items": { "type": "object" } },
434
450
  "dry": { "type": "boolean" }
435
- }
451
+ },
452
+ "required": ["id", "doc"]
436
453
  },
437
454
  "output": true,
438
455
  "policy": { "idempotency": "optional" },
439
- "http": { "method": "PUT", "path": "/docs/:id", "body": "doc", "in": { "dry": "query" }, "status": 204 }
456
+ "http": { "method": "PUT", "path": "/docs/:id", "in": { "dry": "query" }, "body": "doc", "status": 204 }
440
457
  },
441
458
  "doc.remove": {
442
459
  "kind": "command",
443
- "input": { "type": "object", "required": ["id"], "properties": { "id": { "type": "string" } } },
460
+ "input": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] },
444
461
  "output": true
445
462
  }
446
463
  }
@@ -489,10 +506,12 @@ is the operation's output. `ctx` is frozen per request:
489
506
  |---|---|
490
507
  | `op` | the compiled operation |
491
508
  | `trace` | the server trace id of this request (also `x-jaren-trace` on the response and `requestId` in an error body) |
509
+ | `carrier` | `"http"` — the binding this context comes from (`"port"` and `"local"` on theirs, §15–§16) |
510
+ | `host` | the host lifecycle's value (§7.7): what `acquire` entered with — `null` by default, the identity's host when only `identify` is named; the value itself is the host's and is not deep-frozen |
492
511
  | `method`, `path` | the request line, path without the query |
493
512
  | `params` | the raw decoded path strings, frozen |
494
513
  | `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` |
514
+ | `body` | the raw request body of an **opaque** operation: text, bytes, or — when the adapter handed the request over as a stream — a pull source (`AsyncIterable<Uint8Array>`) that never yields a byte past `policy.limits.maxBodyBytes` (the chunk that would cross it throws a `BodyLimitError`, exported by `@jarenjs/contract/http`, to the puller); `null` for a JSON operation, whose body was decoded into `input` |
496
515
  | `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
516
  | `idempotency` | `{ key, scope }` when this request runs under an idempotency key, else `null` |
498
517
  | `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` |
@@ -502,6 +521,26 @@ is the operation's output. `ctx` is frozen per request:
502
521
  An **opaque** operation (`http.opaque`) takes a *raw* handler: `(input,
503
522
  ctx) => { status, headers?, body? }` with the bytes in `ctx.body`; it
504
523
  bypasses media, parse, body assembly, idempotency and output validation.
524
+ **The bytes stream.** Through the adapters (§9) `ctx.body` is a pull
525
+ source: the handler reads it with `for await`, one chunk at a time, and
526
+ nothing is collected on its behalf. The source counts: the chunk that
527
+ would cross `policy.limits.maxBodyBytes` is never yielded — the upstream
528
+ is cancelled once and a `BodyLimitError` is thrown to the puller. A
529
+ handler that lets it propagate answers `JC2003` (the request's fault,
530
+ not a host fault — `onError` does not see it); one that catches it
531
+ decides for itself. The handler's `body` may likewise be a pull source
532
+ (an async iterable, or a Web `ReadableStream`, normalized): the adapter
533
+ writes it chunk by chunk behind the socket's backpressure, and under
534
+ HEAD it is cancelled once, never drained. A plain (non-stream) response
535
+ answered with the upload still unread cancels the upload once before the
536
+ response is exposed; a streamed response keeps the upload alive — the
537
+ handler may be transforming it — and releases it once when the response
538
+ reaches EOF, throws, or is cancelled by the consumer. A limit crossing
539
+ met by such a transform after the headers went out cuts the response
540
+ body (the status cannot be rewritten) and is reported to `onError`.
541
+ Effects a handler made before a chunked upload crossed its limit are its
542
+ own to undo; the host lifecycle's acquired transaction (a later order's
543
+ seam) is where such a rollback belongs.
505
544
  Its transport members are decoded and normalized into `input` and
506
545
  validated like any other input (`JC2006`) — they **are** its whole
507
546
  input, since an opaque operation cannot declare a body-located member
@@ -519,8 +558,10 @@ is `JC2008`.
519
558
  ### §7.2 The pipeline, in order
520
559
 
521
560
  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.
561
+ `body` a string, `Uint8Array`, a pull source (`AsyncIterable<
562
+ Uint8Array>`, or a Web `ReadableStream`, normalized to one) or `null`
563
+ (an absent body is `null`) — a malformed object is `JC1004`,
564
+ **rejected**, never a response.
524
565
  2. **Route.** `url` is split at the first `?`; the path goes to
525
566
  `contract.match(method, path)`; under `HEAD` with `head` on, `HEAD`
526
567
  is tried, then `GET`. No match: an undecodable path (a malformed
@@ -529,10 +570,21 @@ is `JC2008`.
529
570
  (with `HEAD` added beside `GET` when `head` is on) is `JC2002` with
530
571
  `Allow`; else `JC2001`. A matched operation without a handler (a
531
572
  `partial` server) is `JC2013`.
573
+ Then **identify** (§7.7): the host lifecycle's first hook runs with
574
+ the operation, the trace, the signal and the raw transport facts —
575
+ before any byte of the body is read — and answers the identity
576
+ lease (its `host` is what `scope` sees) or a declared failure; a
577
+ hook fault is `JC2008`. Every later refusal in this list releases the
578
+ identity before it is exposed.
532
579
  3. **The body limit.** A `content-length` above `policy.limits.maxBodyBytes`
533
580
  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.
581
+ text or byte body whose length exceeds the limit is `JC2003` after; a
582
+ pull source is measured as it is pulled — a JSON operation drains it
583
+ under the limit and answers `JC2003` at the first byte past it (the
584
+ source cancelled once, the crossing chunk never retained), an opaque
585
+ handler's source throws `BodyLimitError` there (§7.1). Applies to
586
+ every matched operation, opaque and body-less included; a body-less
587
+ operation releases a source without pulling it.
536
588
  4. **Opaque** → the transport input validated as in step 8 when no
537
589
  member is body-located (`JC2006`), then the raw handler through the
538
590
  same boundary as step 11; done.
@@ -543,9 +595,10 @@ is `JC2008`.
543
595
  accepted for `application/json`); else `JC2004`. A body-less
544
596
  operation with a body **ignores** the body. An empty body needs no
545
597
  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`).
598
+ 6. **Parse.** A pull source is drained whole first (a source that fails
599
+ or is aborted before EOF never arrived whole: `JC2005`); bytes are
600
+ decoded as strict UTF-8 (invalid → `JC2005`; a leading BOM is
601
+ stripped by the decoder); then `JSON.parse` (a failure is `JC2005`).
549
602
  7. **Assemble** the input object through a prototype-safe setter only, in
550
603
  this order: path members (raw decoded strings), query members
551
604
  (`URLSearchParams` semantics — `+` is a space; a member listed in
@@ -570,7 +623,13 @@ is `JC2008`.
570
623
  (its `policy.idempotency` is `none` by construction).
571
624
  9. **Idempotency** when `policy.idempotency !== "none"` (§8): a missing
572
625
  `Idempotency-Key` is `JC2007` under `required` and runs plainly under
573
- `optional`; otherwise the input is hashed and the ledger claimed.
626
+ `optional`; otherwise the input is hashed and the ledger claimed. A
627
+ `replay`, `mismatch` or `in-progress` answer returns here — the host
628
+ lifecycle's `acquire` is never called for it.
629
+ Then **acquire** (§7.7): the second hook runs with the validated
630
+ input and the identity context, and calls `enter` with the lease the
631
+ handler runs under; the handler's context is the identity context
632
+ with the acquired `host`, frozen. Steps 10–14 run inside `enter`.
574
633
  10. **Preconditions, opt-in** (§7.5): when the operation has a
575
634
  `preconditions` resolver, the CURRENT tag is resolved BEFORE the
576
635
  handler — a command consults it only under a conditional header, a
@@ -612,6 +671,19 @@ is `JC2008`.
612
671
  charset=utf-8` (when a body), `x-jaren-trace`, `etag` when armed;
613
672
  a `204` carries no body; a HEAD carries the `content-length` of the
614
673
  body it dropped and no body. Then the ledger claim is settled (§8).
674
+ A raw handler's body passes through as it is: text, bytes, or a pull
675
+ source the adapter streams (no `content-length`; HEAD cancels it).
676
+ Under a lease that **requires settlement** (§7.7) the claim is
677
+ recorded through the lease's ledger inside `enter`, before `enter`
678
+ resolves, and the root ledger stands down; a host fault or a
679
+ pre-handler refusal makes `enter` reject — a host transaction
680
+ around it rolls back — and the root ledger releases the key
681
+ retryable outside it.
682
+ 15. **Release.** The acquired lease, then the identity, each once: before
683
+ the response is exposed (a release that fails there is `JC2008`,
684
+ its cause observed), or — for a pull-source body and an SSE stream
685
+ — when the body settles or the stream is done (a failure then is
686
+ observed only).
615
687
 
616
688
  Every step's failure path returns a response. `dispatch` never rejects
617
689
  for request content; a defect of the binding itself is caught last and
@@ -700,6 +772,7 @@ wire response:
700
772
  | `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) |
701
773
  | `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) |
702
774
  | `JC1010` | `client.subscribe` was asked for an operation that is not a subscribe operation (§19) |
775
+ | `JC1011` | a ledger `commit`/`fail` named a ref that settles no started record — expired, reclaimed under a newer generation, or settled already (§8); refused by the ledger, reported to `onError` by the binding |
703
776
 
704
777
  ### §7.4 Headers
705
778
 
@@ -768,15 +841,135 @@ running the handler and dropping the body (a declared `HEAD` operation
768
841
  wins); with `head: false` a HEAD is a 405 listing `GET`. The `wellKnown`
769
842
  path (`/.well-known/jaren-contract`, or another absolute path, or `false`)
770
843
  answers `describe()` — `revision: null` until the revision lands, `compat`
771
- present — for negotiation. `trace` (default `crypto.randomUUID`) generates
772
- the server trace; `scope(ctx)` derives the idempotency scope (§8);
844
+ present — for negotiation. `trace` (default the runtime record's `uuid`,
845
+ `crypto.randomUUID` with no record) generates the server trace; `scope(ctx)` derives the idempotency scope (§8);
773
846
  `partial` allows missing handlers; `validateOutput` is `"always" |
774
847
  "never"`; `preconditions` maps operation ids to pre-handler tag
775
848
  resolvers (§7.5); `errorBody(wire, ctx)` and `onError(err, ctx)` are the
776
849
  two host hooks (`ctx` is `null` before an operation is matched);
777
850
  `catalog` is a message catalog (templates or compiled renderers)
778
851
  consulted before the English one; `now` is the clock stamped into ledger
779
- claims.
852
+ claims; `runtime` is the host's runtime record (`@jarenjs/core/runtime`),
853
+ whose `uuid` and `now` apply where `trace` and `now` are absent. Every
854
+ binding of this format takes the same `runtime` option with the same
855
+ precedence — an explicit option wins over the record's member, which
856
+ wins over the platform default — so one record configures a server, its
857
+ ledger and a client together: `servePort` and `openLocalClient` read its
858
+ `uuid` for their trace, `openPortClient` for its client id,
859
+ `openHttpClient` reads `uuid` for idempotency keys, `now` for key-record
860
+ stamps and `random` for retry jitter, and `createMemoryLedger` reads
861
+ `now` for claim stamps.
862
+
863
+ ### §7.7 The host lifecycle: identify, acquire, release, settle
864
+
865
+ A host owns resources a handler needs — a principal, a tenant's store, a
866
+ transaction — and the binding owns the moments they may be taken and
867
+ must be given back. `serveHttp`, `servePort` and `openLocalClient` take
868
+ the same two hooks, validated at construction (`JC1001` otherwise) and
869
+ defaulted exactly:
870
+
871
+ ```
872
+ identify(meta) → { host, release? } | declared failure | Promise<…>
873
+ acquire(input, identity, enter) → enter({ host, release?, settlement? }) | declared failure | Promise<…>
874
+ settlement := { ledger, required: true }
875
+
876
+ default identify → { host: null }
877
+ default acquire → enter({ host: identity.host })
878
+ ```
879
+
880
+ `meta` is `{ op, trace, signal, carrier, method, path, headers, fail }`
881
+ — the matched operation, the trace, the request signal, the carrier
882
+ name, the request line and the raw request headers on HTTP (`null` on
883
+ port and local), and the declared-failure factory. It carries **no
884
+ parsed input and no authentication vocabulary**: what an identity is
885
+ made of is the host's. `identity` is the frozen identity context (the
886
+ request context with the identity's `host`); `scope(ctx)` sees that
887
+ context. A lease is an object with an own `host` — the value the
888
+ handler sees as `ctx.host` — and an optional `release` function; the
889
+ acquired lease may add `settlement`. A host that names only `identify`
890
+ sees its host in `scope` and in the handler; one that names `acquire`
891
+ owns the handler's host.
892
+
893
+ **Order.** (1) the request shape, the route and the trace; (2)
894
+ `identify`; (3) media, parse, assembly and validation; (4) the
895
+ idempotency key, scope, hash and claim of an HTTP JSON command; (5) a
896
+ replay, mismatch or in-progress answer returns here, `acquire` never
897
+ called; (6) `acquire(validatedInput, identity, enter)`; (7) inside
898
+ `enter`: the frozen handler context, the precondition, the handler, the
899
+ output validation and the serialization; (8) a required settlement,
900
+ then `enter` resolves and the host's continuation around it settles;
901
+ (9) the response is exposed, and the releases run at the lifetime
902
+ boundary. Opaque, subscribe, port and local paths skip the ledger steps
903
+ that do not apply and keep the order otherwise.
904
+
905
+ **Faults.** A hook that throws or rejects, answers something that is
906
+ not a lease (no own `host`, a `release` that is not a function, a
907
+ `settlement` without a ledger that commits and fails), never calls
908
+ `enter`, calls it twice, or resolves before `enter` settled is the
909
+ host's fault: observed through `onError` and answered as the binding's
910
+ host fault — `JC2008` on HTTP, `JC2070` on port and local. A declared
911
+ failure is recognized by the `ContractFailure` brand only (`meta.fail`,
912
+ or the package's `ContractFailure`), never by shape, and is validated
913
+ against the operation exactly as a handler's `ctx.fail` is. A
914
+ shape-compatible object is a malformed lease.
915
+
916
+ **Release.** The acquired release runs, then the identity's, each at
917
+ most once, on every exit that reached it: an ordinary response releases
918
+ after the whole pipeline (the required settlement included) and before
919
+ the response is exposed; a pull-source body of an opaque operation
920
+ carries the releases and runs them once at EOF, on a throw, on the
921
+ consumer's cancel, on HEAD's discard and on a disconnect; an SSE stream
922
+ and a port subscription release after the runner's stop/close/done
923
+ sequence. The identity releases on every early refusal too — malformed
924
+ JSON, unsupported media, the body limit, a malformed query or header,
925
+ invalid input, replay, mismatch, in-progress — and the acquired release
926
+ never runs when `acquire` did not enter. A release that fails before
927
+ the response is exposed is observed and answered as the host fault; one
928
+ that fails after exposure is observed only. A release failure after a
929
+ committed settlement therefore answers `JC2008` for work that was done:
930
+ under an idempotency key the retry replays the committed response.
931
+
932
+ **Required settlement.** An `acquire` that hands `enter` a lease with
933
+ `settlement: { ledger, required: true }` — the ledger being one over
934
+ the host's own transaction, `createDbLedger(tx)` from
935
+ `@jarenjs/linq/db` (§8) — has the claim of a newly claimed JSON command
936
+ recorded through that ledger inside `enter`: a success commits, a
937
+ declared failure and a post-handler `JC2014` record the same failed
938
+ receipt and retryability `settleClaim` would, and only then does
939
+ `enter` resolve, so a host transaction opened around it commits the
940
+ domain write and the receipt together or not at all. A pre-handler
941
+ refusal, a handler throw, an invalid output, a fault of the binding's
942
+ own continuation or a settlement that throws makes `enter` reject with
943
+ a private carrier of the intended wire fault: the host transaction rolls
944
+ back, the dispatcher releases the root claim retryable outside it, and
945
+ the fault is the answer. The lease's ledger never settles a replay or a
946
+ claim it did not enter for; a settlement on a non-idempotent operation,
947
+ on port or on local is accepted and unused. Without a required
948
+ settlement the root ledger settles after `enter`, best-effort, as it
949
+ always did.
950
+
951
+ ```js
952
+ import { open, createDbLedger } from '@jarenjs/linq/db';
953
+
954
+ const db = await open(model, { driver: nodeDriver(), path: 'app.db' });
955
+ const server = serveHttp(contract, handlers, {
956
+ ledger: createDbLedger(db), // the root claim: immediate, one writer
957
+ identify: (meta) => ({ host: { tenant: meta.headers['x-tenant'] ?? null } }),
958
+ acquire: (input, identity, enter) => db.transaction( // one transaction around the handler
959
+ (tx) => enter({ host: { db: tx, tenant: identity.host.tenant }, settlement: { ledger: createDbLedger(tx), required: true } }),
960
+ { mode: 'immediate' }),
961
+ });
962
+ ```
963
+
964
+ **What this is, exactly.** The claim is taken before the transaction
965
+ and recovered outside it: a rolled-back `enter` leaves the key
966
+ retryable, a crashed host leaves it `started` until it expires, and a
967
+ retry under the same key finds the recorded receipt or a fresh claim.
968
+ Atomicity is the store's: a ledger and a domain write on ONE store
969
+ commit together; a ledger on one store and a write on another are two
970
+ commits, and nothing here makes an external effect — a mail, a payment
971
+ — exactly-once. The receipt says the command's response was recorded,
972
+ not that the world outside the store agrees.
780
973
 
781
974
  ## §8 Idempotency and the ledger
782
975
 
@@ -796,11 +989,22 @@ is the same request. The binding then calls the ledger:
796
989
  ```jsonc
797
990
  // the Ledger interface — every method may return its value or a promise of it
798
991
  { "claim": "({ op, scope, key, hash, now }) → { state: 'new', ref } | { state: 'replay', response } | { state: 'in-progress' } | { state: 'mismatch' }",
799
- "commit": "(ref, response) → void",
800
- "fail": "(ref, retryable, response?) → void",
801
- "lookup": "({ op, scope, key }) → record | null" }
992
+ "commit": "(ref, response, now?) → void",
993
+ "fail": "(ref, retryable, response?, now?) → void",
994
+ "lookup": "({ op, scope, key, now? }) → record | null" }
802
995
  ```
803
996
 
997
+ The `ref` a `new` claim hands back is `{ id, generation }` — the
998
+ record's id and the **generation** the claim minted for it — and is
999
+ portable: it names the record across processes rather than holding it.
1000
+ A settlement is fenced by both: `commit`/`fail` settle the record whose
1001
+ `id` AND `generation` the ref names while it is still `started`, and a
1002
+ ref whose record expired, was reclaimed under a newer generation, or was
1003
+ settled already is refused with `JC1011` (thrown or rejected) — the
1004
+ binding reports it to `onError` and the response still goes out, so a
1005
+ stale settlement is visible instead of silently landing on a later
1006
+ claim's record.
1007
+
804
1008
  Semantics the binding relies on: same key + same hash → `replay` — the
805
1009
  stored `{ status, headers, body }` **verbatim** with a fresh
806
1010
  `x-jaren-trace` and `idempotent-replayed: true`; same key + different
@@ -815,19 +1019,29 @@ declared failure is recorded as **failed** with its response and its
815
1019
  key as retryable with no response; a POST-handler `JC2014` (the handler
816
1020
  already ran and may have mutated) is recorded as **failed**, not
817
1021
  retryable, with its 412 — a blind retry under the same key replays the
818
- 412 instead of running the handler again. `now` on a claim is the binding's
819
- clock (`options.now`), which a ledger may prefer to its own. Opaque
1022
+ 412 instead of running the handler again. `now` on a claim, a commit, a
1023
+ failure and a lookup is the binding's clock (`options.now`, or the
1024
+ runtime record's): ONE clock judges a record from claim to expiry, so a
1025
+ ledger without a clock of its own follows the binding's instants, and a
1026
+ ledger with one is given the same `runtime` as the binding — a claim
1027
+ stamped by an injected server clock and expired by the platform's is a
1028
+ command that runs twice. Opaque
820
1029
  operations bypass the ledger; reads never carry a key. A ledger that
821
1030
  throws or rejects is reported to `onError` and the response still goes
822
1031
  out (a throwing `claim` is `JC2008`).
823
1032
 
824
- `createMemoryLedger({ ttlMs = 86_400_000, now })` (`@jarenjs/contract/ledger`)
1033
+ `createMemoryLedger({ ttlMs = 86_400_000, now, runtime })` (`@jarenjs/contract/ledger`)
825
1034
  is the reference implementation over a `Map`: synchronous,
826
- single-process, expiring on `claim` and `lookup`, with `sweep()` for a
827
- host timer and `size`. The record it keeps is:
1035
+ single-process, expiring on `claim` and `lookup`, with `sweep(now?)` for a
1036
+ host timer and `size`. Built without `now` or `runtime` it keeps time by
1037
+ the instants the binding passes it (a host-side `lookup`/`sweep` that
1038
+ passes none uses the latest one); built with either, that clock judges
1039
+ every record; the runtime record's `uuid` mints each generation, and
1040
+ `lookup` answers a copy. The record it keeps is:
828
1041
 
829
1042
  ```jsonc
830
- { "id": "product.save|tenant-a|k-1", // "<op>|<scope>|<key>"
1043
+ { "id": "1:[\"product.save\",\"tenant-a\",\"k-1\"]", // ledgerId(op, scope, key): version 1, the JSON tuple
1044
+ "generation": "0f3c…", // minted per started record; what a ref names
831
1045
  "op": "product.save", "scope": "tenant-a", "key": "k-1",
832
1046
  "hash": "9f2a…", // 64 lowercase hex characters
833
1047
  "status": "committed", // started | committed | failed
@@ -836,12 +1050,26 @@ host timer and `size`. The record it keeps is:
836
1050
  "createdAt": 1755000000000, "updatedAt": 1755000000000, "expiresAt": 1755086400000 }
837
1051
  ```
838
1052
 
1053
+ `ledgerId(op, scope, key)` (`@jarenjs/contract/ledger`) spells the id:
1054
+ the version `1`, a colon, the JSON array of the tuple — injective, so a
1055
+ `|`, a control character or any Unicode inside a member cannot collide
1056
+ with another tuple. A record written under the earlier
1057
+ `"<op>|<scope>|<key>"` spelling is matched by no claim again: it
1058
+ expires by its own `expiresAt` (a `sweep` drops it), and a host that
1059
+ must keep such records reachable rewrites their `id` once
1060
+ (`ledgerId(record.op, record.scope, record.key)`) before the new
1061
+ version serves.
1062
+
839
1063
  Two documents ship the same shape as **data**, for a host that wants
840
1064
  durability (this package imports neither `@jarenjs/db` nor
841
1065
  `@jarenjs/flow`): `idempotencyLedgerModel` is a `$model` 0.1 document —
842
- collection `ledger`, key `/id`, that record as its schema (closed),
843
- indexes on `expiresAt` and `status` — a host opens it with `openStore`
844
- and implements the interface over the collection; `commandLifecycleFsm`
1066
+ collection `ledger`, key `/id`, that record as its schema (closed, the
1067
+ `generation` required), indexes on `expiresAt` and `status` — a host
1068
+ opens it with `openStore` and implements the interface over the
1069
+ collection, or takes `createDbLedger` from `@jarenjs/linq/db`, which is
1070
+ that implementation over the typed client (root claims under
1071
+ `mode: 'immediate'`, a transaction client's settlements inside the
1072
+ host's own transaction; DB-CLIENT.md §2.6); `commandLifecycleFsm`
845
1073
  is a `$fsm` 0.1 document — `idle → started` on `claim`, `started →
846
1074
  committed` on `commit`, `started → failed` on `fail`, `failed → started`
847
1075
  on `claim` guarded by `$.context.retryable` — which the memory ledger
@@ -852,82 +1080,88 @@ walks exactly.
852
1080
  A host that retries commands needs a ledger that survives a restart, and
853
1081
  it does NOT need `@jarenjs/db` for that: `node:sqlite` is built into
854
1082
  Node ≥ 24 — the suite's floor — so the ~60 lines below are as
855
- dependency-free as the package. Two design points carry the semantics:
856
- `BEGIN IMMEDIATE` makes each `claim` one writer (two processes cannot
857
- both claim a key), and the ref is the `AUTOINCREMENT` sequence of one
858
- specific insert never reused, where a bare SQLite rowid would be — so
859
- a stale ref can never settle over a record a later claim re-created
860
- (the memory ledger's object-identity guard, spelled in SQL). Everything
861
- else mirrors `createMemoryLedger` exactly: expiry on `claim` and
862
- `lookup`, `sweep()` for a host timer, mismatch before status, a
863
- retryable failure handing the key back, a non-retryable one replaying
864
- its stored response. Remember the boundary (§8): this ledger
1083
+ dependency-free as the package (a host on `@jarenjs/db` takes
1084
+ `createDbLedger` from `@jarenjs/linq/db` instead). Two design points
1085
+ carry the semantics: `BEGIN IMMEDIATE` makes each `claim` one writer
1086
+ (two processes cannot both claim a key), and a settlement names the
1087
+ record's id AND the generation the claim minted persisted with the
1088
+ record so a stale ref matches no row and is refused `JC1011` rather
1089
+ than settling over a record a later claim re-created. Everything else
1090
+ mirrors `createMemoryLedger` exactly: the same `ledgerId`, expiry on
1091
+ `claim` and `lookup`, `sweep()` for a host timer, mismatch before
1092
+ status, a retryable failure handing the key back, a non-retryable one
1093
+ replaying its stored response; the shared ledger contract in the test
1094
+ suite runs all three. Remember the boundary (§8): this ledger
865
1095
  deduplicates DELIVERY — the domain's own durable records stay
866
1096
  authoritative for business state.
867
1097
 
868
1098
  ```js
869
1099
  import { DatabaseSync } from 'node:sqlite';
1100
+ import { ledgerId } from '@jarenjs/contract/ledger';
870
1101
 
871
1102
  /** A durable Ledger over one SQLite file — a host's example, not an export. */
872
1103
  export function createSqliteLedger(path, { ttlMs = 86_400_000, now: clock = Date.now } = {}) {
873
1104
  const db = new DatabaseSync(path);
874
1105
  db.exec(`
875
1106
  CREATE TABLE IF NOT EXISTS ledger (
876
- seq INTEGER PRIMARY KEY AUTOINCREMENT,
877
- id TEXT NOT NULL UNIQUE, op TEXT NOT NULL, scope TEXT NOT NULL, key TEXT NOT NULL,
1107
+ id TEXT PRIMARY KEY, generation TEXT NOT NULL, op TEXT NOT NULL, scope TEXT NOT NULL, key TEXT NOT NULL,
878
1108
  hash TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('started', 'committed', 'failed')),
879
1109
  response TEXT, retryable INTEGER,
880
1110
  createdAt INTEGER NOT NULL, updatedAt INTEGER NOT NULL, expiresAt INTEGER NOT NULL);
881
1111
  CREATE INDEX IF NOT EXISTS ledger_by_expires ON ledger (expiresAt);
882
1112
  CREATE INDEX IF NOT EXISTS ledger_by_status ON ledger (status);`);
883
1113
  const one = db.prepare('SELECT * FROM ledger WHERE id = ?');
884
- const put = db.prepare("INSERT INTO ledger (id, op, scope, key, hash, status, createdAt, updatedAt, expiresAt) VALUES (?, ?, ?, ?, ?, 'started', ?, ?, ?)");
1114
+ const put = db.prepare("INSERT INTO ledger (id, generation, op, scope, key, hash, status, createdAt, updatedAt, expiresAt) VALUES (?, ?, ?, ?, ?, ?, 'started', ?, ?, ?)");
885
1115
  const drop = db.prepare('DELETE FROM ledger WHERE id = ?');
886
- const settle = db.prepare("UPDATE ledger SET status = ?, response = ?, retryable = ?, updatedAt = ? WHERE seq = ? AND status = 'started'");
1116
+ // the fence: a settlement names the id AND the generation of the claim
1117
+ // that started the record, so a ref of an earlier claim matches no row
1118
+ const settle = db.prepare("UPDATE ledger SET status = ?, response = ?, retryable = ?, updatedAt = ? WHERE id = ? AND generation = ? AND status = 'started'");
887
1119
  const reap = db.prepare('DELETE FROM ledger WHERE expiresAt <= ?');
888
1120
  const stored = (row) => (row.response === null ? null : JSON.parse(row.response));
1121
+ const at = (now) => (typeof now === 'number' ? now : clock());
1122
+ const settled = (ref, changes) => {
1123
+ if (changes !== 1) throw Object.assign(new Error(`ledger: ${ref?.id ?? 'a foreign ref'} settles no started record`), { code: 'JC1011' });
1124
+ };
889
1125
  return {
890
1126
  claim({ op, scope, key, hash, now }) {
891
- const at = typeof now === 'number' ? now : clock();
892
- const id = `${op}|${scope}|${key}`;
1127
+ const id = ledgerId(op, scope, key);
893
1128
  db.exec('BEGIN IMMEDIATE'); // one writer: two processes cannot both claim the key
894
1129
  try {
895
- const row = one.get(id);
1130
+ const row = (one.get(id));
896
1131
  if (row !== undefined) {
897
- if (row.expiresAt <= at) drop.run(id);
1132
+ if (row.expiresAt <= at(now)) drop.run(id);
898
1133
  else if (row.hash !== hash) { db.exec('COMMIT'); return { state: 'mismatch' }; }
899
1134
  else if (row.status === 'started') { db.exec('COMMIT'); return { state: 'in-progress' }; }
900
1135
  else if (row.status === 'committed') { db.exec('COMMIT'); return { state: 'replay', response: stored(row) }; }
901
1136
  else if (row.retryable !== 1 && row.response !== null) { db.exec('COMMIT'); return { state: 'replay', response: stored(row) }; }
902
1137
  else drop.run(id); // a retryable failure: the key runs again
903
1138
  }
904
- const ref = { seq: put.run(id, op, scope, key, hash, at, at, at + ttlMs).lastInsertRowid };
1139
+ const generation = crypto.randomUUID();
1140
+ put.run(id, generation, op, scope, key, hash, at(now), at(now), at(now) + ttlMs);
905
1141
  db.exec('COMMIT');
906
- return { state: 'new', ref };
1142
+ return { state: 'new', ref: { id, generation } };
907
1143
  }
908
1144
  catch (err) {
909
1145
  db.exec('ROLLBACK');
910
1146
  throw err;
911
1147
  }
912
1148
  },
913
- commit(ref, response) {
914
- settle.run('committed', JSON.stringify(response), null, clock(), ref.seq);
1149
+ commit(ref, response, now) {
1150
+ settled(ref, settle.run('committed', JSON.stringify(response), null, at(now), (ref)?.id ?? '', (ref)?.generation ?? '').changes);
915
1151
  },
916
- fail(ref, retryable, response) {
917
- settle.run('failed', response === undefined ? null : JSON.stringify(response), retryable === true ? 1 : 0, clock(), ref.seq);
1152
+ fail(ref, retryable, response, now) {
1153
+ settled(ref, settle.run('failed', response === undefined ? null : JSON.stringify(response), retryable === true ? 1 : 0, at(now), (ref)?.id ?? '', (ref)?.generation ?? '').changes);
918
1154
  },
919
- lookup({ op, scope, key }) {
920
- const row = one.get(`${op}|${scope}|${key}`);
1155
+ lookup({ op, scope, key, now }) {
1156
+ const row = (one.get(ledgerId(op, scope, key)));
921
1157
  if (row === undefined) return null;
922
- if (row.expiresAt <= clock()) {
1158
+ if (row.expiresAt <= at(now)) {
923
1159
  drop.run(row.id);
924
1160
  return null;
925
1161
  }
926
- const record = { ...row, response: stored(row), retryable: row.retryable === null ? null : row.retryable === 1 };
927
- delete record.seq; // the record shape is exactly LedgerRecord (§8)
928
- return record;
1162
+ return { ...row, response: stored(row), retryable: row.retryable === null ? null : row.retryable === 1 }; // exactly LedgerRecord (§8)
929
1163
  },
930
- sweep: () => Number(reap.run(clock()).changes),
1164
+ sweep: (now) => Number(reap.run(at(now)).changes),
931
1165
  close: () => db.close(),
932
1166
  };
933
1167
  }
@@ -953,26 +1187,53 @@ the platform:
953
1187
  - **`toFetchHandler(dispatcher)`** (`@jarenjs/contract/fetch`) →
954
1188
  `(Request) => Promise<Response>` — Bun.serve, Deno, service workers,
955
1189
  Cloudflare-style hosts, and Hono. It lowercases the headers into a
956
- plain object, matches the operation first (cheap) to decide how the
957
- body is read `text()` for a JSON operation, `arrayBuffer()` for an
958
- opaque one, and **not at all** for an unmatched request or a declared
959
- `content-length` above the operation's limit (the dispatcher answers
960
- the 413 from the header) forwards `request.signal`, and builds the
961
- `Response` from the dispatcher's status, headers and body.
1190
+ plain object, matches the operation first (cheap) to decide whether
1191
+ the body is handed over at all the request's stream reaches the
1192
+ dispatcher as a pull source for a matched body-carrying operation
1193
+ (a JSON operation drains it under its limit there, an opaque handler
1194
+ pulls it chunk by chunk), and **not at all** for an unmatched request
1195
+ or a declared `content-length` above the operation's limit (the
1196
+ dispatcher answers the 413 from the header) — forwards
1197
+ `request.signal`, and builds the `Response` from the dispatcher's
1198
+ status, headers and body; a streamed body becomes a `ReadableStream`
1199
+ that pulls one chunk per read and cancels the source once.
962
1200
  - **`toNodeHandler(dispatcher)`** (`@jarenjs/contract/node`) → `(req,
963
- res)` — `http.createServer`'s listener and Express middleware. It
964
- collects the body chunk by chunk up to the operation's limit; on
965
- overflow it stops reading, answers the 413 with `connection: close`,
966
- then lingers draining and discarding the rest of the upload, bounded
967
- by a grace timer (`toNodeHandler(dispatcher, { lingerMs })`, default
968
- 1000 ms) before destroying the request, so the close is a FIN the
969
- client can read the 413 through rather than an RST that discards it
970
- (winsock drops buffered receive data on RST); a declared
971
- `content-length` above the limit is never read; an unmatched request's
972
- body is never read. Bytes reach the dispatcher as received (its strict
973
- UTF-8 decode decides `JC2005`); repeated header lines arrive as arrays
974
- (`headersDistinct`); `ctx.signal` aborts when the client goes away
975
- before the response finished; `content-length` is set on every body.
1201
+ res)` — `http.createServer`'s listener and Express middleware. The
1202
+ request reaches the dispatcher as a pull source over its own chunks
1203
+ nothing is collected in the adapter, and an unpulled upload never
1204
+ fills memory. When an upload was pulled and left unread (a limit
1205
+ crossing, a response answered before EOF) the answer carries
1206
+ `connection: close`, then the socket lingers draining and discarding
1207
+ the rest of the upload, bounded by a grace timer
1208
+ (`toNodeHandler(dispatcher, { lingerMs })`, default 1000 ms) before
1209
+ the request is destroyed, so the close is a FIN the client can read
1210
+ the 413 through rather than an RST that discards it (winsock drops
1211
+ buffered receive data on RST); a declared `content-length` above the
1212
+ limit is never read; an unmatched request's body is never read. Bytes
1213
+ reach the dispatcher as received (its strict UTF-8 decode decides
1214
+ `JC2005`); repeated header lines arrive as arrays (`headersDistinct`);
1215
+ `ctx.signal` aborts when the client goes away before the response
1216
+ finished; `content-length` is set on every text or byte body, and a
1217
+ streamed body is written chunk by chunk behind the socket's `drain`,
1218
+ its source cancelled once when the peer goes away.
1219
+
1220
+ **A streaming response is written behind backpressure.** The dispatcher
1221
+ hands the adapter's sink to `createAwaitedSink` (`@jarenjs/core/async`),
1222
+ so every SSE event is written only after the previous write settled,
1223
+ and the adapter's `write` says when that is: the node adapter answers
1224
+ nothing when `res.write()` took the chunk and a promise resolved on the
1225
+ next `drain` when it answered `false` (rejected when the response
1226
+ closes or errors first, its listeners removed either way); the fetch
1227
+ adapter's `ReadableStream` produces on demand — a write settles when
1228
+ the consumer's `pull` takes the chunk, never from an eager loop in
1229
+ `start()` — and `cancel()` rejects the write waiting for demand and
1230
+ stops the subscription exactly once. Behind either, the carrier-neutral
1231
+ runner calls its hooks one at a time (§17.1), so a slow reader parks
1232
+ the source's emissions instead of growing the process's buffers. An
1233
+ adapter of your own supplies `{ write, end, abort? }` where `write` may
1234
+ answer a promise; `response.stream(sink)` answers `{ stop, done }` —
1235
+ `stop()` ends the stream when the consumer cancels, `done` settles once
1236
+ the subscription is released and the sink has ended.
976
1237
 
977
1238
  Fastify, Hono and Express are recipes in the README, each ≤15 lines and
978
1239
  executed by a test that imports the framework from the benchmark
@@ -985,27 +1246,28 @@ streaming and peer abort behave identically through all three.
985
1246
 
986
1247
  `openHttpClient(contract, options)` (`@jarenjs/contract/client`) is the
987
1248
  client half of the http driver pair: `open(contract, options) → Client`
988
- with `Client = { invoke, url, negotiate, pending, capabilities, contract,
989
- describe(), close() }`. It is **binding-agnostic in shape** — the app
1249
+ with `Client = { invoke, bytes, url, negotiate, pending, capabilities,
1250
+ contract, describe(), close() }`. It is **binding-agnostic in shape** — the app
990
1251
  binding (§11) and later the AI tools read only `invoke`, `contract` and
991
1252
  `capabilities` — and **total in behavior**: `invoke` resolves an
992
1253
  **outcome** for everything a server or a network can do and rejects
993
1254
  only for the host's own mistake (`JC1005`: an operation the contract
994
1255
  does not declare, or an opaque one — `invoke` carries JSON; an opaque
995
- operation is reached through `url`).
1256
+ operation is reached through `bytes` (§10.6) and `url`).
996
1257
 
997
1258
  ```jsonc
998
1259
  // options — every one has a default
999
1260
  { "fetch": "globalThis.fetch", // (url, init) => Promise<Response>; injectable (a toFetchHandler, a recorder)
1000
1261
  "baseUrl": "", // prefixed to every path; '' = relative
1001
1262
  "headers": {}, // static headers, merged UNDER per-call ones
1002
- "keys": "crypto.randomUUID", // the idempotency key generator
1263
+ "keys": "runtime.uuid", // the idempotency key generator (crypto.randomUUID with no record)
1003
1264
  "storage": null, // { read(), write(value) } — the @jarenjs/app docstore adapter shape — for durable key records
1004
1265
  "timeoutMs": 0, // per request; 0 = none; composed with ctx.signal
1005
1266
  "sleep": "(ms, signal) => Promise", // the retry backoff sleeper (injectable)
1006
1267
  "catalog": null, // a message catalog consulted before the English one
1007
1268
  "wellKnown": "/.well-known/jaren-contract", // where negotiate() asks
1008
- "now": "Date.now" } // the clock stamped into key records
1269
+ "now": "runtime.now", // the clock stamped into key records (Date.now with no record)
1270
+ "runtime": "createRuntime()" } // the host's runtime record; its random draws the retry jitter
1009
1271
  ```
1010
1272
 
1011
1273
  `capabilities` is `{ name: "http", status: true, headers: true, media:
@@ -1168,6 +1430,14 @@ is the server-side half). A store that throws on the pre-send write is
1168
1430
  `JC2054` and nothing is sent; a store that throws on the drop leaves
1169
1431
  the record (the conservative side) and the outcome is unaffected.
1170
1432
 
1433
+ Concurrent writes and releases are serialized across clients sharing the
1434
+ same storage adapter object, so each mutation sees the previous write.
1435
+ A failed mutation does not block later ones. Separate adapter objects,
1436
+ tabs and processes need coordination in the storage implementation.
1437
+ `pending()` has no ordering guarantee: concurrent requests can finish hashing
1438
+ in either order before entering the storage queue. Callers that need a display
1439
+ order sort the returned records themselves.
1440
+
1171
1441
  ### §10.4 Retry
1172
1442
 
1173
1443
  Only under a declared `policy.retry` (`{ max, on }`); never for an
@@ -1208,6 +1478,38 @@ itself (`null` when unreachable or not a contract). **Nothing else is
1208
1478
  inferred**: an unrelated service on the port is exactly `not-a-contract`;
1209
1479
  two unversioned contracts are `same-version`.
1210
1480
 
1481
+ ### §10.6 `bytes(op, input, ctx)` — the opaque operations
1482
+
1483
+ `bytes(op, input, ctx) → Promise<Outcome>` is `invoke`'s twin for an
1484
+ **opaque** operation (§4.5): the same pre-send validation of the
1485
+ transport members (`JC2050`), the same URL and header assembly, one
1486
+ request through the injected `fetch`, and a D6 outcome — but its
1487
+ success owns a **live stream**, never a JSON value. `op` must be opaque
1488
+ (`JC1005` for a JSON operation: use `invoke`); `ctx` is `{ signal?,
1489
+ attempt?, headers?, ifNoneMatch?, ifMatch?, body? }` — no idempotency
1490
+ key, an opaque operation carries none — where `body` is the request
1491
+ body to send: text, bytes, a Web `ReadableStream`, an async iterable of
1492
+ `Uint8Array` chunks (wrapped in a stream that pulls one chunk per
1493
+ demand and cancels the iterator once), or none (`JC1008` for anything
1494
+ else). A streamed upload goes out with `duplex: "half"`; a body without
1495
+ a caller's `content-type` is sent as the operation's `media`.
1496
+
1497
+ The outcome: a `2xx` is `{ ok: true, value: { status, headers, media,
1498
+ body }, meta }` — `headers` the response headers under lowercase names,
1499
+ `media` its `content-type` (`null` when none), `body` the response's
1500
+ `ReadableStream<Uint8Array>` (`null` when the platform has none) which
1501
+ the **caller** reads; the client never calls `text()` or
1502
+ `arrayBuffer()` on a success. A `304` is `ok: true` with `body: null`,
1503
+ `media: null` and `meta.notModified`. Every other status is classified
1504
+ exactly as §10.1 classifies an `invoke` answer — a declared or taxonomy
1505
+ code is a `failure`, anything else `contract` `JC2055` — from the error
1506
+ body's text. A transport rejection before the headers is `network`
1507
+ (`JC2051`), an abort `cancelled` (`JC2052`). **`bytes` never retries**,
1508
+ whatever `policy.retry` declares: an upload stream cannot be replayed
1509
+ and a body already exposed cannot be re-read — a failure after the
1510
+ headers reaches the caller as the rejection of its own read of `body`.
1511
+ `meta` carries the trace, the attempt and the `etag`.
1512
+
1211
1513
  ## §11 The app binding
1212
1514
 
1213
1515
  `contractAppBinding(contract, { namespace = "contract/", statePath =
@@ -1547,13 +1849,39 @@ name), the reachable `$defs` once, then — fixed text in a JTLT
1547
1849
  stylesheet (`src/project/typescript.jtlt.json`) — `Operations` (the
1548
1850
  typed operation map: kind, input, output, the declared error codes as a
1549
1851
  literal union), `UrlOperations` (opaque operations included, for
1550
- `Client.url`), and `Meta`, `WireError`, `Outcome<T>`, `InvokeContext`,
1551
- `Client`, `Failure`, `HandlerContext`, `Handlers`. `Meta` and
1852
+ `Client.url`), `ByteOperations` (the opaque operations only, for
1853
+ `HttpClient.bytes`), and `Meta`, `WireError`, `Outcome<T>`,
1854
+ `InvokeContext`, `Client`, `ByteContext`, `ByteResponse`, `HttpClient`
1855
+ (`Client` plus `bytes` over `ByteOperations`, §10.6 — the
1856
+ binding-neutral `Client` never requires a byte method), `Failure`,
1857
+ `CarrierName`, `HandlerContextBase<Host>`, `HttpHandlerContext<Host>`,
1858
+ `ChannelHandlerContext<Host, Carrier>`, `HandlerContext<Host = null,
1859
+ Carrier = 'http'>` and `Handlers<Host = null, Carrier = 'http'>` — the
1860
+ handler context selected by carrier (§7.7): omitted generics are the
1861
+ HTTP context with `host: null`, exactly the shape it always was plus
1862
+ `carrier` and `host`; a port or local context spells the request-line
1863
+ members, the body, the key, `etag` and `status` as `null` rather than
1864
+ omitting them, so an HTTP-only member is a compile error there; a
1865
+ carrier union is a discriminated union to narrow on `ctx.carrier`. `Meta` and
1552
1866
  `WireError` spell **exactly** the fixed D6 shapes (§10.1) —
1553
1867
  `OUTCOME_META_MEMBERS`/`OUTCOME_ERROR_MEMBERS` are the runtime twins and
1554
1868
  a test holds the text to them; `details` is `unknown` and `status`
1555
1869
  `number | null`, never optional members. An input-less operation's
1556
- `input` is `null`; an opaque operation appears only in `UrlOperations`.
1870
+ `input` is `null`; an opaque operation appears in `UrlOperations` and
1871
+ `ByteOperations`, never in `Operations`; a contract without one still
1872
+ declares an empty `ByteOperations`, so `bytes` is uncallable rather
1873
+ than absent.
1874
+
1875
+ One convention rides on top of emit's reading, and it is the suite's:
1876
+ a string with `format: "date-time"` or `format: "date"` is declared as
1877
+ `DateTime` — `string & { __jarenTag: 'date-time' }` — rendered once per
1878
+ document and referenced from every position, an array item as much as a
1879
+ member. Emit itself records a format only as a dropped constraint; the
1880
+ brand is applied by this projection so a consumer's generated types
1881
+ agree with `@jarenjs/db`'s entity types (`entityEmitModel`) and with
1882
+ `@jarenjs/linq`'s schema and contract pens, which read a date format the
1883
+ same way. A contract that already declares a `$defs` entry named
1884
+ `DateTime` keeps it; the brand takes the next free name.
1557
1885
 
1558
1886
  ### §12.4 Markdown
1559
1887
 
@@ -1754,14 +2082,19 @@ no `negotiate`, no `pending`.
1754
2082
  refusal is the pre-send `JC2050` outcome (kind `contract`, details
1755
2083
  by `policy.errors.details`) and nothing ran — the same refusal every
1756
2084
  client binding shares;
1757
- 3. the handler runs through the neutral pipeline with the frozen
1758
- context `{ op, trace, signal, params: null, headers: {}, fail,
1759
- idempotency: null }` `trace` from the `trace` option (default
1760
- `crypto.randomUUID`), `signal` the caller's composed with the
1761
- client's closer. There is no `ctx.etag`, `ctx.status` or `ctx.body`:
1762
- statuses, entity tags and bytes do not exist here, and a handler
1763
- that reaches for them fails honestly (`JC2070`) instead of
1764
- pretending;
2085
+ 3. the host lifecycle's `identify` runs (§7.7, `carrier: "local"`,
2086
+ the request-line members `null`), then the validation, then
2087
+ `acquire`, and the handler runs inside `enter` through the neutral
2088
+ pipeline with the frozen context `{ op, trace, carrier: "local",
2089
+ host, signal, method: null, path: null, params: null, headers: {},
2090
+ body: null, fail, idempotency: null, etag: null, status: null }`
2091
+ `trace` from the `trace` option (default `crypto.randomUUID`),
2092
+ `signal` the caller's composed with the client's closer. `etag`,
2093
+ `status` and `body` are `null`, never callable: statuses, entity tags
2094
+ and bytes do not exist here, and a handler that reaches for them
2095
+ fails honestly (`JC2070`) instead of pretending; a hook fault is
2096
+ `JC2070` too, a hook's declared failure is a `failure` outcome, and
2097
+ the releases run before the outcome is exposed;
1765
2098
  4. the outcome (§10.1 shapes, assembled by the same assembler):
1766
2099
 
1767
2100
  | settlement | outcome |
@@ -1771,7 +2104,7 @@ no `negotiate`, no `pending`.
1771
2104
  | 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 |
1772
2105
  | `ctx.signal` aborted before or while running, or the client closed | kind `cancelled` `JC2052`; a handler that settles later settles into nothing |
1773
2106
 
1774
- Options: `trace`, `validateOutput` (`'never'` is a declared downgrade,
2107
+ Options: `trace`, `runtime` (the record `trace` defaults from), `validateOutput` (`'never'` is a declared downgrade,
1775
2108
  reported in `capabilities.validatedOutput`; the output is validated
1776
2109
  ONCE, in the pipeline — the assembler does not re-validate what never
1777
2110
  crossed a wire), `catalog`, `onError`. Capabilities:
@@ -1806,9 +2139,20 @@ The local codes (the table shared with §16; `PORT_LOCAL_ERRORS` in
1806
2139
 
1807
2140
  ## §16 The port binding
1808
2141
 
2142
+ A request and a subscription on this carrier run the host lifecycle
2143
+ of §7.7 with `carrier: "port"`: `identify` after the operation
2144
+ resolved and before the input is validated, `acquire` after it, the
2145
+ handler inside `enter` with the frozen context `{ op, trace, carrier:
2146
+ "port", host, signal, method: null, path: null, params: null,
2147
+ headers: {}, body: null, fail, idempotency: null, etag: null, status:
2148
+ null }`. A hook fault is `JC2070`, a hook's declared failure the
2149
+ declared error frame; the releases run after the response frame was
2150
+ posted, and for a subscription after the runner's stop/close/done
2151
+ sequence.
2152
+
1809
2153
  `servePort(contract, handlers, { channel, trace?, validateOutput?,
1810
- catalog?, onError? })` and `openPortClient(contract, { channel,
1811
- timeoutMs = 15000, catalog? })` — request/response over anything with
2154
+ catalog?, onError?, runtime? })` and `openPortClient(contract, { channel,
2155
+ timeoutMs = 15000, catalog?, runtime? })` — request/response over anything with
1812
2156
  `postMessage` and a message-listener surface: a `MessagePort` (started
1813
2157
  automatically), a `Worker`, a `BroadcastChannel`, a worker's own
1814
2158
  `self`, or a plain object of that shape. The server prepares the same
@@ -1839,7 +2183,10 @@ Frames are JSON-safe plain objects marked `jaren: "contract/0.1"`:
1839
2183
  ```
1840
2184
 
1841
2185
  **Id scoping is the correctness rule.** `id` is `"<clientId>:<seq>"` —
1842
- `clientId` a UUID per client instance, `seq` a per-client counter — so
2186
+ `clientId` a fresh identifier per client instance from the runtime
2187
+ record's `uuid` (a v4 UUID with no record; a deterministic record MUST
2188
+ still answer a distinct value per client, or two clients on one channel
2189
+ take each other's frames), `seq` a per-client counter — so
1843
2190
  two clients on one shared channel can never collide, and a client
1844
2191
  ignores every frame whose id does not start with its own `clientId +
1845
2192
  ":"` (one cheap prefix test before any map lookup). A late response
@@ -1933,11 +2280,26 @@ Subscription = {
1933
2280
  result | snapshot(), // the current snapshot document; snapshot() preferred when both exist
1934
2281
  subscribe(cb) → stop, // cb receives LIVE-FORMAT emissions { patch, seq } or { error }
1935
2282
  close(), // release the registration
1936
- replay?(seq), // optional: the emissions after seq, or null/undefined when it cannot
2283
+ replay?(after, { limit, maxBytes, signal }), // optional: ONE page of the emissions after `after`
2284
+ // → { items, next?, earliestAvailable, highWatermark, hasMore, resetRequired }
1937
2285
  mode?, // ignored by the binding
1938
2286
  }
1939
2287
  ```
1940
2288
 
2289
+ `replay` answers a **page**, never an array — the exact shape
2290
+ `@jarenjs/db`'s `changes.page()` answers (LIVE-FORMAT §5), so a store's
2291
+ bounded change reader is a replay source as returned: `items` are
2292
+ `{ patch, seq }` emissions in ascending seq above `after`, at most
2293
+ `limit` of them and at most `maxBytes` serialized patch bytes; `next`
2294
+ is the seq to continue from; `earliestAvailable`/`highWatermark` are
2295
+ the log's watermarks; `hasMore` says records remain; `resetRequired:
2296
+ true` is the total refusal — `items` empty, `next` absent — for a
2297
+ cursor that fell behind the log's retention. Every member is read
2298
+ defensively: a page that breaks the shape or its bounds is a host fault
2299
+ (`JC2008` / `JC2070`) that ends the stream, and a `replay` that answers
2300
+ an array is that fault too (an array would materialize a history the
2301
+ bounds exist to keep out).
2302
+
1941
2303
  — a `@jarenjs/db` `live()` object satisfies it **as returned** (`result`
1942
2304
  + `subscribe` + `close`; it has no `replay`), so a handler is one line:
1943
2305
  `(input) => store.collection('x').live(doc, { externals: input })`. No
@@ -1956,7 +2318,50 @@ the server broke the contract; the cause goes to `onError`, never the
1956
2318
  wire), forwards each emission verbatim (the binding never mutates a
1957
2319
  patch), and calls `stop()` then `close()` **exactly once** — on peer
1958
2320
  disconnect (`ctx.signal`), on an `unsubscribe`/stream cancel, on server
1959
- close, and after an `error` emission ends the stream.
2321
+ close, and after an `error` emission ends the stream. Both may answer
2322
+ a promise: the binding awaits `stop()`, then `close()`, then releases
2323
+ the carrier, and reports completion only after all three settled. The
2324
+ carrier's writes are serialized — one event at a time, the next only
2325
+ after the previous write settled (`createAwaitedSink`,
2326
+ `@jarenjs/core/async`) — so an emission that arrives while the carrier
2327
+ is waiting on the socket is queued in order, never overlapped and never
2328
+ dropped; a carrier write that fails (the peer dropped the socket, the
2329
+ consumer cancelled the stream) releases the subscription silently. The
2330
+ queue is **bounded** (§18.1): what it holds is charged until each write
2331
+ settled, and the emission that would cross the bound ends the stream
2332
+ with `JC2096` instead of growing memory or dropping an event.
2333
+
2334
+ An `{ error }` emission ends the stream, and the binding classifies it
2335
+ once, carrier-neutrally. When the error's `code` — read guardedly — is
2336
+ a string the operation **declares**, the stream ends with that declared
2337
+ failure: the `error` event carries the code, the operation's declared
2338
+ message rendered from the host catalog (never the error's own text),
2339
+ the error's `details` when they are JSON-safe, and `retryable` — the
2340
+ error's own boolean, else whether `policy.retry.on` names the code —
2341
+ and the client delivers a `failure` outcome under that code. Any other
2342
+ error — an undeclared code, a code that is not a string, a hostile
2343
+ accessor — is the host fault (`JC2008` / `JC2070`): the cause goes to
2344
+ `onError`, the wire carries the generic message, and the client reports
2345
+ `JC2093`. Declared codes are lowercase by grammar (`JC0011`), so a
2346
+ store's own coded error — `@jarenjs/db`'s `JD2060` when a live query
2347
+ crosses `live.maxMaintained` — is declared by **mapping** it in the
2348
+ handler, the cause riding along for the observer:
2349
+
2350
+ ```js
2351
+ // errors: { overflow: { status: 507 } } declared on the operation
2352
+ 'data.live': async (input) => {
2353
+ const live = await store.collection(input.collection).live(query);
2354
+ return {
2355
+ get result() { return live.result; },
2356
+ subscribe: (cb) => live.subscribe((e) => cb(
2357
+ 'error' in e && e.error?.code === 'JD2060' ? { error: { code: 'overflow', cause: e.error } } : e)),
2358
+ close: () => live.close(),
2359
+ };
2360
+ },
2361
+ ```
2362
+
2363
+ Returned as is, the same error reaches the client as `JC2093` and the
2364
+ server's `onError` as the `DbRuntimeError` it is.
1960
2365
 
1961
2366
  ## §18 The stream wire
1962
2367
 
@@ -1973,11 +2378,15 @@ Response: `200`, `content-type: text/event-stream`, `cache-control:
1973
2378
  no-store`, `x-jaren-trace`. Events, in order:
1974
2379
 
1975
2380
  - `snapshot` — `id: <seq>` (the stream's starting seq; `0` for a source
1976
- that names none), data `{ "value": <snapshot>, "resumed": false }`.
2381
+ that names none), data `{ "value": <snapshot>, "resumed": false,
2382
+ "reset": false, "earliestAvailable": null, "highWatermark": null }`.
1977
2383
  The envelope exists because a resume verdict cannot ride *inside* the
1978
2384
  snapshot value without breaking a closed output schema; `resumed:
1979
2385
  false` states this snapshot is a fresh document (a refused resume —
1980
2386
  `JC2095` — looks exactly like this, which is how the client learns).
2387
+ The shape is stable: `reset: true` marks the snapshot that re-seeds a
2388
+ consumer whose cursor fell behind the server's retention (below), and
2389
+ the two watermarks are the replay source's when it reported them.
1981
2390
  - `patch` — `id: <seq>`, data `{ "patch": [...], "seq": n }`: the
1982
2391
  LIVE-FORMAT emission verbatim.
1983
2392
  - heartbeat comment lines (`:`) every `policy.stream.heartbeatMs`.
@@ -1992,20 +2401,50 @@ at that emission's seq — the consumer swaps its document instead of
1992
2401
  patching it; nothing is dropped.
1993
2402
 
1994
2403
  **Resumption.** A request carrying `Last-Event-ID: <seq>` asks to
1995
- resume. Under `resume: "replay"` the binding asks
1996
- `subscription.replay?.(seq)`; when the handler answers an array of
1997
- emissions, the stream starts with the `patch` events after that seq
1998
- (no snapshot) and continues live. Otherwise — `resume: "snapshot"`, no
1999
- `replay`, or a `replay` that answers `null` the stream starts with a
2000
- fresh `snapshot` whose data carries `resumed: false` (`JC2095`,
2404
+ resume. Under `resume: "replay"` with a `replay` on the subscription
2405
+ the binding **pages**: it subscribes live first, then calls
2406
+ `replay(seq, { limit, maxBytes, signal })` and delivers each page's
2407
+ items as `patch` events (no snapshot), continuing from `next` until the
2408
+ **first page's** `highWatermark` is reached or the log has no more —
2409
+ a watermark that keeps rising on later pages cannot make replay chase a
2410
+ busy writer forever — and then continues live, discarding the
2411
+ emissions that arrived meanwhile whose seq the pages already covered.
2412
+ A page with `resetRequired: true` ends the replay without a suffix:
2413
+ the binding reads a fresh snapshot and emits it with `resumed: false`,
2414
+ `reset: true`, `earliestAvailable`, and `highWatermark` — where the
2415
+ event id and the `highWatermark` are the higher of the page's watermark
2416
+ and the highest live emission already buffered, because the snapshot
2417
+ just read reflects those emissions (§17.1's contract), and replaying
2418
+ one of them would apply a change twice. The consumer resumes from that
2419
+ id. Otherwise — `resume: "snapshot"`, or no `replay` — the stream starts
2420
+ with a fresh `snapshot` whose data carries `resumed: false` (`JC2095`,
2001
2421
  informational, never an outcome).
2002
2422
 
2003
- `toNodeHandler` writes SSE with `flushHeaders()` + `res.write` and ends
2004
- on close; `toFetchHandler` answers a `ReadableStream` body; both abort
2005
- `ctx.signal` when the peer goes away (`request.signal`, `req` close),
2006
- which runs the exactly-once `stop()`/`close()`. A dispatcher's
2007
- `close()` ends every live SSE stream with `end` (`server-shutdown`)
2008
- before releasing it.
2423
+ **Bounds.** `serveHttp`/`servePort` take `streamLimits: { replay: {
2424
+ limit, maxBytes }, queue: { events, bytes } }` (defaults 256 / 1 MiB
2425
+ for both): a replay page asks for at most `replay.limit` emissions and
2426
+ `replay.maxBytes` serialized patch bytes; the undelivered queue the
2427
+ events that arrived while a carrier write was pending or a page was
2428
+ loading, the SSE text or port frame as it will go on the wire — holds
2429
+ at most `queue.events` frames and `queue.bytes` bytes, each charged
2430
+ until its write settled. When the next event would cross either bound
2431
+ the stream ends with the terminal `error` event `JC2096` (`kind:
2432
+ network`, retryable — the consumer reads slower than the source
2433
+ emits), then the subscription is released and the carrier tears its
2434
+ sink down (the node adapter destroys the socket, the fetch bridge
2435
+ errors the stream) rather than wait for a consumer that stopped
2436
+ reading; nothing is dropped silently, oldest or newest.
2437
+
2438
+ `toNodeHandler` writes SSE with `flushHeaders()` + `res.write`, waits
2439
+ for `drain` whenever `res.write()` answered `false` before the next
2440
+ event, and ends on close; `toFetchHandler` answers a `ReadableStream`
2441
+ body that produces on the consumer's `pull`; both abort `ctx.signal`
2442
+ when the peer goes away (`request.signal`, `req` close), which runs the
2443
+ exactly-once `stop()`/`close()`, and both tear the connection down
2444
+ after a `JC2096`. A heartbeat is never queued behind a
2445
+ heartbeat: while one waits on the sink, the interval skips. A
2446
+ dispatcher's `close()` ends every live SSE stream with `end`
2447
+ (`server-shutdown`) before releasing it (§9 has the adapter contract).
2009
2448
 
2010
2449
  ### §18.2 Port: push frames
2011
2450
 
@@ -2046,8 +2485,10 @@ other range:
2046
2485
  | `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 |
2047
2486
  | `JC2094` | network | `contract/heartbeat-missed` | yes | no bytes for `2 × heartbeatMs` (client-side, SSE only) |
2048
2487
  | `JC2095` | — | — | — | a requested resume was refused; informational, carried as `resumed: false` in the fresh snapshot's event data, never an outcome |
2488
+ | `JC2096` | network | `contract/slow-consumer` | yes | the stream's bounded queue would overflow — the consumer reads slower than the source emits; sent as the terminal `error` event, then the carrier tears the connection down |
2489
+ | `JC2097` | network | `contract/reconnect-exhausted` | no | the HTTP client's reconnect budget is spent: every further attempt after a network loss ended in another loss (client-side); `details` is `{ attempts, lastCode }` — the further attempts made and the last loss's code |
2049
2490
 
2050
- `JC2096–JC2109` are reserved for later stream codes. `JC1009` (an SSE
2491
+ `JC2098–JC2109` are reserved for later stream codes. `JC1009` (an SSE
2051
2492
  data string the frame cannot carry) and `JC1010` (`subscribe` of a
2052
2493
  non-subscribe operation) are the stream's host programming errors
2053
2494
  (§7.3's host table).
@@ -2055,32 +2496,63 @@ non-subscribe operation) are the stream's host programming errors
2055
2496
  ## §19 The client: `subscribe`
2056
2497
 
2057
2498
  `client.subscribe(op, input, { onSnapshot, onPatch, onError, onEnd,
2058
- signal, lastSeq }) → { stop() }` — on the `http` and `port` clients
2059
- alike (`capabilities.stream: true`); `serveLocal` keeps
2499
+ signal, lastSeq, reconnect }) → { stop(), lastSeq }` — on the `http`
2500
+ and `port` clients alike (`capabilities.stream: true`); `serveLocal` keeps
2060
2501
  `capabilities.stream: false` and needs no handler for a subscribe
2061
2502
  operation (`invoke` of one throws `JC1005` there). A non-subscribe
2062
2503
  operation is `JC1010`, thrown — the host named the wrong operation.
2063
2504
 
2064
- - `onSnapshot(value, { seq, resumed })` — a fresh, validated snapshot;
2065
- the consumer replaces its document. `resumed` is `false` exactly as
2066
- §18.1 defines it.
2505
+ - `onSnapshot(value, { seq, resumed, reset, earliestAvailable,
2506
+ highWatermark })` a fresh, validated snapshot; the consumer
2507
+ replaces its document. `resumed` is `false` exactly as §18.1 defines
2508
+ it; `reset: true` marks the re-seed after a retention gap — its `seq`
2509
+ is the cursor to resume from — and the watermarks are the server
2510
+ log's when it reported them, `null` otherwise. The shape is the same
2511
+ for every snapshot.
2067
2512
  - `onPatch({ patch, seq })` — the LIVE emission, **not applied**: the
2068
2513
  client forwards patches; the app binding (§11.4) and the consumer
2069
2514
  apply them (`@jarenjs/json/patch`). `seq` is strictly increasing or
2070
2515
  the stream ends with `JC2092`.
2071
2516
  - `onError(outcome)` — a D6 `ok: false` outcome (`failure` for a
2072
- declared error event; `network` for a transport failure or a missed
2073
- heartbeat; `contract` for `JC2090`/`JC2092`/`JC2093`, an invalid
2074
- snapshot value, or a pre-send input refusal `JC2050`). Its `error`
2075
- and `meta` carry every member (`status: null` where the wire has
2076
- none). After `onError` the stream is finished and cleaned up.
2517
+ declared error event; `network` for a transport failure, a missed
2518
+ heartbeat, the server's `JC2096` the consumer fell behind the
2519
+ stream's bounded queue or `JC2097`, a spent reconnect budget;
2520
+ `contract` for `JC2090`/`JC2092`/`JC2093`, an invalid snapshot value,
2521
+ or a pre-send input refusal `JC2050`). Its `error` and `meta` carry
2522
+ every member (`status: null` where the wire has none). After
2523
+ `onError` the stream is finished and cleaned up.
2077
2524
  - `onEnd({ reason })` — the server's `end` event; a stream that ends
2078
2525
  without one is reported as `reason: "closed"`.
2079
2526
 
2080
2527
  Every callback is optional and total for the client: a callback that
2081
2528
  throws does not break the stream machinery. `signal` aborts the
2082
2529
  subscription silently (the caller asked); `stop()` does the same and,
2083
- on `port`, posts the `unsubscribe` frame. `lastSeq` is what a
2084
- reconnect passes (§18's resumption). **Reconnection is not automatic**:
2085
- the host decides a `subscribe` that ends with a `network` outcome is
2086
- re-entered by calling `subscribe` again with the last delivered seq.
2530
+ on `port`, posts the `unsubscribe` frame. The subscription's read-only
2531
+ `lastSeq` is the last delivered seq `null` before the first event,
2532
+ the passed `lastSeq` until an event moves it and the `lastSeq` option
2533
+ is what a re-entered `subscribe` passes (§18's resumption).
2534
+
2535
+ **Reconnection is opt-in and HTTP-only.** `reconnect: { max }` — a
2536
+ non-negative integer of *further* attempts, `0` when absent — makes the
2537
+ HTTP client re-establish the stream after a **network loss**: a
2538
+ rejected request (`JC2051`), a missed heartbeat (`JC2094`), the
2539
+ server's `JC2096`, or a body that ends before an `end` event. Each
2540
+ further attempt waits the retry backoff of §10.4 (`min(1000 · 2^n,
2541
+ 8000)` ms plus up to 250 ms of the runtime's jitter, `n` counting from
2542
+ 0) and sends the last delivered seq as `Last-Event-ID` — the seq a
2543
+ reset snapshot advanced included — with no callback for the loss in
2544
+ between; every attempt is a fresh request with a consumer, reader and
2545
+ watchdog of its own, so a stale attempt's late bytes and settlements
2546
+ reach no callback and close no newer reader. A declared failure, a
2547
+ contract outcome, the server's `end`, `stop()` and the signal are
2548
+ terminal on every setting. When the budget is spent the subscription
2549
+ ends with one `onError` — `JC2097` (`kind: network`, not retryable),
2550
+ its `details` `{ attempts, lastCode }` naming the further attempts
2551
+ made and the last loss's code — after the reader, the watchdog and the
2552
+ backoff were released. Without `reconnect` (or with `max: 0`) a
2553
+ `network` outcome is delivered as is and re-entering is the host's;
2554
+ a body that ends without an `end` event is then `onEnd({ reason:
2555
+ "closed" })`, not a loss. The port client validates the option exactly
2556
+ as the HTTP client does and then does nothing with it: a channel has no
2557
+ network loss to reconnect from (a closed channel is `JC2074`, final),
2558
+ and one options object serves both clients.