@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
@@ -23,10 +23,16 @@
23
23
 
24
24
  import { isJsonObject, setObjectMember } from '@jarenjs/core/object';
25
25
  import { toPromise, isThenable } from '@jarenjs/core/function';
26
+ import { createAwaitedSink } from '@jarenjs/core/async';
27
+ import { utf8ByteLength } from '@jarenjs/core/string';
26
28
  import { canonicalSha256, JsonCanonicalizeError } from '@jarenjs/json/canonical';
27
29
 
28
30
  import { ContractHostError, ContractFailure } from '../errors.js';
29
- import { validateOperationInput, settleOperation, safeTrace } from '../pipeline.js';
31
+ import { validateOperationInput, settleOperation, safeTrace, classifyDeclared } from '../pipeline.js';
32
+ import { identify as identifyHost, acquire as acquireHost, once, RollbackCarrier } from '../host.js';
33
+ import {
34
+ BodyLimitError, isAsyncByteSource, normalizeBody, collectBytes, countingSource, onSettled,
35
+ } from './body.js';
30
36
  import {
31
37
  isSubscriptionLike, runSubscription, STREAM_ERRORS, STREAM_MEDIA, HEARTBEAT_LINE, encodeStreamEvent,
32
38
  } from '../stream/server.js';
@@ -51,19 +57,25 @@ import {
51
57
  * The per-request context a handler receives — frozen. `params` are the
52
58
  * raw decoded path strings; `headers` carries the declared header members
53
59
  * (by header name) plus `if-match`/`if-none-match` when present; `body`
54
- * is the raw request body for an OPAQUE operation and `null` for a JSON
55
- * one (whose body was decoded into the input). `fail` makes a declared
60
+ * is the raw request body for an OPAQUE operation text, bytes, or a
61
+ * pull source of chunks that never yields a byte past
62
+ * `policy.limits.maxBodyBytes` (the chunk that would cross it throws a
63
+ * `BodyLimitError` to the puller instead; let it propagate and the
64
+ * response is `JC2003`) — and `null` for a JSON one (whose body was
65
+ * decoded into the input). `fail` makes a declared
56
66
  * failure by code; `etag` arms the entity-tag path; `status` overrides
57
67
  * the success status (2xx only — `JC1006` otherwise, a host error the
58
68
  * handler boundary settles into `JC2008` and reports through `onError`).
59
69
  * @typedef {Object} RequestContext
60
70
  * @property {CompiledOperation} op
61
71
  * @property {string} trace
72
+ * @property {'http'} carrier - the binding this context comes from
73
+ * @property {any} host - the identity's host until `acquire` entered; the acquired host in the handler's context (§7.7) — `any`, so a table typed by the projection's `HandlerContext<Host>` is a `Handler`
62
74
  * @property {string} method
63
75
  * @property {string} path
64
76
  * @property {Readonly<Record<string, string>>} params
65
77
  * @property {Readonly<Record<string, string>>} headers
66
- * @property {string | Uint8Array | null} body
78
+ * @property {string | Uint8Array | AsyncIterable<Uint8Array> | null} body
67
79
  * @property {AbortSignal | null} signal
68
80
  * @property {Readonly<{ key: string, scope: string }> | null} idempotency
69
81
  * @property {(code: string, params?: Record<string, unknown>, details?: unknown, options?: { retryable?: boolean }) => ContractFailureValue} fail
@@ -84,8 +96,10 @@ import {
84
96
  */
85
97
 
86
98
  /**
87
- * The raw response of an opaque operation's handler.
88
- * @typedef {{ status: number, headers?: Record<string, string>, body?: string | Uint8Array | null }} RawResponse
99
+ * The raw response of an opaque operation's handler: `body` may be text,
100
+ * bytes, a pull source of chunks (an async iterable, or a Web
101
+ * `ReadableStream` — normalized, never collected), or none.
102
+ * @typedef {{ status: number, headers?: Record<string, string>, body?: string | Uint8Array | AsyncIterable<Uint8Array> | ReadableStream<Uint8Array> | null }} RawResponse
89
103
  */
90
104
 
91
105
  /**
@@ -151,6 +165,17 @@ import {
151
165
  * @property {{ text: string | null }} described - the memoized well-known body
152
166
  * @property {Set<(reason: string | null) => void>} streams - the live SSE
153
167
  * streams' stoppers; the dispatcher's `close()` ends them all
168
+ * @property {import('../stream/server.js').StreamLimits} streamLimits - the bounds of every SSE stream
169
+ * @property {import('../host.js').Lifecycle} lifecycle - the host lifecycle hooks (§7.7)
170
+ */
171
+
172
+ /**
173
+ * The lifetime of one request's leases: the identity's release, the
174
+ * acquired lease's release once entered, and whether the releases were
175
+ * handed to a body or a stream (`deferred`) rather than run before the
176
+ * response is exposed. Each release runs at most once, acquired before
177
+ * identity.
178
+ * @typedef {{ op: string, trace: string, identity: () => Promise<boolean>, acquired: (() => Promise<boolean>) | null, deferred: boolean }} Life
154
179
  */
155
180
 
156
181
  /** The strict decoder of a JSON body given as bytes. */
@@ -160,7 +185,7 @@ const utf8 = new TextDecoder('utf-8', { fatal: true });
160
185
  const NO_HEADERS = Object.freeze({});
161
186
 
162
187
  /** The valid shape of a request object — `JC1004` otherwise. */
163
- const REQUEST_SHAPE = 'a request is { method: string, url: string, headers: object, body: string | Uint8Array | null }';
188
+ const REQUEST_SHAPE = 'a request is { method: string, url: string, headers: object, body: string | Uint8Array | AsyncIterable<Uint8Array> | ReadableStream | null }';
164
189
 
165
190
  //#region the boundary
166
191
 
@@ -215,8 +240,8 @@ function requestShapeError(request) {
215
240
  if (r.headers === null || typeof r.headers !== 'object') {
216
241
  return new ContractHostError('JC1004', `dispatch: request.headers must be an object of lowercase names; ${REQUEST_SHAPE}`);
217
242
  }
218
- if (!(r.body === null || r.body === undefined || typeof r.body === 'string' || r.body instanceof Uint8Array)) {
219
- return new ContractHostError('JC1004', `dispatch: request.body must be a string, a Uint8Array or null; ${REQUEST_SHAPE}`);
243
+ if (normalizeBody(r.body) === undefined) {
244
+ return new ContractHostError('JC1004', `dispatch: request.body must be a string, a Uint8Array, an async iterable of Uint8Array chunks, a ReadableStream or null; ${REQUEST_SHAPE}`);
220
245
  }
221
246
  return null;
222
247
  }
@@ -286,12 +311,33 @@ function decodable(path) {
286
311
  }
287
312
 
288
313
  /**
289
- * Whether the request carried a non-empty body.
290
- * @param {string | Uint8Array | null} body
314
+ * Whether the request carried a non-empty body. A pull source counts as
315
+ * content: whether it yields anything is known only by pulling it.
316
+ * @param {string | Uint8Array | AsyncIterable<Uint8Array> | null} body
291
317
  * @returns {boolean}
292
318
  */
293
319
  function hasContent(body) {
294
- return body !== null && (typeof body === 'string' ? body.length > 0 : body.byteLength > 0);
320
+ if (body === null) return false;
321
+ if (typeof body === 'string') return body.length > 0;
322
+ if (body instanceof Uint8Array) return body.byteLength > 0;
323
+ return true;
324
+ }
325
+
326
+ /**
327
+ * Cancel a pull source exactly once, for a body nobody will read (a
328
+ * HEAD's returned stream, a request source a response left unread).
329
+ * @param {AsyncIterable<Uint8Array>} source
330
+ * @returns {Promise<void>}
331
+ */
332
+ async function discard(source) {
333
+ const iterator = source[Symbol.asyncIterator]();
334
+ if (typeof iterator.return !== 'function') return;
335
+ try {
336
+ await iterator.return();
337
+ }
338
+ catch {
339
+ // a source that refuses its cancel is already gone
340
+ }
295
341
  }
296
342
 
297
343
  /**
@@ -321,7 +367,9 @@ function signalOf(request) {
321
367
  * claim. `decided` is set by a `preconditions` resolver that already
322
368
  * evaluated the conditionals; the post-handler comparison then stands
323
369
  * down.
324
- * @typedef {{ etag: string | null, strong: boolean, status: number, outcome: number, retryable: boolean, decided: boolean }} Armed
370
+ * `settled` is set by a required settlement (§7.7) that recorded the
371
+ * claim inside `enter`; the root ledger then stands down.
372
+ * @typedef {{ etag: string | null, strong: boolean, status: number, outcome: number, retryable: boolean, decided: boolean, settled: boolean }} Armed
325
373
  */
326
374
 
327
375
  /**
@@ -339,7 +387,7 @@ function run(server, request) {
339
387
  const path = q === -1 ? url : url.slice(0, q);
340
388
  const query = q === -1 ? '' : url.slice(q + 1);
341
389
  const headers = request.headers;
342
- const body = request.body === undefined ? null : request.body;
390
+ const body = /** @type {import('./body.js').Body} */ (normalizeBody(request.body));
343
391
 
344
392
  // ——— 2. route ———
345
393
  let hit = server.contract.match(method, path);
@@ -366,11 +414,178 @@ function run(server, request) {
366
414
  const op = route.op;
367
415
  if (route.handler === null) return refuse(server, 'JC2013', trace, { op: op.id }, undefined, null, null);
368
416
 
417
+ // ——— 3. identify: the host's first look at the request, before any
418
+ // byte of the body is read — the operation, the trace, the signal and
419
+ // the transport facts; never a parsed input (§7.7) ———
420
+ const meta = Object.freeze({ op, trace, signal: signalOf(request), carrier: /** @type {const} */ ('http'), method, path, headers, fail: ContractFailure });
421
+ const identified = identifyHost(server.lifecycle, meta);
422
+ /** @param {ReturnType<typeof identifyHost> extends Promise<infer A> ? A : never} answer */
423
+ const identifiedAs = (answer) => {
424
+ if (answer.kind === 'fault') {
425
+ observe(server, answer.cause, null);
426
+ return refuse(server, 'JC2008', trace, { op: op.id }, undefined, null, null);
427
+ }
428
+ if (answer.kind === 'failure') return hookFailure(server, route, null, answer.failure, trace, freshArmed());
429
+ /** @type {Life} */
430
+ const life = { op: op.id, trace, identity: once(answer.lease.release, (err) => observe(server, err, null)), acquired: null, deferred: false };
431
+ return exposeWith(server, life, () => afterIdentity(server, request, route, trace, hit, isHead, method, path, query, headers, body, life, answer.lease.host));
432
+ };
433
+ return isThenable(identified) ? /** @type {Promise<any>} */ (identified).then(identifiedAs) : identifiedAs(/** @type {any} */ (identified));
434
+ }
435
+
436
+ /** A fresh per-request state record. @returns {Armed} */
437
+ function freshArmed() {
438
+ return { etag: null, strong: false, status: 0, outcome: 0, retryable: false, decided: false, settled: false };
439
+ }
440
+
441
+ /**
442
+ * Run the pipeline after identity and expose its response: the leases
443
+ * are released (acquired, then identity) before the response is
444
+ * exposed — unless the response handed them to its body or stream — and
445
+ * a release that fails before exposure is the host's fault (`JC2008`,
446
+ * the cause observed); one that fails after exposure is observed only.
447
+ * A defect of the pipeline itself still releases before it reaches the
448
+ * last resort.
449
+ * @param {Server} server
450
+ * @param {Life} life
451
+ * @param {() => HttpResponse | Promise<HttpResponse>} produce
452
+ * @returns {Promise<HttpResponse>}
453
+ */
454
+ function exposeWith(server, life, produce) {
455
+ let out;
456
+ try {
457
+ out = produce();
458
+ }
459
+ catch (err) {
460
+ out = Promise.reject(err);
461
+ }
462
+ return toPromise(out).then(
463
+ (response) => (life.deferred ? response : releaseLife(life).then((clean) =>
464
+ (clean ? response : refuse(server, 'JC2008', life.trace, { op: life.op }, undefined, null, null)))),
465
+ (err) => releaseLife(life).then(() => { throw err; }));
466
+ }
467
+
468
+ /**
469
+ * Release a request's leases in order — acquired, then identity — each
470
+ * at most once; answers whether every release was clean.
471
+ * @param {Life} life
472
+ * @returns {Promise<boolean>}
473
+ */
474
+ function releaseLife(life) {
475
+ const acquired = life.acquired === null ? Promise.resolve(true) : life.acquired();
476
+ return acquired.then((a) => life.identity().then((b) => a && b));
477
+ }
478
+
479
+ /**
480
+ * Hand the releases to a pull-source body: they run once when the body
481
+ * reaches EOF, throws, or is cancelled by the consumer (a HEAD's
482
+ * discard included) — after the headers went out, so a failing release
483
+ * is observed, never answered.
484
+ * @param {Server} server
485
+ * @param {Life} life
486
+ * @param {HttpResponse} response
487
+ * @returns {HttpResponse}
488
+ */
489
+ function deferToBody(server, life, response) {
490
+ if (life.deferred) return response;
491
+ const body = response.body;
492
+ if (!isAsyncByteSource(body)) return response;
493
+ life.deferred = true;
494
+ return { ...response, body: onSettled(body, () => releaseLife(life).then(() => undefined)) };
495
+ }
496
+
497
+ /**
498
+ * A declared failure a host hook answered: classified against the
499
+ * operation like a handler's (an undeclared code or bad details is the
500
+ * host's fault), rendered as the declared response.
501
+ * @param {Server} server
502
+ * @param {Route} route
503
+ * @param {RequestContext | null} ctx
504
+ * @param {import('../errors.js').ContractFailureValue} failure
505
+ * @param {string} trace
506
+ * @param {Armed} armed
507
+ * @returns {HttpResponse}
508
+ */
509
+ function hookFailure(server, route, ctx, failure, trace, armed) {
510
+ const result = classifyDeclared(route, failure);
511
+ if (result.kind === 'failure') return declaredFailure(server, route, ctx, result, trace, armed);
512
+ if (result.cause !== undefined) observe(server, result.cause, ctx);
513
+ armed.outcome = 2;
514
+ return refuse(server, 'JC2008', trace, { op: route.op.id }, undefined, null, ctx);
515
+ }
516
+
517
+ /**
518
+ * The handler's context: the identity context with the acquired host,
519
+ * frozen. The host value itself is the host's and is not deep-frozen.
520
+ * @param {RequestContext} ctx
521
+ * @param {unknown} host
522
+ * @returns {RequestContext}
523
+ */
524
+ function handlerContext(ctx, host) {
525
+ return Object.freeze({ ...ctx, host });
526
+ }
527
+
528
+ /**
529
+ * Run `acquire` around a continuation and settle its answer: a lease
530
+ * entered is the continuation's response (a rejection of the hook after
531
+ * the continuation settled is observed, the response stands); a declared
532
+ * failure is rendered; a fault is the host's (`JC2008`, observed). The
533
+ * acquired release is registered on the lifetime as the lease enters.
534
+ * @param {Server} server
535
+ * @param {Route} route
536
+ * @param {RequestContext} ctx - the identity context (frozen)
537
+ * @param {unknown} input
538
+ * @param {string} trace
539
+ * @param {Armed} armed
540
+ * @param {Life} life
541
+ * @param {(lease: import('../host.js').Lease, hctx: RequestContext) => HttpResponse | Promise<HttpResponse>} enter
542
+ * @returns {Promise<HttpResponse>}
543
+ */
544
+ function acquireAround(server, route, ctx, input, trace, armed, life, enter) {
545
+ return acquireHost(server.lifecycle, input, ctx, (lease) => {
546
+ const hctx = handlerContext(ctx, lease.host);
547
+ life.acquired = once(lease.release, (err) => observe(server, err, hctx));
548
+ return enter(lease, hctx);
549
+ }).then((out) => {
550
+ if (out.kind === 'fault') {
551
+ observe(server, out.cause, ctx);
552
+ armed.outcome = 2;
553
+ return refuse(server, 'JC2008', trace, { op: route.op.id }, undefined, null, ctx);
554
+ }
555
+ if (out.kind === 'failure') return hookFailure(server, route, ctx, out.failure, trace, armed);
556
+ if (out.afterFault !== undefined) observe(server, out.afterFault, ctx);
557
+ return /** @type {HttpResponse} */ (out.result);
558
+ });
559
+ }
560
+
561
+ /**
562
+ * The pipeline after identity: the body limit, the transport members,
563
+ * the context, then the opaque branch or the parse. Every early refusal
564
+ * returns a response; the lifetime's releases run at exposure.
565
+ * @param {Server} server
566
+ * @param {HttpRequest} request
567
+ * @param {Route} route
568
+ * @param {string} trace
569
+ * @param {{ params: Record<string, string> }} hit
570
+ * @param {boolean} isHead
571
+ * @param {string} method
572
+ * @param {string} path
573
+ * @param {string} query
574
+ * @param {Readonly<Record<string, string | readonly string[]>>} headers
575
+ * @param {import('./body.js').Body} body
576
+ * @param {Life} life
577
+ * @param {unknown} identityHost
578
+ * @returns {HttpResponse | Promise<HttpResponse>}
579
+ */
580
+ function afterIdentity(server, request, route, trace, hit, isHead, method, path, query, headers, body, life, identityHost) {
581
+ const op = route.op;
582
+
369
583
  // ——— 4. the body limit: by declaration before the read, by length after ———
370
584
  const declared = contentLength(headers);
371
585
  if (declared > route.maxBody) return refuse(server, 'JC2003', trace, { op: op.id, limit: route.maxBody }, undefined, null, null);
372
586
  const content = hasContent(body);
373
- if (content && exceedsBytes(/** @type {string | Uint8Array} */ (body), route.maxBody)) {
587
+ const sourced = isAsyncByteSource(body);
588
+ if (content && !sourced && exceedsBytes(/** @type {string | Uint8Array} */ (body), route.maxBody)) {
374
589
  return refuse(server, 'JC2003', trace, { op: op.id, limit: route.maxBody }, undefined, null, null);
375
590
  }
376
591
 
@@ -434,12 +649,15 @@ function run(server, request) {
434
649
  Object.freeze(ctxHeaders);
435
650
  const transported = route.normalize === null ? input : route.normalize(input);
436
651
 
437
- /** @type {Armed} */
438
- const armed = { etag: null, strong: false, status: 0, outcome: 0, retryable: false, decided: false };
652
+ const armed = freshArmed();
653
+ // an opaque handler pulls its bytes through a counting source that
654
+ // never yields past the limit; text and bytes were already measured
655
+ /** @type {import('./body.js').CountingSource | null} */
656
+ const requestSource = route.raw && sourced ? countingSource(/** @type {AsyncIterable<Uint8Array>} */ (body), route.maxBody) : null;
439
657
  /** @type {RequestContext} */
440
658
  const ctx = {
441
- op, trace, method, path, params, headers: ctxHeaders,
442
- body: route.raw ? body : null,
659
+ op, trace, carrier: 'http', host: identityHost, method, path, params, headers: ctxHeaders,
660
+ body: route.raw ? (requestSource !== null ? requestSource : body) : null,
443
661
  signal: signalOf(request),
444
662
  idempotency: null,
445
663
  fail: ContractFailure,
@@ -466,23 +684,74 @@ function run(server, request) {
466
684
  const invalid = validateOperationInput(route, transported);
467
685
  if (invalid !== null && invalid.kind === 'contract') {
468
686
  if (invalid.cause !== undefined) observe(server, invalid.cause, null);
687
+ if (requestSource !== null) void requestSource.cancel();
469
688
  return refuse(server, 'JC2006', trace, { op: op.id }, invalid.details, null, null);
470
689
  }
471
690
  Object.freeze(ctx);
472
- return boundary(server, route, ctx, op.input === null ? null : transported, trace, armed, isHead, ifMatch, ifNoneMatch, true);
691
+ const rawInput = op.input === null ? null : transported;
692
+ return acquireAround(server, route, ctx, rawInput, trace, armed, life, (lease, hctx) => {
693
+ const ran = boundary(server, route, hctx, rawInput, trace, armed, isHead, ifMatch, ifNoneMatch, true);
694
+ return (requestSource === null ? ran : ran.then((response) => settleUpload(server, hctx, response, requestSource)))
695
+ .then((response) => deferToBody(server, life, response));
696
+ });
473
697
  }
474
698
 
475
699
  // ——— 5. media, 6. parse ———
476
- let parsed;
477
700
  if (route.hasBody && content) {
478
701
  if (!mediaMatches(headerValue(headers, 'content-type'), route.media)) {
702
+ if (sourced) void discard(/** @type {AsyncIterable<Uint8Array>} */ (body));
479
703
  return refuse(server, 'JC2004', trace, { op: op.id, media: route.media }, undefined, null, null);
480
704
  }
705
+ if (sourced) {
706
+ // a JSON body must be parsed and validated whole: drain the
707
+ // source under the limit — never past it — then parse
708
+ return collectBytes(/** @type {AsyncIterable<Uint8Array>} */ (body), route.maxBody, signalOf(request)).then((collected) => {
709
+ if (!collected.ok) {
710
+ if (collected.kind === 'limit') return refuse(server, 'JC2003', trace, { op: op.id, limit: route.maxBody }, undefined, null, null);
711
+ // the body never arrived whole (the source failed, or the
712
+ // request was aborted between pulls): not a valid document
713
+ return refuse(server, 'JC2005', trace, { op: op.id }, undefined, null, null);
714
+ }
715
+ return parseAndContinue(server, route, ctx, request, trace, armed, isHead, ifMatch, ifNoneMatch, transported, headers, collected.bytes.byteLength === 0 ? null : collected.bytes, life);
716
+ });
717
+ }
718
+ }
719
+ else if (sourced) {
720
+ // a body-less operation ignores the body; a source is released, never read
721
+ void discard(/** @type {AsyncIterable<Uint8Array>} */ (body));
722
+ }
723
+ return parseAndContinue(server, route, ctx, request, trace, armed, isHead, ifMatch, ifNoneMatch, transported, headers,
724
+ route.hasBody && content && !sourced ? /** @type {string | Uint8Array} */ (body) : null, life);
725
+ }
726
+
727
+ /**
728
+ * The pipeline from the parse on: the materialized JSON body (or none),
729
+ * the body members, validation, then the subscribe / idempotency /
730
+ * precondition / handler branches.
731
+ * @param {Server} server
732
+ * @param {Route} route
733
+ * @param {RequestContext} ctx - not yet frozen
734
+ * @param {HttpRequest} request
735
+ * @param {string} trace
736
+ * @param {Armed} armed
737
+ * @param {boolean} isHead
738
+ * @param {string | undefined} ifMatch
739
+ * @param {string | undefined} ifNoneMatch
740
+ * @param {any} transported
741
+ * @param {Readonly<Record<string, string | readonly string[]>>} headers
742
+ * @param {string | Uint8Array | null} body - the whole JSON body, or none
743
+ * @param {Life} life
744
+ * @returns {HttpResponse | Promise<HttpResponse>}
745
+ */
746
+ function parseAndContinue(server, route, ctx, request, trace, armed, isHead, ifMatch, ifNoneMatch, transported, headers, body, life) {
747
+ const op = route.op;
748
+ let parsed;
749
+ if (body !== null) {
481
750
  let text;
482
751
  if (typeof body === 'string') text = body;
483
752
  else {
484
753
  try {
485
- text = utf8.decode(/** @type {Uint8Array} */ (body));
754
+ text = utf8.decode(body);
486
755
  }
487
756
  catch {
488
757
  return refuse(server, 'JC2005', trace, { op: op.id }, undefined, null, null);
@@ -525,7 +794,8 @@ function run(server, request) {
525
794
  // ——— subscribe: the stream branch (§17–§18), or the one-shot read ———
526
795
  if (route.stream) {
527
796
  Object.freeze(ctx);
528
- return subscribeBranch(server, route, ctx, assembled, trace, headers, armed, isHead, ifMatch, ifNoneMatch);
797
+ return acquireAround(server, route, ctx, assembled, trace, armed, life, (lease, hctx) =>
798
+ subscribeBranch(server, route, hctx, assembled, trace, headers, armed, isHead, ifMatch, ifNoneMatch, life));
529
799
  }
530
800
 
531
801
  // ——— 9. idempotency ———
@@ -534,12 +804,85 @@ function run(server, request) {
534
804
  if (key === undefined || key.length === 0) {
535
805
  if (route.idempotency === 'required') return refuse(server, 'JC2007', trace, { op: op.id }, undefined, null, null);
536
806
  }
537
- else return idempotent(server, route, ctx, assembled, trace, armed, isHead, ifMatch, ifNoneMatch, key);
807
+ else return idempotent(server, route, ctx, assembled, trace, armed, isHead, ifMatch, ifNoneMatch, key, life);
538
808
  }
539
809
 
810
+ // ——— 10. acquire, then the handler inside enter ———
540
811
  Object.freeze(ctx);
541
- if (route.tag !== null) return preconditionedBoundary(server, route, ctx, assembled, trace, armed, isHead, ifMatch, ifNoneMatch);
542
- return boundary(server, route, ctx, assembled, trace, armed, isHead, ifMatch, ifNoneMatch, false);
812
+ return acquireAround(server, route, ctx, assembled, trace, armed, life, (lease, hctx) =>
813
+ enterHandler(server, route, hctx, assembled, trace, armed, isHead, ifMatch, ifNoneMatch, lease, undefined));
814
+ }
815
+
816
+ /**
817
+ * The continuation inside `enter` for a JSON operation: the
818
+ * precondition, the handler, the output validation and the response
819
+ * serialization; then, for a lease that requires settlement of a new
820
+ * claim, the settlement through the lease's ledger — inside `enter`, so
821
+ * a host transaction around it commits the domain write and the receipt
822
+ * together. A host fault or a pre-handler refusal (outcome 2) rejects
823
+ * `enter` with the rollback carrier: the host transaction rolls back
824
+ * and the intended fault is still the answer.
825
+ * @param {Server} server
826
+ * @param {Route} route
827
+ * @param {RequestContext} hctx - the handler's context (frozen)
828
+ * @param {any} input
829
+ * @param {string} trace
830
+ * @param {Armed} armed
831
+ * @param {boolean} isHead
832
+ * @param {string | undefined} ifMatch
833
+ * @param {string | undefined} ifNoneMatch
834
+ * @param {import('../host.js').Lease} lease
835
+ * @param {unknown} ref - the new claim's ref, `undefined` without one
836
+ * @returns {Promise<HttpResponse>}
837
+ */
838
+ function enterHandler(server, route, hctx, input, trace, armed, isHead, ifMatch, ifNoneMatch, lease, ref) {
839
+ const ran = route.tag !== null
840
+ ? preconditionedBoundary(server, route, hctx, input, trace, armed, isHead, ifMatch, ifNoneMatch)
841
+ : boundary(server, route, hctx, input, trace, armed, isHead, ifMatch, ifNoneMatch, false);
842
+ return toPromise(ran).then((response) => {
843
+ if (armed.outcome === 2) throw new RollbackCarrier(response, undefined);
844
+ if (lease.settlement === null || ref === undefined) return response;
845
+ return requiredSettlement(server, route, lease.settlement.ledger, ref, response, hctx, armed, trace);
846
+ });
847
+ }
848
+
849
+ /**
850
+ * The required settlement (§7.7): the same receipt `settleClaim` would
851
+ * record, through the lease's ledger, inside `enter`. A settlement that
852
+ * throws or rejects is the host's fault: observed, and `enter` rejects
853
+ * with the carrier of a `JC2008` so the host transaction rolls back and
854
+ * the root claim is released retryable outside it.
855
+ * @param {Server} server
856
+ * @param {Route} route
857
+ * @param {Ledger} ledger
858
+ * @param {unknown} ref
859
+ * @param {HttpResponse} response
860
+ * @param {RequestContext} hctx
861
+ * @param {Armed} armed
862
+ * @param {string} trace
863
+ * @returns {Promise<HttpResponse>}
864
+ */
865
+ function requiredSettlement(server, route, ledger, ref, response, hctx, armed, trace) {
866
+ /** @param {unknown} err */
867
+ const carrier = (err) => {
868
+ observe(server, err, hctx);
869
+ armed.outcome = 2;
870
+ return new RollbackCarrier(refuse(server, 'JC2008', trace, { op: route.op.id }, undefined, null, hctx), err);
871
+ };
872
+ let settlement;
873
+ try {
874
+ const now = server.now();
875
+ if (armed.outcome === 0) settlement = ledger.commit(ref, response, now);
876
+ else if (armed.outcome === 1) settlement = ledger.fail(ref, armed.retryable, response, now);
877
+ else settlement = ledger.fail(ref, false, response, now);
878
+ }
879
+ catch (err) {
880
+ return Promise.reject(carrier(err));
881
+ }
882
+ return toPromise(settlement).then(() => {
883
+ armed.settled = true;
884
+ return response;
885
+ }, (err) => { throw carrier(err); });
543
886
  }
544
887
 
545
888
  /**
@@ -593,9 +936,10 @@ function wellKnown(server, method, trace) {
593
936
  * @param {boolean} isHead
594
937
  * @param {string | undefined} ifMatch
595
938
  * @param {string | undefined} ifNoneMatch
939
+ * @param {Life} life
596
940
  * @returns {Promise<HttpResponse>}
597
941
  */
598
- function subscribeBranch(server, route, ctx, input, trace, headers, armed, isHead, ifMatch, ifNoneMatch) {
942
+ function subscribeBranch(server, route, ctx, input, trace, headers, armed, isHead, ifMatch, ifNoneMatch, life) {
599
943
  const accept = headerValue(headers, 'accept');
600
944
  const wantsStream = !isHead && accept !== undefined && accept.toLowerCase().indexOf(STREAM_MEDIA) !== -1;
601
945
  return settleOperation(route, input, ctx, false).then((result) => {
@@ -612,7 +956,10 @@ function subscribeBranch(server, route, ctx, input, trace, headers, armed, isHea
612
956
  return refuse(server, 'JC2010', trace, { op: route.op.id }, undefined, null, ctx);
613
957
  }
614
958
  if (!wantsStream) return oneShotSnapshot(server, route, ctx, sub, trace, armed, isHead, ifMatch, ifNoneMatch);
615
- return sseResponse(server, route, ctx, sub, trace, headers);
959
+ // the stream owns the leases from here: they are released after the
960
+ // runner's stop/close/done sequence, once the sink has ended
961
+ life.deferred = true;
962
+ return sseResponse(server, route, ctx, sub, trace, headers, life);
616
963
  });
617
964
  }
618
965
 
@@ -666,19 +1013,29 @@ function oneShotSnapshot(server, route, ctx, sub, trace, armed, isHead, ifMatch,
666
1013
  /**
667
1014
  * The SSE response of a live subscription: `200 text/event-stream` with
668
1015
  * the pump behind `stream`. The pump runs the carrier-neutral
669
- * subscription runner snapshot/patch/error/end land as SSE events, a
670
- * heartbeat comment line goes out every `policy.stream.heartbeatMs`,
671
- * the peer's abort (`ctx.signal`) stops silently, and the dispatcher's
672
- * `close()` ends with `server-shutdown`.
1016
+ * subscription runner over the adapter's sink, serialized through the
1017
+ * suite's awaited-sink primitive: snapshot/patch/error/end land as SSE
1018
+ * events, each written only after the previous write settled (a Node
1019
+ * response that answered `false` waits for `drain`; a Web stream
1020
+ * bridge waits for the consumer's pull), a heartbeat comment line goes
1021
+ * out every `policy.stream.heartbeatMs` unless the previous heartbeat
1022
+ * is still waiting on the sink (a stalled consumer never queues a
1023
+ * second), the peer's abort (`ctx.signal`) stops silently, and the
1024
+ * dispatcher's `close()` ends with `server-shutdown`. A sink write that
1025
+ * rejects is the peer being gone: the runner releases the subscription
1026
+ * silently and nothing is observed. The pump answers the runner's
1027
+ * stopper and its completion signal, which settles only after the
1028
+ * subscription's `stop()`/`close()` and the sink's `end()` have.
673
1029
  * @param {Server} server
674
1030
  * @param {Route} route
675
1031
  * @param {RequestContext} ctx
676
1032
  * @param {import('../stream/server.js').SubscriptionLike} sub
677
1033
  * @param {string} trace
678
1034
  * @param {Readonly<Record<string, string | readonly string[]>>} headers
1035
+ * @param {Life} life
679
1036
  * @returns {HttpResponse}
680
1037
  */
681
- function sseResponse(server, route, ctx, sub, trace, headers) {
1038
+ function sseResponse(server, route, ctx, sub, trace, headers, life) {
682
1039
  const lastRaw = headerValue(headers, 'last-event-id');
683
1040
  let lastSeq = null;
684
1041
  if (lastRaw !== undefined) {
@@ -689,29 +1046,41 @@ function sseResponse(server, route, ctx, sub, trace, headers) {
689
1046
  const heartbeatMs = streamPolicy === null ? 15000 : streamPolicy.heartbeatMs;
690
1047
 
691
1048
  /** @type {NonNullable<HttpResponse['stream']>} */
692
- const stream = (sink) => {
1049
+ const stream = (raw) => {
1050
+ const sink = createAwaitedSink(raw);
1051
+ let heartbeatPending = false;
1052
+ const heartbeatSettled = () => { heartbeatPending = false; };
693
1053
  const timer = setInterval(() => {
694
- try {
695
- sink.write(HEARTBEAT_LINE);
696
- }
697
- catch {
698
- // a dead sink is ended by the abort path
1054
+ if (heartbeatPending) return;
1055
+ const answer = sink.write(HEARTBEAT_LINE);
1056
+ if (answer !== undefined) {
1057
+ heartbeatPending = true;
1058
+ // a rejected heartbeat is the sink being gone; the runner's own
1059
+ // next write meets the same rejection and releases the stream
1060
+ answer.then(heartbeatSettled, heartbeatSettled);
699
1061
  }
700
1062
  }, heartbeatMs);
701
1063
  if (timer !== null && typeof (/** @type {any} */ (timer)).unref === 'function') /** @type {any} */ (timer).unref();
702
1064
 
703
- /** @param {string} event @param {number | null} seq @param {unknown} data */
704
- const write = (event, seq, data) => {
1065
+ /**
1066
+ * Encode one event as its SSE text — the frame the runner measures,
1067
+ * queues and writes. An event the frame cannot carry (`JC1009`, a
1068
+ * host error) is observed and skipped; the stream goes on.
1069
+ * @param {string} event @param {number | null} seq @param {unknown} data
1070
+ * @returns {string | null}
1071
+ */
1072
+ const frame = (event, seq, data) => {
705
1073
  try {
706
- sink.write(encodeStreamEvent(event, seq, data));
1074
+ return encodeStreamEvent(event, seq, data);
707
1075
  }
708
1076
  catch (err) {
709
1077
  observe(server, err, ctx);
1078
+ return null;
710
1079
  }
711
1080
  };
712
1081
  // registered BEFORE the runner starts, so a stream that fails during
713
1082
  // construction removes itself and never lingers in the set
714
- /** @type {{ stop: (reason: string | null) => void } | null} */
1083
+ /** @type {import('../stream/server.js').StreamRunner | null} */
715
1084
  let runner = null;
716
1085
  /** @type {(reason: string | null) => void} */
717
1086
  const stopper = (reason) => {
@@ -719,31 +1088,46 @@ function sseResponse(server, route, ctx, sub, trace, headers) {
719
1088
  };
720
1089
  server.streams.add(stopper);
721
1090
  runner = runSubscription(route, sub, {
722
- snapshot: (seq, value, resumed) => write('snapshot', seq, { value, resumed }),
723
- patch: (seq, emission) => write('patch', seq, emission),
724
- error: (intent, cause) => {
725
- observe(server, cause, ctx);
726
- const code = intent === 'invalid-snapshot' ? 'JC2091' : 'JC2008';
727
- const msgid = intent === 'invalid-snapshot' ? STREAM_ERRORS.JC2091.msgid : HTTP_ERRORS.JC2008.msgid;
728
- write('error', null, { code, message: renderMessage(server.catalog, msgid, { op: route.op.id }), requestId: trace, retryable: false });
1091
+ snapshot: (seq, data) => frame('snapshot', seq, data),
1092
+ patch: (seq, emission) => frame('patch', seq, emission),
1093
+ error: (intent, cause, seq, declared) => {
1094
+ // a slow consumer is the peer's doing, not the host's: not observed
1095
+ if (intent !== 'slow-consumer') observe(server, cause, ctx);
1096
+ if (intent === 'declared' && declared !== null) {
1097
+ // the operation's own declared failure, its message rendered
1098
+ // from the catalog — never the error's text
1099
+ const message = declaredMessage(server.catalog, route.op.id, declared.code, {});
1100
+ return frame('error', null, declared.details === undefined
1101
+ ? { code: declared.code, message, requestId: trace, retryable: declared.retryable }
1102
+ : { code: declared.code, message, requestId: trace, details: declared.details, retryable: declared.retryable });
1103
+ }
1104
+ const code = intent === 'invalid-snapshot' ? 'JC2091' : intent === 'slow-consumer' ? 'JC2096' : 'JC2008';
1105
+ const row = intent === 'invalid-snapshot' ? STREAM_ERRORS.JC2091
1106
+ : intent === 'slow-consumer' ? STREAM_ERRORS.JC2096 : HTTP_ERRORS.JC2008;
1107
+ return frame('error', null, { code, message: renderMessage(server.catalog, row.msgid, { op: route.op.id }), requestId: trace, retryable: row.retryable });
729
1108
  },
730
- end: (reason) => write('end', null, { reason }),
731
- done: () => {
1109
+ end: (reason) => frame('end', null, { reason }),
1110
+ size: (text) => utf8ByteLength(text),
1111
+ write: (text) => sink.write(text),
1112
+ done: (reason) => {
732
1113
  clearInterval(timer);
733
1114
  server.streams.delete(stopper);
734
- try {
735
- sink.end();
736
- }
737
- catch {
738
- // the sink may already be gone
739
- }
1115
+ // a consumer that fell behind is not waited for: the sink is
1116
+ // torn down (the node adapter destroys the socket, the fetch
1117
+ // bridge errors the stream); otherwise the sink ends, and one
1118
+ // that cannot end cleanly (the peer dropped it) is the fact a
1119
+ // silent release expects, not a fault
1120
+ const ended = reason === 'slow-consumer'
1121
+ ? sink.abort(new Error('the consumer fell behind the stream\'s bounded queue')).catch(() => undefined)
1122
+ : sink.end().catch(() => undefined);
1123
+ return ended.then(() => releaseLife(life)).then(() => undefined);
740
1124
  },
741
- }, { lastSeq, validate: server.validateOutput });
1125
+ }, { lastSeq, validate: server.validateOutput, limits: server.streamLimits });
742
1126
  if (ctx.signal !== null) {
743
1127
  if (ctx.signal.aborted) stopper(null);
744
1128
  else ctx.signal.addEventListener('abort', () => stopper(null), { once: true });
745
1129
  }
746
- return () => stopper(null);
1130
+ return { stop: () => stopper(null), done: runner.done };
747
1131
  };
748
1132
 
749
1133
  return {
@@ -876,6 +1260,12 @@ function boundary(server, route, ctx, input, trace, armed, isHead, ifMatch, ifNo
876
1260
  function project(server, route, ctx, result, trace, armed, isHead, ifMatch, ifNoneMatch, raw) {
877
1261
  if (result.kind === 'failure') return declaredFailure(server, route, ctx, result, trace, armed);
878
1262
  if (result.kind === 'contract') {
1263
+ if (raw && result.code === 'JC2008' && result.cause instanceof BodyLimitError) {
1264
+ // the raw handler let the upload's limit crossing propagate: the
1265
+ // request is too large, and the handler is not at fault
1266
+ armed.outcome = 2;
1267
+ return refuse(server, 'JC2003', trace, { op: route.op.id, limit: result.cause.limit }, undefined, null, ctx);
1268
+ }
879
1269
  if (result.cause !== undefined) observe(server, result.cause, ctx);
880
1270
  armed.outcome = 2;
881
1271
  return refuse(server, result.code, trace, { op: route.op.id }, result.details, null, ctx);
@@ -951,7 +1341,9 @@ function finishValue(server, route, ctx, value, trace, armed, isHead, ifMatch, i
951
1341
  /**
952
1342
  * The value of a raw (opaque) handler: passed through verbatim plus the
953
1343
  * trace header; anything that is not `{ status, headers?, body? }` is
954
- * `JC2010`.
1344
+ * `JC2010`. A body that is a pull source (an async iterable, or a Web
1345
+ * stream, normalized) is passed through as one — the adapter writes it
1346
+ * chunk by chunk — and under HEAD it is cancelled, never drained.
955
1347
  * @param {Server} server
956
1348
  * @param {Route} route
957
1349
  * @param {RequestContext} ctx
@@ -974,7 +1366,8 @@ function finishRaw(server, route, ctx, value, trace, armed, isHead) {
974
1366
  body = r.body;
975
1367
  if (!Number.isInteger(status) || status < 100 || status > 599) throw new TypeError('raw status');
976
1368
  if (rawHeaders !== undefined && (rawHeaders === null || typeof rawHeaders !== 'object')) throw new TypeError('raw headers');
977
- if (!(body === undefined || body === null || typeof body === 'string' || body instanceof Uint8Array)) throw new TypeError('raw body');
1369
+ body = normalizeBody(body);
1370
+ if (body === undefined) throw new TypeError('raw body');
978
1371
  if (rawHeaders !== undefined) {
979
1372
  const names = Object.keys(rawHeaders);
980
1373
  for (let i = 0; i < names.length; i++) {
@@ -989,7 +1382,43 @@ function finishRaw(server, route, ctx, value, trace, armed, isHead) {
989
1382
  return refuse(server, 'JC2010', trace, { op: route.op.id }, undefined, null, ctx);
990
1383
  }
991
1384
  headers['x-jaren-trace'] = trace;
992
- return { status, headers, body: isHead || body === undefined ? null : body };
1385
+ if (isHead && isAsyncByteSource(body)) {
1386
+ // a HEAD drops the body: a source nobody will read is released, never pulled
1387
+ void discard(body);
1388
+ body = null;
1389
+ }
1390
+ return { status, headers, body: isHead ? null : body };
1391
+ }
1392
+
1393
+ /**
1394
+ * Settle an opaque upload after its handler answered: a response whose
1395
+ * body is not a stream goes out only after an unread request source
1396
+ * was cancelled once (the connection cannot be reused with an upload
1397
+ * still arriving, and nothing will read it); a streamed response keeps
1398
+ * the request source alive — the handler may be transforming it — and
1399
+ * releases it once when the response body reaches EOF, throws, or is
1400
+ * cancelled by the consumer. A throw from the response body after the
1401
+ * headers are out (a limit crossing met mid-transform among them) cuts
1402
+ * the body and is observed: the status cannot be rewritten by then.
1403
+ * @param {Server} server
1404
+ * @param {RequestContext} ctx
1405
+ * @param {HttpResponse} response
1406
+ * @param {import('./body.js').CountingSource} source
1407
+ * @returns {HttpResponse | Promise<HttpResponse>}
1408
+ */
1409
+ function settleUpload(server, ctx, response, source) {
1410
+ const body = response.body;
1411
+ if (isAsyncByteSource(body)) {
1412
+ return {
1413
+ ...response,
1414
+ body: onSettled(body, (cause) => {
1415
+ if (cause !== undefined) observe(server, cause, ctx);
1416
+ return source.state.finished ? undefined : source.cancel();
1417
+ }),
1418
+ };
1419
+ }
1420
+ if (source.state.finished) return response;
1421
+ return source.cancel().then(() => response);
993
1422
  }
994
1423
 
995
1424
  /**
@@ -1029,9 +1458,10 @@ function declaredFailure(server, route, ctx, result, trace, armed) {
1029
1458
  * @param {string | undefined} ifMatch
1030
1459
  * @param {string | undefined} ifNoneMatch
1031
1460
  * @param {string} key
1461
+ * @param {Life} life
1032
1462
  * @returns {Promise<HttpResponse>}
1033
1463
  */
1034
- function idempotent(server, route, ctx, input, trace, armed, isHead, ifMatch, ifNoneMatch, key) {
1464
+ function idempotent(server, route, ctx, input, trace, armed, isHead, ifMatch, ifNoneMatch, key, life) {
1035
1465
  const ledger = /** @type {Ledger} */ (server.ledger);
1036
1466
  let scope;
1037
1467
  try {
@@ -1066,13 +1496,16 @@ function idempotent(server, route, ctx, input, trace, armed, isHead, ifMatch, if
1066
1496
  return fault(err);
1067
1497
  }
1068
1498
  if (state === 'new') {
1069
- // claim first, precondition second: a committed key replays its
1070
- // stored response before the resolver runs (a retried command
1071
- // that already succeeded must not answer 412)
1072
- const ran = route.tag !== null
1073
- ? preconditionedBoundary(server, route, ctx, input, trace, armed, isHead, ifMatch, ifNoneMatch)
1074
- : boundary(server, route, ctx, input, trace, armed, isHead, ifMatch, ifNoneMatch, false);
1075
- return toPromise(ran).then((response) => settleClaim(server, ledger, ref, response, ctx, armed));
1499
+ // claim first, acquire second, precondition third: a committed key
1500
+ // replays its stored response before any host resource is taken
1501
+ // and before the resolver runs (a retried command that already
1502
+ // succeeded must not answer 412). A lease that requires settlement
1503
+ // records the claim inside `enter`; otherwise, or when the host
1504
+ // transaction rolled back, the root ledger settles it here — a
1505
+ // rollback releases the key retryable (outcome 2)
1506
+ return acquireAround(server, route, ctx, input, trace, armed, life, (lease, hctx) =>
1507
+ enterHandler(server, route, hctx, input, trace, armed, isHead, ifMatch, ifNoneMatch, lease, ref))
1508
+ .then((response) => (armed.settled ? response : settleClaim(server, ledger, ref, response, ctx, armed)));
1076
1509
  }
1077
1510
  if (state === 'replay') return replay(server, route, stored, trace, ctx);
1078
1511
  if (state === 'in-progress') {
@@ -1123,11 +1556,14 @@ function idempotent(server, route, ctx, input, trace, armed, isHead, ifMatch, if
1123
1556
  */
1124
1557
  function settleClaim(server, ledger, ref, response, ctx, armed) {
1125
1558
  let settlement;
1559
+ // the settlement carries the binding's clock, as the claim did: one
1560
+ // clock judges the record from claim to expiry
1126
1561
  try {
1127
- if (armed.outcome === 0) settlement = ledger.commit(ref, response);
1128
- else if (armed.outcome === 1) settlement = ledger.fail(ref, armed.retryable, response);
1129
- else if (armed.outcome === 3) settlement = ledger.fail(ref, false, response);
1130
- else settlement = ledger.fail(ref, true, undefined);
1562
+ const now = server.now();
1563
+ if (armed.outcome === 0) settlement = ledger.commit(ref, response, now);
1564
+ else if (armed.outcome === 1) settlement = ledger.fail(ref, armed.retryable, response, now);
1565
+ else if (armed.outcome === 3) settlement = ledger.fail(ref, false, response, now);
1566
+ else settlement = ledger.fail(ref, true, undefined, now);
1131
1567
  }
1132
1568
  catch (err) {
1133
1569
  observe(server, err, ctx);