@nifrajs/core 1.9.1 → 1.11.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 (83) hide show
  1. package/dist/assurance.d.ts +18 -0
  2. package/dist/assurance.d.ts.map +1 -1
  3. package/dist/assurance.js +28 -1
  4. package/dist/assurance.js.map +1 -1
  5. package/dist/budget.d.ts +91 -0
  6. package/dist/budget.d.ts.map +1 -0
  7. package/dist/budget.js +172 -0
  8. package/dist/budget.js.map +1 -0
  9. package/dist/capabilities.d.ts +146 -0
  10. package/dist/capabilities.d.ts.map +1 -0
  11. package/dist/capabilities.js +283 -0
  12. package/dist/capabilities.js.map +1 -0
  13. package/dist/classification.d.ts +39 -0
  14. package/dist/classification.d.ts.map +1 -0
  15. package/dist/classification.js +153 -0
  16. package/dist/classification.js.map +1 -0
  17. package/dist/errors.d.ts +1 -1
  18. package/dist/errors.d.ts.map +1 -1
  19. package/dist/errors.js.map +1 -1
  20. package/dist/idempotency.d.ts +137 -0
  21. package/dist/idempotency.d.ts.map +1 -0
  22. package/dist/idempotency.js +306 -0
  23. package/dist/idempotency.js.map +1 -0
  24. package/dist/index.d.ts +8 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +8 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/internal/capability-runtime.d.ts +16 -0
  29. package/dist/internal/capability-runtime.d.ts.map +1 -0
  30. package/dist/internal/capability-runtime.js +27 -0
  31. package/dist/internal/capability-runtime.js.map +1 -0
  32. package/dist/internal/route-assurance.d.ts +1 -0
  33. package/dist/internal/route-assurance.d.ts.map +1 -1
  34. package/dist/internal/route-assurance.js +1 -0
  35. package/dist/internal/route-assurance.js.map +1 -1
  36. package/dist/invariants.d.ts +66 -0
  37. package/dist/invariants.d.ts.map +1 -0
  38. package/dist/invariants.js +483 -0
  39. package/dist/invariants.js.map +1 -0
  40. package/dist/ledger.d.ts +154 -0
  41. package/dist/ledger.d.ts.map +1 -0
  42. package/dist/ledger.js +271 -0
  43. package/dist/ledger.js.map +1 -0
  44. package/dist/manifest.d.ts +81 -0
  45. package/dist/manifest.d.ts.map +1 -0
  46. package/dist/manifest.js +394 -0
  47. package/dist/manifest.js.map +1 -0
  48. package/dist/mount.d.ts +18 -0
  49. package/dist/mount.d.ts.map +1 -0
  50. package/dist/mount.js +11 -0
  51. package/dist/mount.js.map +1 -0
  52. package/dist/reflection.d.ts +4 -0
  53. package/dist/reflection.d.ts.map +1 -1
  54. package/dist/reflection.js +9 -0
  55. package/dist/reflection.js.map +1 -1
  56. package/dist/server/context.d.ts +50 -1
  57. package/dist/server/context.d.ts.map +1 -1
  58. package/dist/server/contract.d.ts +8 -1
  59. package/dist/server/contract.d.ts.map +1 -1
  60. package/dist/server/contract.js +11 -1
  61. package/dist/server/contract.js.map +1 -1
  62. package/dist/server/server.d.ts +56 -2
  63. package/dist/server/server.d.ts.map +1 -1
  64. package/dist/server/server.js +294 -17
  65. package/dist/server/server.js.map +1 -1
  66. package/package.json +36 -4
  67. package/src/assurance.ts +48 -1
  68. package/src/budget.ts +267 -0
  69. package/src/capabilities.ts +486 -0
  70. package/src/classification.ts +189 -0
  71. package/src/errors.ts +2 -0
  72. package/src/idempotency.ts +420 -0
  73. package/src/index.ts +8 -1
  74. package/src/internal/capability-runtime.ts +49 -0
  75. package/src/internal/route-assurance.ts +1 -0
  76. package/src/invariants.ts +600 -0
  77. package/src/ledger.ts +430 -0
  78. package/src/manifest.ts +543 -0
  79. package/src/mount.ts +24 -0
  80. package/src/reflection.ts +18 -0
  81. package/src/server/context.ts +54 -1
  82. package/src/server/contract.ts +19 -2
  83. package/src/server/server.ts +463 -26
