@jarenjs/contract 0.56.0 → 0.67.0

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 (48) hide show
  1. package/README.md +139 -25
  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/runtime.d.ts +25 -0
  20. package/dist/types/stream/client.d.ts +14 -3
  21. package/dist/types/stream/server.d.ts +218 -44
  22. package/dist/types/stream/sse.d.ts +10 -0
  23. package/docs/APP-INTEGRATION.md +4 -2
  24. package/docs/CONTRACT-FORMAT.md +580 -128
  25. package/package.json +5 -5
  26. package/src/adapters/fetch.js +144 -25
  27. package/src/adapters/node.js +246 -82
  28. package/src/cli.js +22 -16
  29. package/src/client/http.js +588 -189
  30. package/src/compat.js +1 -1
  31. package/src/errors.js +3 -0
  32. package/src/host.js +319 -0
  33. package/src/http/body.js +337 -0
  34. package/src/http/dispatch.js +511 -75
  35. package/src/http/serve.js +39 -5
  36. package/src/http/wire.js +33 -14
  37. package/src/ledger.js +119 -36
  38. package/src/local/index.js +91 -35
  39. package/src/messages.js +2 -0
  40. package/src/path.js +9 -3
  41. package/src/pipeline.js +18 -1
  42. package/src/port/client.js +39 -6
  43. package/src/port/serve.js +207 -69
  44. package/src/project/typescript.jtlt.json +39 -7
  45. package/src/runtime.js +36 -0
  46. package/src/stream/client.js +40 -6
  47. package/src/stream/server.js +573 -138
  48. package/src/stream/sse.js +2 -0
@@ -299,7 +299,8 @@ and **canonicalized to `{name}`**, which is what `describe()` and every
299
299
  projection show — with `name` matching `[A-Za-z_][A-Za-z0-9_]*` and
300
300
  declared once per template. A static segment is any run of characters
301
301
  except `/ { } : * ? #`, whitespace and control characters; a `%` in it
302
- 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).
303
304
 
304
305
  Reserved and refused by name (`JC0008` says which): the RFC 6570 operator
305
306
  forms `{+name}` `{#name}` `{.name}` `{/name}` `{;name}` `{?name}` `{&name}`
@@ -322,15 +323,22 @@ The **shape** of a binding is its method plus its template with every
322
323
  variable normalized to `{}`: `GET /api/products/{}`. Two operations MUST
323
324
  NOT share a shape (`JC0010` at the second one, in document order); the
