@nifrajs/core 1.10.0 → 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 (63) hide show
  1. package/dist/assurance.d.ts +13 -0
  2. package/dist/assurance.d.ts.map +1 -1
  3. package/dist/assurance.js +20 -0
  4. package/dist/assurance.js.map +1 -1
  5. package/dist/capabilities.d.ts +30 -1
  6. package/dist/capabilities.d.ts.map +1 -1
  7. package/dist/capabilities.js +32 -6
  8. package/dist/capabilities.js.map +1 -1
  9. package/dist/classification.d.ts +20 -0
  10. package/dist/classification.d.ts.map +1 -1
  11. package/dist/classification.js +120 -0
  12. package/dist/classification.js.map +1 -1
  13. package/dist/errors.d.ts +1 -1
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js.map +1 -1
  16. package/dist/idempotency.d.ts +59 -12
  17. package/dist/idempotency.d.ts.map +1 -1
  18. package/dist/idempotency.js +191 -20
  19. package/dist/idempotency.js.map +1 -1
  20. package/dist/index.d.ts +5 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +5 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/invariants.d.ts +66 -0
  25. package/dist/invariants.d.ts.map +1 -0
  26. package/dist/invariants.js +483 -0
  27. package/dist/invariants.js.map +1 -0
  28. package/dist/ledger.d.ts +154 -0
  29. package/dist/ledger.d.ts.map +1 -0
  30. package/dist/ledger.js +271 -0
  31. package/dist/ledger.js.map +1 -0
  32. package/dist/manifest.d.ts +81 -0
  33. package/dist/manifest.d.ts.map +1 -0
  34. package/dist/manifest.js +394 -0
  35. package/dist/manifest.js.map +1 -0
  36. package/dist/mount.d.ts +18 -0
  37. package/dist/mount.d.ts.map +1 -0
  38. package/dist/mount.js +11 -0
  39. package/dist/mount.js.map +1 -0
  40. package/dist/reflection.d.ts +3 -3
  41. package/dist/reflection.d.ts.map +1 -1
  42. package/dist/reflection.js +5 -5
  43. package/dist/reflection.js.map +1 -1
  44. package/dist/server/context.d.ts +17 -1
  45. package/dist/server/context.d.ts.map +1 -1
  46. package/dist/server/server.d.ts +23 -2
  47. package/dist/server/server.d.ts.map +1 -1
  48. package/dist/server/server.js +179 -26
  49. package/dist/server/server.js.map +1 -1
  50. package/package.json +26 -1
  51. package/src/assurance.ts +33 -0
  52. package/src/capabilities.ts +60 -4
  53. package/src/classification.ts +145 -0
  54. package/src/errors.ts +1 -0
  55. package/src/idempotency.ts +245 -30
  56. package/src/index.ts +5 -1
  57. package/src/invariants.ts +600 -0
  58. package/src/ledger.ts +430 -0
  59. package/src/manifest.ts +543 -0
  60. package/src/mount.ts +24 -0
  61. package/src/reflection.ts +11 -7
  62. package/src/server/context.ts +20 -1
  63. package/src/server/server.ts +254 -30
@@ -8,14 +8,17 @@ import {
8
8
  } from "../budget.ts"
9
9
  import { FrameworkError, RouteConfigError } from "../errors.ts"
10
10
  import {
11
+ canonicalizeIdempotencyBody,
11
12
  computeIdempotencyFingerprint,
12
13
  DEFAULT_IDEMPOTENCY_HEADER,
13
14
  DEFAULT_IDEMPOTENCY_TTL_MS,
15
+ IdempotencyResponseTooLargeError,
14
16
  type IdempotencyStore,
15
17
  MemoryIdempotencyStore,
16
18
  responseFromStored,
17
19
  serializeResponse,
18
20
  validIdempotencyKey,
21
+ validIdempotencyNamespace,
19
22
  } from "../idempotency.ts"