@@ -1,6 +1,9 @@
1
- import { admitDeadline, createRequestBudget, createUnboundedRequestBudget, NIFRA_DEADLINE_HEADER, } from "@nifrajs/budget";
1
+ import { admitDeadline, createRequestBudget, createUnboundedRequestBudget, NIFRA_DEADLINE_HEADER, } from "../budget.js";
2
2
  import { FrameworkError, RouteConfigError } from "../errors.js";
3
+ import { canonicalizeIdempotencyBody, computeIdempotencyFingerprint, DEFAULT_IDEMPOTENCY_HEADER, DEFAULT_IDEMPOTENCY_TTL_MS, IdempotencyResponseTooLargeError, MemoryIdempotencyStore, responseFromStored, serializeResponse, validIdempotencyKey, validIdempotencyNamespace, } from "../idempotency.js";
4
+ import { CAPABILITY_GUARD, createCapabilityGuard, normalizeRouteCapabilities, } from "../internal/capability-runtime.js";
3
5
  import { assuranceDeclarationsOf, assuranceEvidenceFor, NIFRA_ASSURANCE_IDS, } from "../internal/route-assurance.js";
6
+ import { attachEffectLedger, createRequestLedger, DEFAULT_MAX_ENTRIES, } from "../ledger.js";
4
7
  import { EMPTY_PARAMS, Router } from "../router/router.js";
5
8
  import { drainCapped, parseContentLength, readBoundedBytes } from "./body.js";
6
9
  import { parseCookies, serializeCookie } from "./cookies.js";