324
325
  canonical binding takes part (`POST /<id>` may collide with a declared
325
- `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}`.
326
330
 
327
331
  ### §4.5 Opaque operations
328
332
 
329
333
  A `media` other than `application/json` (or a `+json` structured-syntax
330
334
  suffix, parameters ignored) marks the operation **opaque**: it is routed
331
335
  and matched, its path/query still decoded, its body neither decoded nor
332
- validated by the contract, and it is excluded from generated clients
333
- 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
334
342
  bytes the contract never decodes, an opaque operation MUST NOT declare a
335
343
  **body-located member** — neither through `http.body`, nor `http.in`,
336
344
  nor the `command` default (`JC0017` at the member that placed it there,
@@ -498,10 +506,12 @@ is the operation's output. `ctx` is frozen per request:
498
506
  |---|---|
499
507
  | `op` | the compiled operation |
500
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 |
501
511
  | `method`, `path` | the request line, path without the query |
502
512
  | `params` | the raw decoded path strings, frozen |
503
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 |
504
- | `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` |
505
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` |
506
516
  | `idempotency` | `{ key, scope }` when this request runs under an idempotency key, else `null` |
507
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` |
@@ -511,6 +521,26 @@ is the operation's output. `ctx` is frozen per request:
511
521
  An **opaque** operation (`http.opaque`) takes a *raw* handler: `(input,
512
522
  ctx) => { status, headers?, body? }` with the bytes in `ctx.body`; it
513
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.
514
544
  Its transport members are decoded and normalized into `input` and
515
545
  validated like any other input (`JC2006`) — they **are** its whole
516
546
  input, since an opaque operation cannot declare a body-located member
@@ -528,8 +558,10 @@ is `JC2008`.
528
558
  ### §7.2 The pipeline, in order
529
559
 
530
560
  1. **The request object.** `method`/`url` strings, `headers` an object,
531
- `body` a string, `Uint8Array` or `null` (an absent body is `null`) — a
532
- 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.
533
565
  2. **Route.** `url` is split at the first `?`; the path goes to
534
566
  `contract.match(method, path)`; under `HEAD` with `head` on, `HEAD`
535
567
  is tried, then `GET`. No match: an undecodable path (a malformed
@@ -538,10 +570,21 @@ is `JC2008`.
538
570
  (with `HEAD` added beside `GET` when `head` is on) is `JC2002` with
539
571
  `Allow`; else `JC2001`. A matched operation without a handler (a
540
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.
541
579
  3. **The body limit.** A `content-length` above `policy.limits.maxBodyBytes`
542
580
  is `JC2003` **before** any read (the adapters honor this too, §9); a
543
- body whose byte length exceeds the limit is `JC2003` after. Applies to
544
- 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.
545
588
  4. **Opaque** → the transport input validated as in step 8 when no
546
589
  member is body-located (`JC2006`), then the raw handler through the
547
590
  same boundary as step 11; done.
@@ -552,9 +595,10 @@ is `JC2008`.
552
595
  accepted for `application/json`); else `JC2004`. A body-less
553
596
  operation with a body **ignores** the body. An empty body needs no
554
597
  media.
555
- 6. **Parse.** Bytes are decoded as strict UTF-8 first (invalid `JC2005`;
556
- a leading BOM is stripped by the decoder); then `JSON.parse` (a failure
557
- 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`).
558
602
  7. **Assemble** the input object through a prototype-safe setter only, in
559
603
  this order: path members (raw decoded strings), query members
560
604
  (`URLSearchParams` semantics — `+` is a space; a member listed in
@@ -579,7 +623,13 @@ is `JC2008`.
579
623
  (its `policy.idempotency` is `none` by construction).
580
624
  9. **Idempotency** when `policy.idempotency !== "none"` (§8): a missing
581
625
  `Idempotency-Key` is `JC2007` under `required` and runs plainly under
582
- `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`.
583
633
  10. **Preconditions, opt-in** (§7.5): when the operation has a
584
634
  `preconditions` resolver, the CURRENT tag is resolved BEFORE the
585
635
  handler — a command consults it only under a conditional header, a
@@ -621,6 +671,19 @@ is `JC2008`.
621
671
  charset=utf-8` (when a body), `x-jaren-trace`, `etag` when armed;
622
672
  a `204` carries no body; a HEAD carries the `content-length` of the
623
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).
624
687
 
625
688
  Every step's failure path returns a response. `dispatch` never rejects
626
689
  for request content; a defect of the binding itself is caught last and
@@ -709,6 +772,7 @@ wire response:
709
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) |
710
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) |
711
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 |
712
776
 
713
777
  ### §7.4 Headers
714
778
 
@@ -777,15 +841,135 @@ running the handler and dropping the body (a declared `HEAD` operation
777
841
  wins); with `head: false` a HEAD is a 405 listing `GET`. The `wellKnown`
778
842
  path (`/.well-known/jaren-contract`, or another absolute path, or `false`)
779
843
  answers `describe()` — `revision: null` until the revision lands, `compat`
780
- present — for negotiation. `trace` (default `crypto.randomUUID`) generates
781
- 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);
782
846
  `partial` allows missing handlers; `validateOutput` is `"always" |
783
847
  "never"`; `preconditions` maps operation ids to pre-handler tag
784
848
  resolvers (§7.5); `errorBody(wire, ctx)` and `onError(err, ctx)` are the
785
849
  two host hooks (`ctx` is `null` before an operation is matched);