20
23
  import {
21
24
  CAPABILITY_GUARD,
@@ -30,6 +33,13 @@ import {
30
33
  assuranceEvidenceFor,
31
34
  NIFRA_ASSURANCE_IDS,
32
35
  } from "../internal/route-assurance.ts"
36
+ import {
37
+ attachEffectLedger,
38
+ createRequestLedger,
39
+ DEFAULT_MAX_ENTRIES,
40
+ type EffectLedgerOptions,
41
+ type RequestLedger,
42
+ } from "../ledger.ts"
33
43
  import { EMPTY_PARAMS, type Method, Router } from "../router/router.ts"
34
44
  import type {
35
45
  InferOutput,
@@ -164,7 +174,7 @@ type RawAfterHandle = (result: unknown, ctx: RawContext) => MaybePromise<unknown
164
174
  type RawErrorHandler = (error: unknown, ctx: RawContext) => MaybePromise<unknown>
165
175
  type RawAround = <T>(ctx: RawContext, next: () => MaybePromise<T>) => MaybePromise<T>
166
176
  export type OnRequestResult = Response | Request | undefined
167
- type RawOnRequest = (req: Request) => MaybePromise<OnRequestResult>
177
+ type RawOnRequest = (req: Request, platform?: Platform) => MaybePromise<OnRequestResult>
168
178
  type RawOnResponse = (response: Response, req: Request) => MaybePromise<Response>
169
179
  type RawOnResponseFinalized = (outcome: ResponseFinalization, req: Request) => MaybePromise<void>
170
180
 
@@ -173,6 +183,20 @@ interface ResolvedIdempotency {
173
183
  readonly store: IdempotencyStore
174
184
  readonly ttlMs: number
175
185
  readonly headerName: string
186
+ readonly namespace: NonNullable<RouteSchema["idempotency"]>["namespace"]
187
+ readonly maxResponseBytes: number
188
+ }
189
+
190
+ /** Registration-resolved effect-ledger wiring for one capability-declaring route. */
191
+ interface ResolvedEffectLedger {
192
+ readonly sink: EffectLedgerOptions["sink"]
193
+ readonly maxEntries: number
194
+ readonly chain: boolean
195
+ /** The registered route pattern — what the sealed ledger names (never the concrete URL). */
196
+ readonly method: string
197
+ readonly path: string
198
+ /** The route's declared capability tokens, surfaced on the sealed ledger. */
199
+ readonly declared: readonly string[]
176
200
  }
177
201
 
178
202
  interface RouteEntry {
@@ -180,6 +204,8 @@ interface RouteEntry {
180
204
  readonly schema: RouteSchema | undefined
181
205
  /** Resolved idempotency config; `undefined` = off (the dedupe lane is never entered). */
182
206
  readonly idempotent: ResolvedIdempotency | undefined
207
+ /** Resolved effect-ledger wiring; `undefined` = off (no per-request ledger, no settle step). */
208
+ readonly ledgered: ResolvedEffectLedger | undefined
183
209
  /** Per-request context extensions captured at registration (order-scoped). */
184
210
  readonly derives: ReadonlyArray<RawDerive>
185
211
  /** Static context extensions captured at registration. */
@@ -343,6 +369,16 @@ export interface ServerOptions {
343
369
  * parameters, body, tenant, or values. Routes without capability declarations keep the old hot path.
344
370
  */
345
371
  readonly onCapabilityUse?: (event: CapabilityUseEvent) => void
372
+ /**
373
+ * Enable the per-request effect ledger. Each route that declares `schema.capabilities` gets a
374
+ * bounded, token-only ledger; `useCapability(c, id, …)` appends one entry per effect, and the sink
375
+ * receives the sealed ledger when the response settles (only when it recorded entries). Token-only
376
+ * by construction — capability ids, phases, counters, digests; never payloads, values, or the
377
+ * concrete URL (the route *pattern* is recorded). Enabling it moves capability-declaring routes off
378
+ * the fused fast path (they need a per-request context + a settle step); all other routes are
379
+ * unaffected, and when unset the request path pays nothing.
380
+ */
381
+ readonly effectLedger?: EffectLedgerOptions
346
382
  /**
347
383
  * Default store backing routes that declare `schema.idempotency` without their own `store`. Defaults
348
384
  * to a shared in-process {@link MemoryIdempotencyStore} (created lazily on first idempotent route).
@@ -450,7 +486,7 @@ export interface RunningServer {
450
486
  */
451
487
  export interface Middleware {
452
488
  readonly name?: string
453
- readonly onRequest?: (req: Request) => MaybePromise<OnRequestResult>
489
+ readonly onRequest?: (req: Request, platform?: Platform) => MaybePromise<OnRequestResult>
454
490
  readonly around?: <T>(context: Context, next: () => MaybePromise<T>) => MaybePromise<T>
455
491
  readonly beforeHandle?: (context: Context) => MaybePromise<unknown>
456
492
  readonly afterHandle?: (result: unknown, context: Context) => MaybePromise<unknown>
@@ -1272,6 +1308,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1272
1308
  /** Capacity-admission gate; `undefined` = off (the request path pays nothing). */
1273
1309
  private readonly capacityGate: AdmissionController | undefined
1274
1310
  private readonly onCapabilityUse: ((event: CapabilityUseEvent) => void) | undefined
1311
+ /** Effect-ledger defaults pinned at construction; per-route `ledgered` copies add route identity. */
1312
+ private readonly effectLedger:
1313
+ | Omit<ResolvedEffectLedger, "method" | "path" | "declared">
1314
+ | undefined
1275
1315
  /** App-wide idempotency store (explicit option, else a lazily-created in-process default). */
1276
1316
  private readonly configuredIdempotencyStore: IdempotencyStore | undefined
1277
1317
  private lazyIdempotencyStore: IdempotencyStore | undefined
@@ -1336,6 +1376,23 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1336
1376
  this.gracefulSignals = options.gracefulSignals ?? false
1337
1377
  this.capacityGate = options.admission
1338
1378
  this.onCapabilityUse = options.onCapabilityUse
1379
+ if (options.effectLedger !== undefined) {
1380
+ const ledger = options.effectLedger
1381
+ if (typeof ledger.sink !== "function") {
1382
+ throw new TypeError("effectLedger.sink must be a function")
1383
+ }
1384
+ const maxEntries = ledger.maxEntries ?? DEFAULT_MAX_ENTRIES
1385
+ if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
1386
+ throw new RangeError("effectLedger.maxEntries must be a positive safe integer")
1387
+ }
1388
+ this.effectLedger = Object.freeze({
1389
+ sink: ledger.sink,
1390
+ maxEntries,
1391
+ chain: ledger.chain ?? false,
1392
+ })
1393
+ } else {
1394
+ this.effectLedger = undefined
1395
+ }
1339
1396
  this.configuredIdempotencyStore = options.idempotencyStore
1340
1397
  this.lazyIdempotencyStore = undefined
1341
1398
  this.logger = options.logger ?? jsonLogger()
@@ -1375,8 +1432,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1375
1432
  * Run before routing on the raw request. Return a `Response` to short-circuit, or a replacement
1376
1433
  * `Request` to continue routing with a rewritten method/URL/headers. Global.
1377
1434
  */
1378
- onRequest(fn: (req: Request) => MaybePromise<OnRequestResult>): this {
1379
- this.onRequestHooks.push(fn)
1435
+ onRequest(
1436
+ fn: (req: Request, platform?: Platform<EnvOf<Ctx>>) => MaybePromise<OnRequestResult>,
1437
+ ): this {
1438
+ this.onRequestHooks.push(fn as RawOnRequest)
1380
1439
  return this
1381
1440
  }
1382
1441
 
@@ -1800,6 +1859,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1800
1859
  `route handler assurance must use plugin scope (received ${invalidHandlerScope.scope})`,
1801
1860
  )
1802
1861
  }
1862
+ const authenticated = assuranceEvidenceFor(
1863
+ [...this.activeAssurance, ...handlerAssurance, ...this.globalAssurance],
1864
+ method,
1865
+ path,
1866
+ ).some((evidence) => evidence.id === NIFRA_ASSURANCE_IDS.AUTHENTICATED)
1803
1867
  const routeDecorations: Record<PropertyKey, unknown> = { ...this.decorations }
1804
1868
  if (capabilities.length > 0) {
1805
1869
  routeDecorations[CAPABILITY_GUARD] = createCapabilityGuard(
@@ -1813,11 +1877,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1813
1877
  // An idempotency route runs a dedupe lane that must buffer the body and capture the response, so it
1814
1878
  // never takes the fused/native fast path — force it onto the portable matched lane (which routes
1815
1879
  // through `fetchMatched`, where the dedupe wrapper lives).
1816
- const idempotent = this.resolveIdempotency(schema)
1880
+ const idempotent = this.resolveIdempotency(schema, authenticated)
1881
+ // A ledgered route (capabilities declared + `server({ effectLedger })`) needs a per-request
1882
+ // context to carry the ledger and a settle step to seal + sink it, so it too leaves the
1883
+ // fused/contextless fast path. Resolved per route, at registration — like the capability guard.
1884
+ const ledgered: ResolvedEffectLedger | undefined =
1885
+ capabilities.length > 0 && this.effectLedger !== undefined
1886
+ ? Object.freeze({ ...this.effectLedger, method, path, declared: capabilities })
1887
+ : undefined
1817
1888
  const bare =
1818
1889
  schema?.body === undefined &&
1819
1890
  schema?.query === undefined &&
1820
1891
  idempotent === undefined &&
1892
+ ledgered === undefined &&
1821
1893
  this.derives.length === 0 &&
1822
1894
  this.beforeHandleHooks.length === 0 &&
1823
1895
  this.afterHandleHooks.length === 0 &&
@@ -1836,6 +1908,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1836
1908
  handler: handler as unknown as InternalHandler,
1837
1909
  schema,
1838
1910
  idempotent,
1911
+ ledgered,
1839
1912
  fusedWeb,
1840
1913
  derives: [...this.derives],
1841
1914
  decorations: routeDecorations,
@@ -1883,9 +1956,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1883
1956
  }),
1884
1957
  )
1885
1958
  }
1886
- // Declaring `schema.idempotency` is the evidence that satisfies a write capability's idempotency
1887
- // requirement (capability assurance reads these ids). `durable` scope additionally proves the
1888
- // durable-command requirement.
1959
+ // Declaring `schema.idempotency` is evidence for request replay only. It deliberately never proves
1960
+ // durable command execution; that stronger evidence belongs to a command/outbox adapter.
1889
1961
  if (schema?.idempotency !== undefined) {
1890
1962
  routeAssurance.push(
1891
1963
  Object.freeze({
@@ -1894,15 +1966,6 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1894
1966
  scope: "plugin",
1895
1967
  }),
1896
1968
  )
1897
- if (schema.idempotency.scope === "durable") {
1898
- routeAssurance.push(
1899
- Object.freeze({
1900
- id: NIFRA_ASSURANCE_IDS.DURABLE_COMMAND,
1901
- source: "route-schema",
1902
- scope: "plugin",
1903
- }),
1904
- )
1905
- }
1906
1969
  }
1907
1970
  this.rawEntries.push({
1908
1971
  method,
@@ -1914,13 +1977,73 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1914
1977
  }
1915
1978
 
1916
1979
  /** Resolve a route's declared idempotency into a pinned store + defaults, or `undefined` when off. */
1917
- private resolveIdempotency(schema: RouteSchema | undefined): ResolvedIdempotency | undefined {
1980
+ private resolveIdempotency(
1981
+ schema: RouteSchema | undefined,
1982
+ authenticated: boolean,
1983
+ ): ResolvedIdempotency | undefined {
1918
1984
  const config = schema?.idempotency
1919
1985
  if (config === undefined) return undefined
1986
+ if (schema?.sse !== undefined) {
1987
+ throw new RouteConfigError(
1988
+ "INVALID_IDEMPOTENCY",
1989
+ "streaming/SSE responses cannot be used on an idempotency route",
1990
+ )
1991
+ }
1992
+ const store = config.store ?? this.defaultIdempotencyStore()
1993
+ if (config.scope === "durable" && store.durability !== "durable") {
1994
+ throw new RouteConfigError(
1995
+ "INVALID_IDEMPOTENCY",
1996
+ 'durable idempotency requires a durable store (store.durability must be "durable")',
1997
+ )
1998
+ }
1999
+ const ttlMs = config.ttlMs ?? DEFAULT_IDEMPOTENCY_TTL_MS
2000
+ if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) {
2001
+ throw new RouteConfigError(
2002
+ "INVALID_IDEMPOTENCY",
2003
+ "idempotency ttlMs must be a finite positive number",
2004
+ )
2005
+ }
2006
+ const headerName = (config.headerName ?? DEFAULT_IDEMPOTENCY_HEADER).toLowerCase()
2007
+ try {
2008
+ new Headers().set(headerName, "probe")
2009
+ } catch {
2010
+ throw new RouteConfigError(
2011
+ "INVALID_IDEMPOTENCY",
2012
+ `invalid idempotency header name ${JSON.stringify(headerName)}`,
2013
+ )
2014
+ }
2015
+ const namespace = config.namespace
2016
+ if (namespace === undefined) {
2017
+ throw new RouteConfigError(
2018
+ "INVALID_IDEMPOTENCY",
2019
+ "idempotency namespace is required; use a principal resolver or an explicit shared scope",
2020
+ )
2021
+ }
2022
+ if (typeof namespace === "string" && !validIdempotencyNamespace(namespace)) {
2023
+ throw new RouteConfigError(
2024
+ "INVALID_IDEMPOTENCY",
2025
+ `invalid idempotency namespace ${JSON.stringify(namespace)}`,
2026
+ )
2027
+ }
2028
+ if (authenticated && typeof namespace !== "function") {
2029
+ throw new RouteConfigError(
2030
+ "INVALID_IDEMPOTENCY",
2031
+ "authenticated idempotency routes require a principal namespace resolver",
2032
+ )
2033
+ }
2034
+ const maxResponseBytes = config.maxResponseBytes ?? this.maxBodyBytes
2035
+ if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes < 1) {
2036
+ throw new RouteConfigError(
2037
+ "INVALID_IDEMPOTENCY",
2038
+ "idempotency maxResponseBytes must be a positive integer",
2039
+ )
2040
+ }
1920
2041
  return Object.freeze({
1921
- store: config.store ?? this.defaultIdempotencyStore(),
1922
- ttlMs: config.ttlMs ?? DEFAULT_IDEMPOTENCY_TTL_MS,
1923
- headerName: (config.headerName ?? DEFAULT_IDEMPOTENCY_HEADER).toLowerCase(),
2042
+ store,
2043
+ ttlMs,
2044
+ headerName,
2045
+ namespace,
2046
+ maxResponseBytes,
1924
2047
  })
1925
2048
  }
1926
2049
 
@@ -2285,7 +2408,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2285
2408
  const originalRequest = requestOf(source)
2286
2409
  let current: RequestSource = source
2287
2410
  for (let i = 0; i < hooks.length; i++) {
2288
- const outcome = (hooks[i] as RawOnRequest)(requestOf(current))
2411
+ const outcome = (hooks[i] as RawOnRequest)(requestOf(current), platform)
2289
2412
  if (outcome instanceof Promise) {
2290
2413
  return outcome.then((early) =>
2291
2414
  this.continueOnRequest(
@@ -2335,7 +2458,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2335
2458
  return wrapResponse(early)
2336
2459
  }
2337
2460
  if (index >= this.onRequestHooks.length) break
2338
- const outcome = (this.onRequestHooks[index] as RawOnRequest)(requestOf(current))
2461
+ const outcome = (this.onRequestHooks[index] as RawOnRequest)(requestOf(current), platform)
2339
2462
  early = outcome instanceof Promise ? await outcome : outcome
2340
2463
  index++
2341
2464
  }
@@ -2445,6 +2568,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2445
2568
  if (key === null || !validIdempotencyKey(key)) {
2446
2569
  return wrapResponse(jsonError(400, "idempotency_key_required"))
2447
2570
  }
2571
+ const namespace =
2572
+ typeof config.namespace === "function"
2573
+ ? await config.namespace(req.clone(), platform)
2574
+ : config.namespace
2575
+ if (!validIdempotencyNamespace(namespace)) {
2576
+ return wrapResponse(jsonError(500, "idempotency_namespace_invalid"))
2577
+ }
2448
2578
  const read = await readBoundedBytes(req, this.maxBodyBytes)
2449
2579
  if (!read.ok) {
2450
2580
  return wrapResponse(
@@ -2452,22 +2582,34 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2452
2582
  )
2453
2583
  }
2454
2584
  const url = urlPartsOf(req.url)
2585
+ const canonicalBody = canonicalizeIdempotencyBody(read.bytes, req.headers.get("content-type"))
2455
2586
  const fingerprint = await computeIdempotencyFingerprint(
2456
2587
  req.method,
2457
2588
  `${url.pathname}${url.search}`,
2458
- read.bytes,
2589
+ canonicalBody,
2590
+ req.headers.get("content-type") ?? "",
2459
2591
  )
2460
2592
 
2461
- const begin = await config.store.begin(key, fingerprint, config.ttlMs)
2462
- if (begin.state === "replay") return wrapResponse(responseFromStored(begin.response))
2593
+ const begin = await config.store.begin({ namespace, key, fingerprint, ttlMs: config.ttlMs })
2594
+ if (begin.state === "replay") {
2595
+ return wrapResponse(responseFromStored(begin.response, { maxBytes: config.maxResponseBytes }))
2596
+ }
2463
2597
  if (begin.state === "mismatch") return wrapResponse(jsonError(409, "idempotency_key_reused"))
2464
2598
  if (begin.state === "in-flight") {
2465
2599
  return wrapResponse(jsonError(409, "idempotency_in_progress", { "Retry-After": "1" }))
2466
2600
  }
2601
+ if (begin.state === "capacity") {
2602
+ return wrapResponse(jsonError(503, "idempotency_store_capacity", { "Retry-After": "1" }))
2603
+ }
2604
+ const reservation = begin.reservation
2467
2605
 
2468
2606
  // Fresh key: re-expose the buffered body as a Request so the normal lanes can read + validate it,
2469
2607
  // then run those lanes to a concrete Response.
2470
- const bufferedInit: RequestInit = { method: req.method, headers: req.headers }
2608
+ const bufferedInit: RequestInit = {
2609
+ method: req.method,
2610
+ headers: req.headers,
2611
+ signal: req.signal,
2612
+ }
2471
2613
  if (read.bytes.byteLength > 0)
2472
2614
  bufferedInit.body = read.bytes as NonNullable<RequestInit["body"]>
2473
2615
  const buffered: RequestSource = new Request(req.url, bufferedInit)
@@ -2487,14 +2629,44 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2487
2629
  } catch (err) {
2488
2630
  // The lanes never throw (errors resolve to a 500 Response), but stay fail-safe: release the
2489
2631
  // reservation so a retry isn't wedged, then re-throw.
2490
- await config.store.abandon(key)
2632
+ await config.store.abandon({ namespace, key, reservation })
2491
2633
  throw err
2492
2634
  }
2493
2635
  // Cache only successful responses; release the key on any error so the client can retry.
2494
2636
  if (response.status >= 200 && response.status < 300) {
2495
- await config.store.complete(key, await serializeResponse(response))
2637
+ let storedResponse: Awaited<ReturnType<typeof serializeResponse>>
2638
+ try {
2639
+ storedResponse = await serializeResponse(response, { maxBytes: config.maxResponseBytes })
2640
+ } catch (error) {
2641
+ if (!(error instanceof IdempotencyResponseTooLargeError)) throw error
2642
+ // The effect may already have happened, so never abandon and permit a duplicate execution.
2643
+ // Commit a small terminal response under the winning key and return that same response now.
2644
+ response = jsonError(507, "idempotency_response_too_large")
2645
+ try {
2646
+ storedResponse = await serializeResponse(response, {
2647
+ maxBytes: config.maxResponseBytes,
2648
+ })
2649
+ } catch (terminalError) {
2650
+ if (!(terminalError instanceof IdempotencyResponseTooLargeError)) throw terminalError
2651
+ // Even an intentionally tiny bound must remain truthful. An empty 507 preserves terminal
2652
+ // status + replay safety without silently storing more bytes than the route permits.
2653
+ response = new Response(null, { status: 507 })
2654
+ storedResponse = await serializeResponse(response, {
2655
+ maxBytes: config.maxResponseBytes,
2656
+ })
2657
+ }
2658
+ }
2659
+ const completed = await config.store.complete({
2660
+ namespace,
2661
+ key,
2662
+ reservation,
2663
+ response: storedResponse,
2664
+ })
2665
+ if (!completed) {
2666
+ return wrapResponse(jsonError(503, "idempotency_reservation_lost", { "Retry-After": "1" }))
2667
+ }
2496
2668
  } else {
2497
- await config.store.abandon(key)
2669
+ await config.store.abandon({ namespace, key, reservation })
2498
2670
  }
2499
2671
  return wrapResponse(response)
2500
2672
  }
@@ -2584,6 +2756,20 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2584
2756
  platform,
2585
2757
  this.maxBodyBytes,
2586
2758
  )
2759
+ // Ledgered routes (capabilities + effectLedger) carry a bounded per-request ledger; the
2760
+ // `useCapability` beacon appends to it, and the settle step below seals + sinks it. The route
2761
+ // was forced off the fused/contextless lanes at registration, so this branch always runs.
2762
+ let ledger: RequestLedger | undefined
2763
+ if (entry.ledgered !== undefined) {
2764
+ ledger = createRequestLedger({
2765
+ method: entry.ledgered.method,
2766
+ path: entry.ledgered.path,
2767
+ declared: entry.ledgered.declared,
2768
+ maxEntries: entry.ledgered.maxEntries,
2769
+ chain: entry.ledgered.chain,
2770
+ })
2771
+ attachEffectLedger(ctx, ledger)
2772
+ }
2587
2773
 
2588
2774
  if (entry.around.length === 0) {
2589
2775
  outcome = entry.bare
@@ -2609,6 +2795,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2609
2795
  wrapResponse,
2610
2796
  )
2611
2797
  }