@@ -660,6 +663,12 @@ export class Server {
660
663
  gracefulSignals;
661
664
  /** Capacity-admission gate; `undefined` = off (the request path pays nothing). */
662
665
  capacityGate;
666
+ onCapabilityUse;
667
+ /** Effect-ledger defaults pinned at construction; per-route `ledgered` copies add route identity. */
668
+ effectLedger;
669
+ /** App-wide idempotency store (explicit option, else a lazily-created in-process default). */
670
+ configuredIdempotencyStore;
671
+ lazyIdempotencyStore;
663
672
  logger;
664
673
  /** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
665
674
  defaultOnValidationError;
@@ -713,6 +722,27 @@ export class Server {
713
722
  }
714
723
  this.gracefulSignals = options.gracefulSignals ?? false;
715
724
  this.capacityGate = options.admission;
725
+ this.onCapabilityUse = options.onCapabilityUse;
726
+ if (options.effectLedger !== undefined) {
727
+ const ledger = options.effectLedger;
728
+ if (typeof ledger.sink !== "function") {
729
+ throw new TypeError("effectLedger.sink must be a function");
730
+ }
731
+ const maxEntries = ledger.maxEntries ?? DEFAULT_MAX_ENTRIES;
732
+ if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
733
+ throw new RangeError("effectLedger.maxEntries must be a positive safe integer");
734
+ }
735
+ this.effectLedger = Object.freeze({
736
+ sink: ledger.sink,
737
+ maxEntries,
738
+ chain: ledger.chain ?? false,
739
+ });
740
+ }
741
+ else {
742
+ this.effectLedger = undefined;
743
+ }
744
+ this.configuredIdempotencyStore = options.idempotencyStore;
745
+ this.lazyIdempotencyStore = undefined;
716
746
  this.logger = options.logger ?? jsonLogger();
717
747
  this.defaultOnValidationError = options.onValidationError;
718
748
  this.bunServer = undefined;
@@ -989,24 +1019,50 @@ export class Server {
989
1019
  * is the "compiled", order-scoped per-route chain.
990
1020
  */
991
1021
  register(method, path, schema, handler) {
1022
+ const capabilities = normalizeRouteCapabilities(schema?.capabilities);
1023
+ const handlerAssurance = assuranceDeclarationsOf(handler);
1024
+ const invalidHandlerScope = handlerAssurance.find((declaration) => declaration.scope !== "plugin");
1025
+ if (invalidHandlerScope !== undefined) {
1026
+ throw new RouteConfigError("INVALID_ASSURANCE", `route handler assurance must use plugin scope (received ${invalidHandlerScope.scope})`);
1027
+ }
1028
+ const authenticated = assuranceEvidenceFor([...this.activeAssurance, ...handlerAssurance, ...this.globalAssurance], method, path).some((evidence) => evidence.id === NIFRA_ASSURANCE_IDS.AUTHENTICATED);
1029
+ const routeDecorations = { ...this.decorations };
1030
+ if (capabilities.length > 0) {
1031
+ routeDecorations[CAPABILITY_GUARD] = createCapabilityGuard(capabilities, method, path, this.onCapabilityUse);
1032
+ }
1033
+ const hasDecorations = Reflect.ownKeys(routeDecorations).length > 0;
1034
+ // An idempotency route runs a dedupe lane that must buffer the body and capture the response, so it
1035
+ // never takes the fused/native fast path — force it onto the portable matched lane (which routes
1036
+ // through `fetchMatched`, where the dedupe wrapper lives).
1037
+ const idempotent = this.resolveIdempotency(schema, authenticated);
1038
+ // A ledgered route (capabilities declared + `server({ effectLedger })`) needs a per-request
1039
+ // context to carry the ledger and a settle step to seal + sink it, so it too leaves the
1040
+ // fused/contextless fast path. Resolved per route, at registration — like the capability guard.
1041
+ const ledgered = capabilities.length > 0 && this.effectLedger !== undefined
1042
+ ? Object.freeze({ ...this.effectLedger, method, path, declared: capabilities })
1043
+ : undefined;
992
1044
  const bare = schema?.body === undefined &&
993
1045
  schema?.query === undefined &&
1046
+ idempotent === undefined &&
1047
+ ledgered === undefined &&
994
1048
  this.derives.length === 0 &&
995
1049
  this.beforeHandleHooks.length === 0 &&
996
1050
  this.afterHandleHooks.length === 0 &&
997
1051
  this.onErrorHooks.length === 0;
998
1052
  const fusedWeb = bare && this.aroundHooks.length === 0
999
- ? this.buildFusedWeb(handler, Object.keys(this.decorations).length > 0 ? { ...this.decorations } : undefined, isContextlessNoArgArrow(handler))
1053
+ ? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler))
1000
1054
  : undefined;