786
850
  `catalog` is a message catalog (templates or compiled renderers)
787
851
  consulted before the English one; `now` is the clock stamped into ledger
788
- 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.
789
973
 
790
974
  ## §8 Idempotency and the ledger
791
975
 
@@ -805,11 +989,22 @@ is the same request. The binding then calls the ledger:
805
989
  ```jsonc
806
990
  // the Ledger interface — every method may return its value or a promise of it
807
991
  { "claim": "({ op, scope, key, hash, now }) → { state: 'new', ref } | { state: 'replay', response } | { state: 'in-progress' } | { state: 'mismatch' }",
808
- "commit": "(ref, response) → void",
809
- "fail": "(ref, retryable, response?) → void",
810
- "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" }
811
995
  ```
812
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
+
813
1008
  Semantics the binding relies on: same key + same hash → `replay` — the
814
1009
  stored `{ status, headers, body }` **verbatim** with a fresh
815
1010
  `x-jaren-trace` and `idempotent-replayed: true`; same key + different
@@ -824,19 +1019,29 @@ declared failure is recorded as **failed** with its response and its
824
1019
  key as retryable with no response; a POST-handler `JC2014` (the handler
825
1020
  already ran and may have mutated) is recorded as **failed**, not
826
1021
  retryable, with its 412 — a blind retry under the same key replays the
827
- 412 instead of running the handler again. `now` on a claim is the binding's
828
- 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
829
1029
  operations bypass the ledger; reads never carry a key. A ledger that
830
1030
  throws or rejects is reported to `onError` and the response still goes
831
1031
  out (a throwing `claim` is `JC2008`).
832
1032
 
833
- `createMemoryLedger({ ttlMs = 86_400_000, now })` (`@jarenjs/contract/ledger`)
1033
+ `createMemoryLedger({ ttlMs = 86_400_000, now, runtime })` (`@jarenjs/contract/ledger`)
834
1034
  is the reference implementation over a `Map`: synchronous,
835
- single-process, expiring on `claim` and `lookup`, with `sweep()` for a
836
- 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:
837
1041
 
838
1042
  ```jsonc
839
- { "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
840
1045
  "op": "product.save", "scope": "tenant-a", "key": "k-1",
841
1046
  "hash": "9f2a…", // 64 lowercase hex characters
842
1047
  "status": "committed", // started | committed | failed
@@ -845,12 +1050,26 @@ host timer and `size`. The record it keeps is:
845
1050
  "createdAt": 1755000000000, "updatedAt": 1755000000000, "expiresAt": 1755086400000 }
846
1051
  ```
847
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
+
848
1063
  Two documents ship the same shape as **data**, for a host that wants
849
1064
  durability (this package imports neither `@jarenjs/db` nor
850
1065
  `@jarenjs/flow`): `idempotencyLedgerModel` is a `$model` 0.1 document —
851
- collection `ledger`, key `/id`, that record as its schema (closed),
852
- indexes on `expiresAt` and `status` — a host opens it with `openStore`
853
- 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`
854
1073
  is a `$fsm` 0.1 document — `idle → started` on `claim`, `started →
855
1074
  committed` on `commit`, `started → failed` on `fail`, `failed → started`
856
1075
  on `claim` guarded by `$.context.retryable` — which the memory ledger
@@ -861,82 +1080,88 @@ walks exactly.
861
1080
  A host that retries commands needs a ledger that survives a restart, and
862
1081
  it does NOT need `@jarenjs/db` for that: `node:sqlite` is built into
863
1082
  Node ≥ 24 — the suite's floor — so the ~60 lines below are as
864
- dependency-free as the package. Two design points carry the semantics:
865
- `BEGIN IMMEDIATE` makes each `claim` one writer (two processes cannot
866
- both claim a key), and the ref is the `AUTOINCREMENT` sequence of one
867
- specific insert never reused, where a bare SQLite rowid would be — so
868
- a stale ref can never settle over a record a later claim re-created
869
- (the memory ledger's object-identity guard, spelled in SQL). Everything
870
- else mirrors `createMemoryLedger` exactly: expiry on `claim` and
871
- `lookup`, `sweep()` for a host timer, mismatch before status, a
872
- retryable failure handing the key back, a non-retryable one replaying
873
- 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
874
1095
  deduplicates DELIVERY — the domain's own durable records stay
875
1096
  authoritative for business state.
876
1097
 
877
1098
  ```js