2798
+ // Settle step: seal the ledger and hand it to the sink BEFORE the response is released, on
2799
+ // success and error responses alike (partial work is exactly what an audit must capture). The
2800
+ // lanes above resolve handler errors to responses, so a plain resolve arm is sufficient.
2801
+ if (ledger !== undefined) {
2802
+ const resolved = entry.ledgered as ResolvedEffectLedger
2803
+ const active = ledger
2804
+ outcome = (outcome instanceof Promise ? outcome : Promise.resolve(outcome)).then((value) =>
2805
+ this.settleEffectLedger(active, resolved, value),
2806
+ )
2807
+ }
2612
2808
  }
2613
2809
  // The request timeout only bounds work that is actually pending — a synchronous (bare) result is
2614
2810
  // already complete and can't time out, so it's returned as-is (no 503 race, no promise).
@@ -2627,6 +2823,34 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2627
2823
  return outcome
2628
2824
  }
2629
2825
 
2826
+ /**
2827
+ * Seal a ledgered request and deliver the sealed ledger to the sink. An empty ledger (declared
2828
+ * capabilities, none exercised) is not delivered — there is nothing to audit. This post-effect
2829
+ * ledger is observational: transactional/durable audit belongs in
2830
+ * the owned effect transaction, so a sink outage must not turn a successful effect into a retryable
2831
+ * 500 that can duplicate it.
2832
+ */
2833
+ private async settleEffectLedger<T>(
2834
+ ledger: RequestLedger,
2835
+ resolved: ResolvedEffectLedger,
2836
+ value: T,
2837
+ ): Promise<T> {
2838
+ const sealed = await ledger.seal()
2839
+ if (sealed.entries.length === 0) return value
2840
+ try {
2841
+ await resolved.sink(sealed)
2842
+ } catch (err) {
2843
+ // Token-only logging: the route pattern and the failure name — never entry payloads (there are
2844
+ // none) and never the request.
2845
+ this.logger.error("effect ledger sink failed", {
2846
+ method: resolved.method,
2847
+ path: resolved.path,
2848
+ name: err instanceof Error ? err.name : "Error",
2849
+ })
2850
+ }
2851
+ return value
2852
+ }
2853
+
2630
2854
  /** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
2631
2855
  * successful requests can skip allocating `RequestContext`. Errors still allocate one for logging. */
2632
2856
  private runContextlessBare<T>(