1001
1055
  const entry = {
1002
1056
  // (context: never) => unknown -> InternalHandler: the framework invokes it
1003
1057
  // with the concrete RawContext the typed handler expects, so this is sound.
1004
1058
  handler: handler,
1005
1059
  schema,
1060
+ idempotent,
1061
+ ledgered,
1006
1062
  fusedWeb,
1007
1063
  derives: [...this.derives],
1008
- decorations: { ...this.decorations },
1009
- hasDecorations: Object.keys(this.decorations).length > 0,
1064
+ decorations: routeDecorations,
1065
+ hasDecorations,
1010
1066
  beforeHandle: [...this.beforeHandleHooks],
1011
1067
  afterHandle: [...this.afterHandleHooks],
1012
1068
  onError: [...this.onErrorHooks],
@@ -1031,9 +1087,14 @@ export class Server {
1031
1087
  this.onErrorHooks.length === 0,
1032
1088
  };
1033
1089
  this.router.add(method, path, entry);
1034
- const descriptor = { method, path, schema };
1090
+ const descriptor = {
1091
+ method,
1092
+ path,
1093
+ schema,
1094
+ ...(capabilities.length > 0 ? { capabilities } : {}),
1095
+ };
1035
1096
  this.routeList.push(descriptor);
1036
- const routeAssurance = [...this.activeAssurance];
1097
+ const routeAssurance = [...this.activeAssurance, ...handlerAssurance];
1037
1098
  if (schema?.body !== undefined) {
1038
1099
  routeAssurance.push(Object.freeze({
1039
1100
  id: NIFRA_ASSURANCE_IDS.BODY_BOUNDED,
@@ -1041,6 +1102,15 @@ export class Server {
1041
1102
  scope: "plugin",
1042
1103
  }));
1043
1104
  }
1105
+ // Declaring `schema.idempotency` is evidence for request replay only. It deliberately never proves
1106
+ // durable command execution; that stronger evidence belongs to a command/outbox adapter.
1107
+ if (schema?.idempotency !== undefined) {
1108
+ routeAssurance.push(Object.freeze({
1109
+ id: NIFRA_ASSURANCE_IDS.IDEMPOTENCY_KEY,
1110
+ source: "route-schema",
1111
+ scope: "plugin",
1112
+ }));
1113
+ }
1044
1114
  this.rawEntries.push({
1045
1115
  method,
1046
1116
  path,
@@ -1049,6 +1119,58 @@ export class Server {
1049
1119
  assurance: Object.freeze(routeAssurance),
1050
1120
  });
1051
1121
  }
1122
+ /** Resolve a route's declared idempotency into a pinned store + defaults, or `undefined` when off. */
1123
+ resolveIdempotency(schema, authenticated) {
1124
+ const config = schema?.idempotency;
1125
+ if (config === undefined)
1126
+ return undefined;
1127
+ if (schema?.sse !== undefined) {
1128
+ throw new RouteConfigError("INVALID_IDEMPOTENCY", "streaming/SSE responses cannot be used on an idempotency route");
1129
+ }
1130
+ const store = config.store ?? this.defaultIdempotencyStore();
1131
+ if (config.scope === "durable" && store.durability !== "durable") {
1132
+ throw new RouteConfigError("INVALID_IDEMPOTENCY", 'durable idempotency requires a durable store (store.durability must be "durable")');
1133
+ }
1134
+ const ttlMs = config.ttlMs ?? DEFAULT_IDEMPOTENCY_TTL_MS;
1135
+ if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) {
1136
+ throw new RouteConfigError("INVALID_IDEMPOTENCY", "idempotency ttlMs must be a finite positive number");
1137
+ }
1138
+ const headerName = (config.headerName ?? DEFAULT_IDEMPOTENCY_HEADER).toLowerCase();
1139
+ try {
1140
+ new Headers().set(headerName, "probe");
1141
+ }
1142
+ catch {
1143
+ throw new RouteConfigError("INVALID_IDEMPOTENCY", `invalid idempotency header name ${JSON.stringify(headerName)}`);
1144
+ }
1145
+ const namespace = config.namespace;
1146
+ if (namespace === undefined) {
1147
+ throw new RouteConfigError("INVALID_IDEMPOTENCY", "idempotency namespace is required; use a principal resolver or an explicit shared scope");
1148
+ }
1149
+ if (typeof namespace === "string" && !validIdempotencyNamespace(namespace)) {
1150
+ throw new RouteConfigError("INVALID_IDEMPOTENCY", `invalid idempotency namespace ${JSON.stringify(namespace)}`);
1151
+ }
1152
+ if (authenticated && typeof namespace !== "function") {
1153
+ throw new RouteConfigError("INVALID_IDEMPOTENCY", "authenticated idempotency routes require a principal namespace resolver");
1154
+ }
1155
+ const maxResponseBytes = config.maxResponseBytes ?? this.maxBodyBytes;
1156
+ if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes < 1) {
1157
+ throw new RouteConfigError("INVALID_IDEMPOTENCY", "idempotency maxResponseBytes must be a positive integer");
1158
+ }
1159
+ return Object.freeze({
1160
+ store,
1161
+ ttlMs,
1162
+ headerName,
1163
+ namespace,
1164
+ maxResponseBytes,
1165
+ });
1166
+ }
1167
+ /** The app-wide idempotency store: the configured one, else a lazily-created shared in-process store. */
1168
+ defaultIdempotencyStore() {
1169
+ if (this.configuredIdempotencyStore !== undefined)
1170
+ return this.configuredIdempotencyStore;
1171
+ this.lazyIdempotencyStore ??= new MemoryIdempotencyStore();
1172
+ return this.lazyIdempotencyStore;
1173
+ }
1052
1174
  /**
1053
1175
  * Merge another server's routes into this one — the composition escape hatch for large apps.
1054
1176
  *
@@ -1179,15 +1301,11 @@ export class Server {
1179
1301
  release();
1180
1302
  throw error;
1181
1303
  }
1182
- if (outcome instanceof Promise) {
1183
- return outcome.then((response) => {
1184
- release();
1185
- return response;
1186
- }, (error) => {
1187
- release();
1188
- throw error;
1189
- });
1190
- }
1304
+ // Release the slot once the response settles — on resolve OR reject — via `finally`, which passes
1305
+ // the value/rejection through unchanged. (A single settle hook, rather than separate then-arms: the
1306
+ // request pipeline resolves handler errors to a Response, so a rejection arm would be unreachable.)
1307
+ if (outcome instanceof Promise)
1308
+ return outcome.finally(release);
1191
1309
  release();
1192
1310
  return outcome;
1193
1311
  }
@@ -1329,7 +1447,7 @@ export class Server {
1329
1447
  const originalRequest = requestOf(source);
1330
1448
  let current = source;
1331
1449
  for (let i = 0; i < hooks.length; i++) {
1332
- const outcome = hooks[i](requestOf(current));
1450
+ const outcome = hooks[i](requestOf(current), platform);
1333
1451
  if (outcome instanceof Promise) {
1334
1452
  return outcome.then((early) => this.continueOnRequest(early, i + 1, originalRequest, current, platform, finalize, wrapResponse, onTimeout, webFast));
1335
1453
  }
@@ -1361,7 +1479,7 @@ export class Server {
1361
1479
  }
1362
1480
  if (index >= this.onRequestHooks.length)
1363
1481
  break;
1364
- const outcome = this.onRequestHooks[index](requestOf(current));
1482
+ const outcome = this.onRequestHooks[index](requestOf(current), platform);
1365
1483
  early = outcome instanceof Promise ? await outcome : outcome;
1366
1484
  index++;
1367
1485
  }
@@ -1399,6 +1517,118 @@ export class Server {
1399
1517
  }
1400
1518
  /** Run a route that has already been matched by the runtime or Nifra's portable router. */