878
1099
  import { DatabaseSync } from 'node:sqlite';
1100
+ import { ledgerId } from '@jarenjs/contract/ledger';
879
1101
 
880
1102
  /** A durable Ledger over one SQLite file — a host's example, not an export. */
881
1103
  export function createSqliteLedger(path, { ttlMs = 86_400_000, now: clock = Date.now } = {}) {
882
1104
  const db = new DatabaseSync(path);
883
1105
  db.exec(`
884
1106
  CREATE TABLE IF NOT EXISTS ledger (
885
- seq INTEGER PRIMARY KEY AUTOINCREMENT,
886
- 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,
887
1108
  hash TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('started', 'committed', 'failed')),
888
1109
  response TEXT, retryable INTEGER,
889
1110
  createdAt INTEGER NOT NULL, updatedAt INTEGER NOT NULL, expiresAt INTEGER NOT NULL);
890
1111
  CREATE INDEX IF NOT EXISTS ledger_by_expires ON ledger (expiresAt);
891
1112
  CREATE INDEX IF NOT EXISTS ledger_by_status ON ledger (status);`);
892
1113
  const one = db.prepare('SELECT * FROM ledger WHERE id = ?');
893
- 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', ?, ?, ?)");
894
1115
  const drop = db.prepare('DELETE FROM ledger WHERE id = ?');
895
- 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'");
896
1119
  const reap = db.prepare('DELETE FROM ledger WHERE expiresAt <= ?');
897
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
+ };
898
1125
  return {
899
1126
  claim({ op, scope, key, hash, now }) {
900
- const at = typeof now === 'number' ? now : clock();
901
- const id = `${op}|${scope}|${key}`;
1127
+ const id = ledgerId(op, scope, key);
902
1128
  db.exec('BEGIN IMMEDIATE'); // one writer: two processes cannot both claim the key
903
1129
  try {
904
- const row = one.get(id);
1130
+ const row = (one.get(id));
905
1131
  if (row !== undefined) {
906
- if (row.expiresAt <= at) drop.run(id);
1132
+ if (row.expiresAt <= at(now)) drop.run(id);
907
1133
  else if (row.hash !== hash) { db.exec('COMMIT'); return { state: 'mismatch' }; }
908
1134
  else if (row.status === 'started') { db.exec('COMMIT'); return { state: 'in-progress' }; }
909
1135
  else if (row.status === 'committed') { db.exec('COMMIT'); return { state: 'replay', response: stored(row) }; }
910
1136
  else if (row.retryable !== 1 && row.response !== null) { db.exec('COMMIT'); return { state: 'replay', response: stored(row) }; }
911
1137
  else drop.run(id); // a retryable failure: the key runs again
912
1138
  }
913
- 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);
914
1141
  db.exec('COMMIT');
915
- return { state: 'new', ref };
1142
+ return { state: 'new', ref: { id, generation } };
916
1143
  }
917
1144
  catch (err) {
918
1145
  db.exec('ROLLBACK');
919
1146
  throw err;
920
1147
  }
921
1148
  },
922
- commit(ref, response) {
923
- 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);
924
1151
  },
925
- fail(ref, retryable, response) {
926
- 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);
927
1154
  },