1401
1519
  runMatched(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast) {
1520
+ // An idempotency route runs its dedupe lane first; on a fresh key it delegates to the normal lanes
1521
+ // (with the body buffered). All non-idempotent routes skip straight to the lanes — no added cost.
1522
+ if (entry.idempotent !== undefined) {
1523
+ return this.runIdempotent(source, platform, entry, params, search, wrapResponse);
1524
+ }
1525
+ return this.runMatchedLanes(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast);
1526
+ }
1527
+ /**
1528
+ * Idempotency dedupe lane. Reads the `Idempotency-Key`, fingerprints the request, and consults the
1529
+ * store: a fresh key runs the handler (via {@link runMatchedLanes} with the body buffered) and stores
1530
+ * the response; a repeat key replays it without re-running; a key reused with a different body or an
1531
+ * in-flight duplicate is rejected. Every branch materializes a `Response`, then adapts to `T` via the
1532
+ * caller's `wrapResponse`, so it works on the Web fetch path and the Node-native outcome path alike.
1533
+ */
1534
+ async runIdempotent(source, platform, entry, params, search, wrapResponse) {
1535
+ const config = entry.idempotent;
1536
+ const req = requestOf(source);
1537
+ const key = req.headers.get(config.headerName);
1538
+ if (key === null || !validIdempotencyKey(key)) {
1539
+ return wrapResponse(jsonError(400, "idempotency_key_required"));
1540
+ }
1541
+ const namespace = typeof config.namespace === "function"
1542
+ ? await config.namespace(req.clone(), platform)
1543
+ : config.namespace;
1544
+ if (!validIdempotencyNamespace(namespace)) {
1545
+ return wrapResponse(jsonError(500, "idempotency_namespace_invalid"));
1546
+ }
1547
+ const read = await readBoundedBytes(req, this.maxBodyBytes);
1548
+ if (!read.ok) {
1549
+ return wrapResponse(jsonError(read.status, read.status === 413 ? "payload_too_large" : "bad_request"));
1550
+ }
1551
+ const url = urlPartsOf(req.url);
1552
+ const canonicalBody = canonicalizeIdempotencyBody(read.bytes, req.headers.get("content-type"));
1553
+ const fingerprint = await computeIdempotencyFingerprint(req.method, `${url.pathname}${url.search}`, canonicalBody, req.headers.get("content-type") ?? "");
1554
+ const begin = await config.store.begin({ namespace, key, fingerprint, ttlMs: config.ttlMs });
1555
+ if (begin.state === "replay") {
1556
+ return wrapResponse(responseFromStored(begin.response, { maxBytes: config.maxResponseBytes }));
1557
+ }
1558
+ if (begin.state === "mismatch")
1559
+ return wrapResponse(jsonError(409, "idempotency_key_reused"));
1560
+ if (begin.state === "in-flight") {
1561
+ return wrapResponse(jsonError(409, "idempotency_in_progress", { "Retry-After": "1" }));
1562
+ }
1563
+ if (begin.state === "capacity") {
1564
+ return wrapResponse(jsonError(503, "idempotency_store_capacity", { "Retry-After": "1" }));
1565
+ }
1566
+ const reservation = begin.reservation;
1567
+ // Fresh key: re-expose the buffered body as a Request so the normal lanes can read + validate it,
1568
+ // then run those lanes to a concrete Response.
1569
+ const bufferedInit = {
1570
+ method: req.method,
1571
+ headers: req.headers,
1572
+ signal: req.signal,
1573
+ };
1574
+ if (read.bytes.byteLength > 0)
1575
+ bufferedInit.body = read.bytes;
1576
+ const buffered = new Request(req.url, bufferedInit);
1577
+ let response;
1578
+ try {
1579
+ response = await this.runMatchedLanes(buffered, platform, entry, params, search, toResponse, IDENTITY_RESPONSE, RESPONSE_TIMEOUT, false);
1580
+ }
1581
+ catch (err) {
1582
+ // The lanes never throw (errors resolve to a 500 Response), but stay fail-safe: release the
1583
+ // reservation so a retry isn't wedged, then re-throw.
1584
+ await config.store.abandon({ namespace, key, reservation });
1585
+ throw err;
1586
+ }
1587
+ // Cache only successful responses; release the key on any error so the client can retry.
1588
+ if (response.status >= 200 && response.status < 300) {
1589
+ let storedResponse;
1590
+ try {
1591
+ storedResponse = await serializeResponse(response, { maxBytes: config.maxResponseBytes });
1592
+ }
1593
+ catch (error) {
1594
+ if (!(error instanceof IdempotencyResponseTooLargeError))
1595
+ throw error;
1596
+ // The effect may already have happened, so never abandon and permit a duplicate execution.
1597
+ // Commit a small terminal response under the winning key and return that same response now.
1598
+ response = jsonError(507, "idempotency_response_too_large");
1599
+ try {
1600
+ storedResponse = await serializeResponse(response, {
1601
+ maxBytes: config.maxResponseBytes,
1602
+ });
1603
+ }
1604
+ catch (terminalError) {
1605
+ if (!(terminalError instanceof IdempotencyResponseTooLargeError))
1606
+ throw terminalError;
1607
+ // Even an intentionally tiny bound must remain truthful. An empty 507 preserves terminal
1608
+ // status + replay safety without silently storing more bytes than the route permits.
1609
+ response = new Response(null, { status: 507 });
1610
+ storedResponse = await serializeResponse(response, {
1611
+ maxBytes: config.maxResponseBytes,
1612
+ });
1613
+ }
1614
+ }
1615
+ const completed = await config.store.complete({
1616
+ namespace,
1617
+ key,
1618
+ reservation,
1619
+ response: storedResponse,
1620
+ });
1621
+ if (!completed) {
1622
+ return wrapResponse(jsonError(503, "idempotency_reservation_lost", { "Retry-After": "1" }));
1623
+ }
1624
+ }
1625
+ else {
1626
+ await config.store.abandon({ namespace, key, reservation });
1627
+ }
1628
+ return wrapResponse(response);
1629
+ }
1630
+ /** Run a matched route through its selected execution lane (fused/bare/bodyOnly/queryOnly/lifecycle). */
1631
+ runMatchedLanes(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast) {
1402
1632
  // Translate the absolute wire deadline once, clamp it to local policy, then use the resulting
1403
1633
  // duration for both c.signal and c.budget. A client can only shorten work, never extend it.
1404
1634
  // Most requests have neither a local timeout nor a propagated deadline. Detect that case with
@@ -1446,6 +1676,20 @@ export class Server {
1446
1676
  // Build the context up front so `onError` always has it. Query, cookies, headers, and body helpers
1447
1677
  // stay lazy, but their accessors live on the prototype instead of allocating per-request closures.
1448
1678
  const ctx = new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
1679
+ // Ledgered routes (capabilities + effectLedger) carry a bounded per-request ledger; the
1680
+ // `useCapability` beacon appends to it, and the settle step below seals + sinks it. The route
1681
+ // was forced off the fused/contextless lanes at registration, so this branch always runs.
1682
+ let ledger;
1683
+ if (entry.ledgered !== undefined) {
1684
+ ledger = createRequestLedger({
1685
+ method: entry.ledgered.method,
1686
+ path: entry.ledgered.path,
1687
+ declared: entry.ledgered.declared,
1688
+ maxEntries: entry.ledgered.maxEntries,
1689
+ chain: entry.ledgered.chain,
1690
+ });
1691
+ attachEffectLedger(ctx, ledger);
1692
+ }
1449
1693
  if (entry.around.length === 0) {
1450
1694
  outcome = entry.bare
1451
1695
  ? this.runBare(entry, ctx, finalize, wrapResponse)
@@ -1464,6 +1708,14 @@ export class Server {
1464
1708
  ? this.runQueryOnly(entry, ctx, finalize, wrapResponse)
1465
1709
  : this.runLifecycle(entry, source, ctx, finalize, wrapResponse), finalize, wrapResponse);
1466
1710
  }
1711
+ // Settle step: seal the ledger and hand it to the sink BEFORE the response is released, on
1712
+ // success and error responses alike (partial work is exactly what an audit must capture). The
1713
+ // lanes above resolve handler errors to responses, so a plain resolve arm is sufficient.
1714
+ if (ledger !== undefined) {
1715
+ const resolved = entry.ledgered;
1716
+ const active = ledger;
1717
+ outcome = (outcome instanceof Promise ? outcome : Promise.resolve(outcome)).then((value) => this.settleEffectLedger(active, resolved, value));
1718
+ }
1467
1719
  }
1468
1720
  // The request timeout only bounds work that is actually pending — a synchronous (bare) result is
1469
1721
  // already complete and can't time out, so it's returned as-is (no 503 race, no promise).
@@ -1475,6 +1727,31 @@ export class Server {
1475
1727
  }
1476
1728
  return outcome;
1477
1729
  }
1730
+ /**
1731
+ * Seal a ledgered request and deliver the sealed ledger to the sink. An empty ledger (declared
1732
+ * capabilities, none exercised) is not delivered — there is nothing to audit. This post-effect
1733
+ * ledger is observational: transactional/durable audit belongs in
1734
+ * the owned effect transaction, so a sink outage must not turn a successful effect into a retryable
1735
+ * 500 that can duplicate it.
1736
+ */
1737
+ async settleEffectLedger(ledger, resolved, value) {
1738
+ const sealed = await ledger.seal();
1739
+ if (sealed.entries.length === 0)
1740
+ return value;
1741
+ try {
1742
+ await resolved.sink(sealed);
1743
+ }
1744
+ catch (err) {
1745
+ // Token-only logging: the route pattern and the failure name — never entry payloads (there are
1746
+ // none) and never the request.
1747
+ this.logger.error("effect ledger sink failed", {
1748
+ method: resolved.method,
1749
+ path: resolved.path,
1750
+ name: err instanceof Error ? err.name : "Error",
1751
+ });
1752
+ }
1753
+ return value;
1754
+ }
1478
1755
  /** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
1479
1756
  * successful requests can skip allocating `RequestContext`. Errors still allocate one for logging. */
1480
1757
  runContextlessBare(entry, source, params, search, signal, budget, platform, finalize, wrapResponse) {