928
- lookup({ op, scope, key }) {
929
- const row = one.get(`${op}|${scope}|${key}`);
1155
+ lookup({ op, scope, key, now }) {
1156
+ const row = (one.get(ledgerId(op, scope, key)));
930
1157
  if (row === undefined) return null;
931
- if (row.expiresAt <= clock()) {
1158
+ if (row.expiresAt <= at(now)) {
932
1159
  drop.run(row.id);
933
1160
  return null;
934
1161
  }
935
- const record = { ...row, response: stored(row), retryable: row.retryable === null ? null : row.retryable === 1 };
936
- delete record.seq; // the record shape is exactly LedgerRecord (§8)
937
- return record;
1162
+ return { ...row, response: stored(row), retryable: row.retryable === null ? null : row.retryable === 1 }; // exactly LedgerRecord (§8)
938
1163
  },
939
- sweep: () => Number(reap.run(clock()).changes),
1164
+ sweep: (now) => Number(reap.run(at(now)).changes),
940
1165
  close: () => db.close(),
941
1166
  };
942
1167
  }
@@ -962,26 +1187,53 @@ the platform:
962
1187
  - **`toFetchHandler(dispatcher)`** (`@jarenjs/contract/fetch`) →
963
1188
  `(Request) => Promise<Response>` — Bun.serve, Deno, service workers,
964
1189
  Cloudflare-style hosts, and Hono. It lowercases the headers into a
965
- plain object, matches the operation first (cheap) to decide how the
966
- body is read `text()` for a JSON operation, `arrayBuffer()` for an
967
- opaque one, and **not at all** for an unmatched request or a declared
968
- `content-length` above the operation's limit (the dispatcher answers
969
- the 413 from the header) forwards `request.signal`, and builds the
970
- `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.
971
1200
  - **`toNodeHandler(dispatcher)`** (`@jarenjs/contract/node`) → `(req,
972
- res)` — `http.createServer`'s listener and Express middleware. It
973
- collects the body chunk by chunk up to the operation's limit; on
974
- overflow it stops reading, answers the 413 with `connection: close`,
975
- then lingers draining and discarding the rest of the upload, bounded
976
- by a grace timer (`toNodeHandler(dispatcher, { lingerMs })`, default
977
- 1000 ms) before destroying the request, so the close is a FIN the
978
- client can read the 413 through rather than an RST that discards it
979
- (winsock drops buffered receive data on RST); a declared
980
- `content-length` above the limit is never read; an unmatched request's
981
- body is never read. Bytes reach the dispatcher as received (its strict
982
- UTF-8 decode decides `JC2005`); repeated header lines arrive as arrays
983
- (`headersDistinct`); `ctx.signal` aborts when the client goes away
984
- 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.
985
1237
 
986
1238
  Fastify, Hono and Express are recipes in the README, each ≤15 lines and
987
1239
  executed by a test that imports the framework from the benchmark
@@ -994,27 +1246,28 @@ streaming and peer abort behave identically through all three.
994
1246
 
995
1247
  `openHttpClient(contract, options)` (`@jarenjs/contract/client`) is the
996
1248
  client half of the http driver pair: `open(contract, options) → Client`
997
- with `Client = { invoke, url, negotiate, pending, capabilities, contract,
998
- 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
999
1251
  binding (§11) and later the AI tools read only `invoke`, `contract` and
1000
1252
  `capabilities` — and **total in behavior**: `invoke` resolves an
1001
1253
  **outcome** for everything a server or a network can do and rejects
1002
1254
  only for the host's own mistake (`JC1005`: an operation the contract
1003
1255
  does not declare, or an opaque one — `invoke` carries JSON; an opaque
1004
- operation is reached through `url`).
1256
+ operation is reached through `bytes` (§10.6) and `url`).
1005
1257
 
1006
1258
  ```jsonc
1007
1259
  // options — every one has a default
1008
1260
  { "fetch": "globalThis.fetch", // (url, init) => Promise<Response>; injectable (a toFetchHandler, a recorder)
1009
1261
  "baseUrl": "", // prefixed to every path; '' = relative
1010
1262
  "headers": {}, // static headers, merged UNDER per-call ones
1011
- "keys": "crypto.randomUUID", // the idempotency key generator
1263
+ "keys": "runtime.uuid", // the idempotency key generator (crypto.randomUUID with no record)
1012
1264
  "storage": null, // { read(), write(value) } — the @jarenjs/app docstore adapter shape — for durable key records
1013
1265
  "timeoutMs": 0, // per request; 0 = none; composed with ctx.signal
1014
1266
  "sleep": "(ms, signal) => Promise", // the retry backoff sleeper (injectable)
1015
1267
  "catalog": null, // a message catalog consulted before the English one
1016
1268
  "wellKnown": "/.well-known/jaren-contract", // where negotiate() asks
1017
- "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
1018
1271
  ```
1019
1272
 
1020
1273
  `capabilities` is `{ name: "http", status: true, headers: true, media:
@@ -1177,6 +1430,14 @@ is the server-side half). A store that throws on the pre-send write is
1177
1430
  `JC2054` and nothing is sent; a store that throws on the drop leaves
1178
1431
  the record (the conservative side) and the outcome is unaffected.
1179
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
+
1180
1441
  ### §10.4 Retry
1181
1442
 
1182
1443
  Only under a declared `policy.retry` (`{ max, on }`); never for an
@@ -1217,6 +1478,38 @@ itself (`null` when unreachable or not a contract). **Nothing else is
1217
1478
  inferred**: an unrelated service on the port is exactly `not-a-contract`;
1218
1479
  two unversioned contracts are `same-version`.
1219
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
+
1220
1513
  ## §11 The app binding
1221
1514
 
1222
1515
  `contractAppBinding(contract, { namespace = "contract/", statePath =
@@ -1556,13 +1849,28 @@ name), the reachable `$defs` once, then — fixed text in a JTLT
1556
1849
  stylesheet (`src/project/typescript.jtlt.json`) — `Operations` (the
1557
1850
  typed operation map: kind, input, output, the declared error codes as a
1558
1851
  literal union), `UrlOperations` (opaque operations included, for
1559
- `Client.url`), and `Meta`, `WireError`, `Outcome<T>`, `InvokeContext`,
1560
- `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
1561
1866
  `WireError` spell **exactly** the fixed D6 shapes (§10.1) —
1562
1867
  `OUTCOME_META_MEMBERS`/`OUTCOME_ERROR_MEMBERS` are the runtime twins and
1563
1868
  a test holds the text to them; `details` is `unknown` and `status`
1564
1869
  `number | null`, never optional members. An input-less operation's
1565
- `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.
1566
1874
 
1567
1875
  One convention rides on top of emit's reading, and it is the suite's:
1568
1876
  a string with `format: "date-time"` or `format: "date"` is declared as
@@ -1774,14 +2082,19 @@ no `negotiate`, no `pending`.
1774
2082
  refusal is the pre-send `JC2050` outcome (kind `contract`, details
1775
2083
  by `policy.errors.details`) and nothing ran — the same refusal every
1776
2084
  client binding shares;
1777
- 3. the handler runs through the neutral pipeline with the frozen
1778
- context `{ op, trace, signal, params: null, headers: {}, fail,
1779
- idempotency: null }` `trace` from the `trace` option (default
1780
- `crypto.randomUUID`), `signal` the caller's composed with the
1781
- client's closer. There is no `ctx.etag`, `ctx.status` or `ctx.body`:
1782
- statuses, entity tags and bytes do not exist here, and a handler
1783
- that reaches for them fails honestly (`JC2070`) instead of
1784
- 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;
1785
2098
  4. the outcome (§10.1 shapes, assembled by the same assembler):
1786
2099
 
1787
2100
  | settlement | outcome |
@@ -1791,7 +2104,7 @@ no `negotiate`, no `pending`.
1791
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 |
1792
2105
  | `ctx.signal` aborted before or while running, or the client closed | kind `cancelled` `JC2052`; a handler that settles later settles into nothing |
1793
2106
 
1794
- Options: `trace`, `validateOutput` (`'never'` is a declared downgrade,
2107
+ Options: `trace`, `runtime` (the record `trace` defaults from), `validateOutput` (`'never'` is a declared downgrade,
1795
2108
  reported in `capabilities.validatedOutput`; the output is validated
1796
2109
  ONCE, in the pipeline — the assembler does not re-validate what never
1797
2110
  crossed a wire), `catalog`, `onError`. Capabilities:
@@ -1826,9 +2139,20 @@ The local codes (the table shared with §16; `PORT_LOCAL_ERRORS` in
1826
2139
 
1827
2140
  ## §16 The port binding
1828
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
+
1829
2153
  `servePort(contract, handlers, { channel, trace?, validateOutput?,
1830
- catalog?, onError? })` and `openPortClient(contract, { channel,
1831
- 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
1832
2156
  `postMessage` and a message-listener surface: a `MessagePort` (started
1833
2157
  automatically), a `Worker`, a `BroadcastChannel`, a worker's own
1834
2158
  `self`, or a plain object of that shape. The server prepares the same
@@ -1859,7 +2183,10 @@ Frames are JSON-safe plain objects marked `jaren: "contract/0.1"`:
1859
2183
  ```
1860
2184
 
1861
2185
  **Id scoping is the correctness rule.** `id` is `"<clientId>:<seq>"` —
1862
- `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
1863
2190
  two clients on one shared channel can never collide, and a client
1864
2191
  ignores every frame whose id does not start with its own `clientId +
1865
2192
  ":"` (one cheap prefix test before any map lookup). A late response
@@ -1953,11 +2280,26 @@ Subscription = {
1953
2280
  result | snapshot(), // the current snapshot document; snapshot() preferred when both exist
1954
2281
  subscribe(cb) → stop, // cb receives LIVE-FORMAT emissions { patch, seq } or { error }
1955
2282
  close(), // release the registration
1956
- 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 }
1957
2285
  mode?, // ignored by the binding
1958
2286
  }
1959
2287
  ```
1960
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
+
1961
2303
  — a `@jarenjs/db` `live()` object satisfies it **as returned** (`result`
1962
2304
  + `subscribe` + `close`; it has no `replay`), so a handler is one line:
1963
2305
  `(input) => store.collection('x').live(doc, { externals: input })`. No
@@ -1976,7 +2318,50 @@ the server broke the contract; the cause goes to `onError`, never the
1976
2318
  wire), forwards each emission verbatim (the binding never mutates a
1977
2319
  patch), and calls `stop()` then `close()` **exactly once** — on peer
1978
2320
  disconnect (`ctx.signal`), on an `unsubscribe`/stream cancel, on server
1979
- 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.
1980
2365
 
1981
2366
  ## §18 The stream wire
1982
2367
 
@@ -1993,11 +2378,15 @@ Response: `200`, `content-type: text/event-stream`, `cache-control:
1993
2378
  no-store`, `x-jaren-trace`. Events, in order:
1994
2379
 
1995
2380
  - `snapshot` — `id: <seq>` (the stream's starting seq; `0` for a source
1996
- that names none), data `{ "value": <snapshot>, "resumed": false }`.
2381
+ that names none), data `{ "value": <snapshot>, "resumed": false,
2382
+ "reset": false, "earliestAvailable": null, "highWatermark": null }`.
1997
2383
  The envelope exists because a resume verdict cannot ride *inside* the
1998
2384
  snapshot value without breaking a closed output schema; `resumed:
1999
2385
  false` states this snapshot is a fresh document (a refused resume —
2000
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.
2001
2390
  - `patch` — `id: <seq>`, data `{ "patch": [...], "seq": n }`: the
2002
2391
  LIVE-FORMAT emission verbatim.
2003
2392
  - heartbeat comment lines (`:`) every `policy.stream.heartbeatMs`.
@@ -2012,20 +2401,50 @@ at that emission's seq — the consumer swaps its document instead of
2012
2401
  patching it; nothing is dropped.
2013
2402
 
2014
2403
  **Resumption.** A request carrying `Last-Event-ID: <seq>` asks to
2015
- resume. Under `resume: "replay"` the binding asks
2016
- `subscription.replay?.(seq)`; when the handler answers an array of
2017
- emissions, the stream starts with the `patch` events after that seq
2018
- (no snapshot) and continues live. Otherwise — `resume: "snapshot"`, no
2019
- `replay`, or a `replay` that answers `null` the stream starts with a
2020
- 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`,
2021
2421
  informational, never an outcome).
2022
2422
 
2023
- `toNodeHandler` writes SSE with `flushHeaders()` + `res.write` and ends
2024
- on close; `toFetchHandler` answers a `ReadableStream` body; both abort
2025
- `ctx.signal` when the peer goes away (`request.signal`, `req` close),
2026
- which runs the exactly-once `stop()`/`close()`. A dispatcher's
2027
- `close()` ends every live SSE stream with `end` (`server-shutdown`)
2028
- 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).
2029
2448
 
2030
2449
  ### §18.2 Port: push frames
2031
2450
 
@@ -2066,8 +2485,10 @@ other range:
2066
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 |
2067
2486
  | `JC2094` | network | `contract/heartbeat-missed` | yes | no bytes for `2 × heartbeatMs` (client-side, SSE only) |
2068
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 |
2069
2490
 
2070
- `JC2096–JC2109` are reserved for later stream codes. `JC1009` (an SSE
2491
+ `JC2098–JC2109` are reserved for later stream codes. `JC1009` (an SSE
2071
2492
  data string the frame cannot carry) and `JC1010` (`subscribe` of a
2072
2493
  non-subscribe operation) are the stream's host programming errors
2073
2494
  (§7.3's host table).
@@ -2075,32 +2496,63 @@ non-subscribe operation) are the stream's host programming errors
2075
2496
  ## §19 The client: `subscribe`
2076
2497
 
2077
2498
  `client.subscribe(op, input, { onSnapshot, onPatch, onError, onEnd,
2078
- signal, lastSeq }) → { stop() }` — on the `http` and `port` clients
2079
- 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
2080
2501
  `capabilities.stream: false` and needs no handler for a subscribe
2081
2502
  operation (`invoke` of one throws `JC1005` there). A non-subscribe
2082
2503
  operation is `JC1010`, thrown — the host named the wrong operation.
2083
2504
 
2084
- - `onSnapshot(value, { seq, resumed })` — a fresh, validated snapshot;
2085
- the consumer replaces its document. `resumed` is `false` exactly as
2086
- §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.
2087
2512
  - `onPatch({ patch, seq })` — the LIVE emission, **not applied**: the
2088
2513
  client forwards patches; the app binding (§11.4) and the consumer
2089
2514
  apply them (`@jarenjs/json/patch`). `seq` is strictly increasing or
2090
2515
  the stream ends with `JC2092`.
2091
2516
  - `onError(outcome)` — a D6 `ok: false` outcome (`failure` for a
2092
- declared error event; `network` for a transport failure or a missed
2093
- heartbeat; `contract` for `JC2090`/`JC2092`/`JC2093`, an invalid
2094
- snapshot value, or a pre-send input refusal `JC2050`). Its `error`
2095
- and `meta` carry every member (`status: null` where the wire has
2096
- 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.
2097
2524
  - `onEnd({ reason })` — the server's `end` event; a stream that ends
2098
2525
  without one is reported as `reason: "closed"`.
2099
2526
 
2100
2527
  Every callback is optional and total for the client: a callback that
2101
2528
  throws does not break the stream machinery. `signal` aborts the
2102
2529
  subscription silently (the caller asked); `stop()` does the same and,
2103
- on `port`, posts the `unsubscribe` frame. `lastSeq` is what a
2104
- reconnect passes (§18's resumption). **Reconnection is not automatic**:
2105
- the host decides a `subscribe` that ends with a `network` outcome is
2106
- 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.