@loomwork/middleware 0.0.0-stage → 0.1.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 (59) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/README.md +82 -2
  4. package/dist/claimCheck.d.ts +179 -0
  5. package/dist/claimCheck.d.ts.map +1 -0
  6. package/dist/claimCheck.js +241 -0
  7. package/dist/claimCheck.js.map +1 -0
  8. package/dist/index.d.ts +13 -0
  9. package/dist/index.d.ts.map +1 -0
  10. package/dist/index.js +48 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/killSwitchRegistry.d.ts +74 -0
  13. package/dist/killSwitchRegistry.d.ts.map +1 -0
  14. package/dist/killSwitchRegistry.js +108 -0
  15. package/dist/killSwitchRegistry.js.map +1 -0
  16. package/dist/metering.d.ts +81 -0
  17. package/dist/metering.d.ts.map +1 -0
  18. package/dist/metering.js +153 -0
  19. package/dist/metering.js.map +1 -0
  20. package/dist/nestedBudget.d.ts +60 -0
  21. package/dist/nestedBudget.d.ts.map +1 -0
  22. package/dist/nestedBudget.js +134 -0
  23. package/dist/nestedBudget.js.map +1 -0
  24. package/dist/profile.d.ts +38 -0
  25. package/dist/profile.d.ts.map +1 -0
  26. package/dist/profile.js +70 -0
  27. package/dist/profile.js.map +1 -0
  28. package/dist/rateLimit.d.ts +40 -0
  29. package/dist/rateLimit.d.ts.map +1 -0
  30. package/dist/rateLimit.js +83 -0
  31. package/dist/rateLimit.js.map +1 -0
  32. package/dist/redaction.d.ts +66 -0
  33. package/dist/redaction.d.ts.map +1 -0
  34. package/dist/redaction.js +141 -0
  35. package/dist/redaction.js.map +1 -0
  36. package/dist/retry.d.ts +50 -0
  37. package/dist/retry.d.ts.map +1 -0
  38. package/dist/retry.js +98 -0
  39. package/dist/retry.js.map +1 -0
  40. package/dist/safety.d.ts +93 -0
  41. package/dist/safety.d.ts.map +1 -0
  42. package/dist/safety.js +152 -0
  43. package/dist/safety.js.map +1 -0
  44. package/dist/streamTokenBudget.d.ts +58 -0
  45. package/dist/streamTokenBudget.d.ts.map +1 -0
  46. package/dist/streamTokenBudget.js +47 -0
  47. package/dist/streamTokenBudget.js.map +1 -0
  48. package/package.json +55 -3
  49. package/src/claimCheck.ts +410 -0
  50. package/src/index.ts +79 -0
  51. package/src/killSwitchRegistry.ts +168 -0
  52. package/src/metering.ts +226 -0
  53. package/src/nestedBudget.ts +200 -0
  54. package/src/profile.ts +86 -0
  55. package/src/rateLimit.ts +126 -0
  56. package/src/redaction.ts +184 -0
  57. package/src/retry.ts +133 -0
  58. package/src/safety.ts +235 -0
  59. package/src/streamTokenBudget.ts +98 -0
@@ -0,0 +1,58 @@
1
+ import type { EffectContext } from "@loomwork/effect-runtime";
2
+ import type { Budget } from "./metering";
3
+ /**
4
+ * Estimate output tokens from generated text. The default is the usual ~4
5
+ * characters-per-token heuristic — deliberately crude and deliberately visible.
6
+ * Pass a real tokenizer when the budget is tight enough for the difference to
7
+ * matter.
8
+ */
9
+ export type TokenEstimator = (text: string) => number;
10
+ export declare const estimateTokensByChars: TokenEstimator;
11
+ export interface StreamTokenBudgetOptions {
12
+ /** Swap in a real tokenizer. Default {@link estimateTokensByChars}. */
13
+ estimate?: TokenEstimator;
14
+ /**
15
+ * Check at most every N deltas.
16
+ *
17
+ * The DEFAULT estimator is O(1) — `text.length / 4`, and `.length` is a field
18
+ * read — so with it this knob buys almost nothing. It exists for a real
19
+ * tokenizer, which scans the text and is therefore O(text) per call: at every
20
+ * delta that would be quadratic over a generation, and every 8 still divides
21
+ * the same quadratic by 8 rather than removing it. If you pass a scanning
22
+ * estimator, prefer one that can score an increment.
23
+ *
24
+ * (An earlier version of this comment credited `checkEvery` with bounding a
25
+ * cost that lived in `collectStream`, not here — the caller rebuilt the whole
26
+ * text per delta before this guard was ever consulted, so the knob could not
27
+ * have helped. That is fixed; the note stays because the reasoning it corrects
28
+ * is easy to repeat.)
29
+ */
30
+ checkEvery?: number;
31
+ }
32
+ /**
33
+ * Build the `collectStream` guard that enforces a token {@link Budget} mid-flight.
34
+ *
35
+ * ```ts
36
+ * createLlmEffect({
37
+ * provider,
38
+ * stream: true,
39
+ * buildRequest,
40
+ * guard: streamTokenBudget({ maxTokens: 50_000 }),
41
+ * });
42
+ * ```
43
+ *
44
+ * On breach it emits `QUOTA_EXCEEDED` — the same durable fact
45
+ * `meteringMiddleware` writes, so an over-budget run looks the same to analytics
46
+ * whichever gate caught it — and throws `QuotaExceededError`, which propagates
47
+ * out of the `for await` and releases the HTTP call through K8.1's abort path.
48
+ *
49
+ * What is already spent comes from `ctx.resources` (K3's running totals from the
50
+ * durable fold), so the ceiling covers the execution rather than this one call.
51
+ * With no `ctx.resources` wired, only this generation's own output counts —
52
+ * degraded, but never wrong in the dangerous direction.
53
+ */
54
+ export declare function streamTokenBudget(budget: Budget, options?: StreamTokenBudgetOptions): (ctx: EffectContext) => (progress: {
55
+ text: string;
56
+ deltas: number;
57
+ }) => void;
58
+ //# sourceMappingURL=streamTokenBudget.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"streamTokenBudget.d.ts","sourceRoot":"","sources":["../src/streamTokenBudget.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAC9D,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAuBzC;;;;;GAKG;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;AAEtD,eAAO,MAAM,qBAAqB,EAAE,cAAqD,CAAC;AAE1F,MAAM,WAAW,wBAAwB;IACvC,uEAAuE;IACvE,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B;;;;;;;;;;;;;;;OAeG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,MAAM,EACd,OAAO,GAAE,wBAA6B,GACrC,CAAC,GAAG,EAAE,aAAa,KAAK,CAAC,QAAQ,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,KAAK,IAAI,CAgB9E"}
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.estimateTokensByChars = void 0;
4
+ exports.streamTokenBudget = streamTokenBudget;
5
+ const metering_1 = require("./metering");
6
+ const estimateTokensByChars = (text) => Math.ceil(text.length / 4);
7
+ exports.estimateTokensByChars = estimateTokensByChars;
8
+ /**
9
+ * Build the `collectStream` guard that enforces a token {@link Budget} mid-flight.
10
+ *
11
+ * ```ts
12
+ * createLlmEffect({
13
+ * provider,
14
+ * stream: true,
15
+ * buildRequest,
16
+ * guard: streamTokenBudget({ maxTokens: 50_000 }),
17
+ * });
18
+ * ```
19
+ *
20
+ * On breach it emits `QUOTA_EXCEEDED` — the same durable fact
21
+ * `meteringMiddleware` writes, so an over-budget run looks the same to analytics
22
+ * whichever gate caught it — and throws `QuotaExceededError`, which propagates
23
+ * out of the `for await` and releases the HTTP call through K8.1's abort path.
24
+ *
25
+ * What is already spent comes from `ctx.resources` (K3's running totals from the
26
+ * durable fold), so the ceiling covers the execution rather than this one call.
27
+ * With no `ctx.resources` wired, only this generation's own output counts —
28
+ * degraded, but never wrong in the dangerous direction.
29
+ */
30
+ function streamTokenBudget(budget, options = {}) {
31
+ const estimate = options.estimate ?? exports.estimateTokensByChars;
32
+ const checkEvery = Math.max(1, options.checkEvery ?? 8);
33
+ return (ctx) => (progress) => {
34
+ if (budget.maxTokens === undefined)
35
+ return;
36
+ if (progress.deltas % checkEvery !== 0)
37
+ return;
38
+ const alreadySpent = ctx.resources?.tokens ?? 0;
39
+ const projected = alreadySpent + estimate(progress.text);
40
+ if (projected < budget.maxTokens)
41
+ return;
42
+ const reason = `maxTokens ${budget.maxTokens} exceeded mid-stream (~${projected} estimated, ${alreadySpent} already metered)`;
43
+ ctx.emit("QUOTA_EXCEEDED", { stepId: ctx.stepId, reason });
44
+ throw new metering_1.QuotaExceededError(reason);
45
+ };
46
+ }
47
+ //# sourceMappingURL=streamTokenBudget.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"streamTokenBudget.js","sourceRoot":"","sources":["../src/streamTokenBudget.ts"],"names":[],"mappings":";;;AA8EA,8CAmBC;AA/FD,yCAAgD;AA8BzC,MAAM,qBAAqB,GAAmB,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;AAA7E,QAAA,qBAAqB,yBAAwD;AAwB1F;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,SAAgB,iBAAiB,CAC/B,MAAc,EACd,UAAoC,EAAE;IAEtC,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,IAAI,6BAAqB,CAAC;IAC3D,MAAM,UAAU,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,OAAO,CAAC,UAAU,IAAI,CAAC,CAAC,CAAC;IAExD,OAAO,CAAC,GAAkB,EAAE,EAAE,CAAC,CAAC,QAAQ,EAAE,EAAE;QAC1C,IAAI,MAAM,CAAC,SAAS,KAAK,SAAS;YAAE,OAAO;QAC3C,IAAI,QAAQ,CAAC,MAAM,GAAG,UAAU,KAAK,CAAC;YAAE,OAAO;QAE/C,MAAM,YAAY,GAAG,GAAG,CAAC,SAAS,EAAE,MAAM,IAAI,CAAC,CAAC;QAChD,MAAM,SAAS,GAAG,YAAY,GAAG,QAAQ,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;QACzD,IAAI,SAAS,GAAG,MAAM,CAAC,SAAS;YAAE,OAAO;QAEzC,MAAM,MAAM,GAAG,aAAa,MAAM,CAAC,SAAS,0BAA0B,SAAS,eAAe,YAAY,mBAAmB,CAAC;QAC9H,GAAG,CAAC,IAAI,CAAC,gBAAgB,EAAE,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;QAC3D,MAAM,IAAI,6BAAkB,CAAC,MAAM,CAAC,CAAC;IACvC,CAAC,CAAC;AACJ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "@loomwork/middleware",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "Reference effect middleware for Loom — cross-cutting concerns (retries, and later logging/idempotency/tracing) around effect execution, replay-safe and statically ordered",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "require": "./dist/index.js",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "dependencies": {
15
+ "@loomwork/classify": "^0.1.1",
16
+ "@loomwork/core": "^0.1.1",
17
+ "@loomwork/effect-runtime": "^0.1.1"
18
+ },
19
+ "devDependencies": {
20
+ "@types/node": "^22.0.0",
21
+ "typescript": "^5.6.3",
22
+ "vitest": "^2.1.8",
23
+ "zod": "^3.23.8"
24
+ },
25
+ "license": "Apache-2.0",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/chhetrisushil/loom.git",
29
+ "directory": "packages/middleware"
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "src",
34
+ "!src/**/*.test.ts",
35
+ "!src/**/*.test.tsx",
36
+ "!src/**/__tests__",
37
+ "!src/**/__fixtures__",
38
+ "!src/**/__snapshots__",
39
+ "!src/**/__golden__",
40
+ "LICENSE",
41
+ "NOTICE"
42
+ ],
43
+ "publishConfig": {
44
+ "access": "public",
45
+ "tag": "next"
46
+ },
47
+ "engines": {
48
+ "node": ">=20.0.0"
49
+ },
50
+ "scripts": {
51
+ "build": "tsc --build",
52
+ "dev": "tsc --build --watch",
53
+ "test": "vitest run",
54
+ "test:watch": "vitest",
55
+ "clean": "rm -rf dist tsconfig.tsbuildinfo",
56
+ "typecheck:tests": "tsc -p tsconfig.test.json"
57
+ }
6
58
  }
@@ -0,0 +1,410 @@
1
+ import { nestedTaintOrigin, tainted } from "@loomwork/classify";
2
+ import type { EffectMiddleware } from "@loomwork/effect-runtime";
3
+
4
+ // ── ADR 0051 D3 / ADR 0054 — claim-check: the reference IS the recorded value ─
5
+ //
6
+ // Effect results over an inline cap are stored in a content-addressed store and
7
+ // the log records `{ ref, size, digest }` AS the step output. Deliberately NOT
8
+ // ADR 0046 externalization: there is no resolving decorator on this path — the
9
+ // fold never touches the content store, replay never resolves, and erasing an
10
+ // unpinned blob is invisible to replay. Orchestration logic may branch only on
11
+ // the recorded envelope (in practice: the digest — the tool's declared
12
+ // deterministic projection). Reaching through the ref from flow control
13
+ // reintroduces nondeterminism and is lintable (CC-5, tools/*).
14
+
15
+ /**
16
+ * ADR 0143 - the execution a content operation is for (structural twin of `@loomwork/plugin-content`'s
17
+ * `ContentAccess`, ContentStore SPI 1.5.0). The middleware passes the running effect's execution on every
18
+ * `put`, so a store that seals per subject knows whose key to use; a plain store ignores it.
19
+ */
20
+ export interface ClaimCheckAccess {
21
+ readonly executionId: string;
22
+ readonly workflowId: string;
23
+ }
24
+
25
+ /** Write-side view of the content store (structural — no plugin dependency). */
26
+ export interface ClaimCheckStore {
27
+ /** Content-addressed, idempotent by hash — the `ContentStore.put` contract (ADR 0046). */
28
+ put(bytes: Uint8Array, access?: ClaimCheckAccess): Promise<{ __loomContent: string }>;
29
+ /** OPTIONAL (ST-1, ADR 0059) — streaming ingestion; required only for stream-typed results. */
30
+ putStream?(
31
+ stream: AsyncIterable<Uint8Array>,
32
+ access?: ClaimCheckAccess
33
+ ): Promise<{ __loomContent: string }>;
34
+ }
35
+
36
+ /** Read-side view, for {@link resolveClaimCheck} OUTSIDE the determinism boundary. */
37
+ export interface ClaimCheckReader {
38
+ get(ref: { __loomContent: string }, access?: ClaimCheckAccess): Promise<Uint8Array | null>;
39
+ /**
40
+ * OPTIONAL (TS-10, ADR 0062) — batched get, positionally aligned, null per
41
+ * absent entry. A `ContentStore` with `getMany` satisfies this structurally.
42
+ */
43
+ getMany?(
44
+ refs: readonly { __loomContent: string }[],
45
+ access?: ClaimCheckAccess
46
+ ): Promise<(Uint8Array | null)[]>;
47
+ }
48
+
49
+ /** What STEP_COMPLETED records in place of an over-cap effect result. */
50
+ export interface ClaimCheckEnvelope {
51
+ readonly __loomClaimCheck: true;
52
+ readonly ref: { readonly __loomContent: string };
53
+ /** Byte length of the offloaded payload. */
54
+ readonly size: number;
55
+ /** The tool's declared deterministic projection of the result (CC-2), if any. */
56
+ readonly digest?: unknown;
57
+ /**
58
+ * How the offloaded bytes decode. Absent = JSON (the classic offload path);
59
+ * stream-typed results record their declared codec ("bytes" default) —
60
+ * resolve those with {@link resolveClaimCheckBytes}, not JSON parsing.
61
+ */
62
+ readonly codec?: string;
63
+ /**
64
+ * ADR 0140 - `true`: the offloaded bytes are the ONLY copy of a value the log once held (the opt-in
65
+ * agent claim-check), so the content sweep treats the ref as LOAD-BEARING: kept while any live log,
66
+ * archive segment or snapshot cites it, freed only when that history is erased or discarded. Absent
67
+ * (the classic ADR 0054 telemetry offload): the ref is TTL-reclaimable (ADR 0055 D4).
68
+ */
69
+ readonly retain?: true;
70
+ }
71
+
72
+ // ── ST-2 (ADR 0059) — stream-typed tool results ───────────────────────────────
73
+ //
74
+ // A tool whose result is too large to hold in memory returns
75
+ // `streamResult(stream, { digest })` instead of the payload: the middleware
76
+ // streams it into the content store INSIDE the effect (transient side of the
77
+ // determinism boundary) and the recorded value is still the small envelope.
78
+ // The digest MUST come from the tool (computed while producing) — the bytes
79
+ // are consumed by the upload, so no post-hoc projection is possible.
80
+
81
+ export interface StreamedResult {
82
+ readonly __loomStreamResult: true;
83
+ readonly stream: AsyncIterable<Uint8Array>;
84
+ /** The declared deterministic projection (CC-2). Pure w.r.t. the streamed content. */
85
+ readonly digest?: unknown;
86
+ /** Recorded on the envelope; default "bytes". */
87
+ readonly codec?: string;
88
+ }
89
+
90
+ /** Wrap a byte stream as a tool result; Node `Readable`s satisfy the iterable directly. */
91
+ export function streamResult(
92
+ stream: AsyncIterable<Uint8Array>,
93
+ opts: { digest?: unknown; codec?: string } = {}
94
+ ): StreamedResult {
95
+ return {
96
+ __loomStreamResult: true,
97
+ stream,
98
+ ...(opts.digest !== undefined && { digest: opts.digest }),
99
+ ...(opts.codec !== undefined && { codec: opts.codec }),
100
+ };
101
+ }
102
+
103
+ export function isStreamResult(value: unknown): value is StreamedResult {
104
+ return (
105
+ typeof value === "object" &&
106
+ value !== null &&
107
+ (value as { __loomStreamResult?: unknown }).__loomStreamResult === true &&
108
+ typeof (value as { stream?: unknown }).stream === "object"
109
+ );
110
+ }
111
+
112
+ export function isClaimCheck(value: unknown): value is ClaimCheckEnvelope {
113
+ return (
114
+ typeof value === "object" &&
115
+ value !== null &&
116
+ (value as { __loomClaimCheck?: unknown }).__loomClaimCheck === true &&
117
+ typeof (value as { ref?: { __loomContent?: unknown } }).ref?.__loomContent === "string"
118
+ );
119
+ }
120
+
121
+ /** CC-3 — offload observability: what, where, how big. */
122
+ export interface ClaimCheckInfo {
123
+ toolId?: string;
124
+ stepId: string;
125
+ executionId: string;
126
+ workflowId: string;
127
+ size: number;
128
+ contentAddress: string;
129
+ }
130
+
131
+ export interface ClaimCheckOptions {
132
+ store: ClaimCheckStore;
133
+ /**
134
+ * Inline cap in bytes for the JSON-encoded effect result; results at or under
135
+ * the cap are recorded inline, untouched. Default 16 KiB.
136
+ */
137
+ inlineCapBytes?: number;
138
+ /**
139
+ * CC-2 — `toolId → digest` declarations. A digest is the tool's DETERMINISTIC
140
+ * projection of its result: the one part of an offloaded payload that
141
+ * orchestration may branch on, recorded inline in the envelope. MUST be a pure
142
+ * function of the result. Tools without a declared digest record `{ ref, size }`
143
+ * only — their orchestration can branch on nothing but existence.
144
+ */
145
+ digests?: Readonly<Record<string, (result: unknown) => unknown>>;
146
+ /**
147
+ * The projection used for an offloaded result whose tool has NO entry in {@link digests}
148
+ * (a per-tool digest always wins). Same contract as a digest: PURE w.r.t. the result, because it is
149
+ * recorded inline in the envelope. Unset (the default) keeps the classic behaviour: such a tool
150
+ * records `{ ref, size }` only. {@link previewDigest} is a ready-made one.
151
+ */
152
+ defaultDigest?: (result: unknown, toolId: string | undefined) => unknown;
153
+ /** CC-3 — called after each offload (metrics/log-budget regression tracking). */
154
+ onOffload?: (info: ClaimCheckInfo) => void;
155
+ /**
156
+ * ADR 0140 - mark the envelope `retain: true` (see {@link ClaimCheckEnvelope.retain}): the offloaded
157
+ * blob is audit evidence the content sweep must not TTL-reclaim. A function decides per tool id. Default
158
+ * unset: the classic reclaimable telemetry offload.
159
+ */
160
+ retain?: boolean | ((toolId: string | undefined) => boolean);
161
+ /**
162
+ * ADR 0140 - results this returns `true` for pass through UNTOUCHED, never offloaded (e.g. a media
163
+ * result, whose parts the loop renders itself). Runs before the cap.
164
+ */
165
+ passThrough?: (result: unknown, toolId: string | undefined) => boolean;
166
+ /**
167
+ * ADR 0140 - treat a result that already LOOKS like an envelope as DATA, not as a ref this middleware
168
+ * made: it is force-offloaded like any other value, so the log never records a tool-forged
169
+ * `{ __loomClaimCheck, ref }` the sweep, the resolvers or the model would trust. Default unset: an
170
+ * existing envelope passes through (stacked wiring, never double-wrapped).
171
+ */
172
+ envelopesAreData?: boolean;
173
+ }
174
+
175
+ /**
176
+ * CC-1 — claim-check middleware on the ADR 0006 seam. Runs only when the effect
177
+ * actually executes (replayed/completed steps short-circuit upstream), so replay
178
+ * determinism is automatic: the envelope is the recorded value. Crash-recovery
179
+ * re-runs re-offload the same bytes to the same content address (`put` is
180
+ * idempotent by hash), reproducing an identical envelope.
181
+ *
182
+ * Results that JSON cannot encode (circular, undefined) pass through untouched —
183
+ * offload is impossible for what the journal could not record anyway.
184
+ */
185
+ export function claimCheckMiddleware(options: ClaimCheckOptions): EffectMiddleware {
186
+ const cap = options.inlineCapBytes ?? 16 * 1024;
187
+ const encoder = new TextEncoder();
188
+ return async (ctx, _input, next): Promise<unknown> => {
189
+ const result = await next();
190
+ const access: ClaimCheckAccess = { executionId: ctx.executionId, workflowId: ctx.workflowId };
191
+ if (options.passThrough?.(result, ctx.toolId) === true) return result;
192
+ // already an envelope (stacked wiring) — never double-wrap; unless envelopes are DATA (ADR 0140 M7)
193
+ const forged = options.envelopesAreData === true && isClaimCheck(result);
194
+ if (!forged && isClaimCheck(result)) return result;
195
+ const retain =
196
+ typeof options.retain === "function" ? options.retain(ctx.toolId) : options.retain === true;
197
+
198
+ // ST-2 — stream-typed result: ALWAYS offloads (a stream is a declared-big
199
+ // payload), never buffered by the middleware (a counting wrapper is the only
200
+ // thing between the tool and the store).
201
+ if (isStreamResult(result)) {
202
+ if (options.store.putStream === undefined) {
203
+ throw new TypeError(
204
+ `claim-check: tool "${ctx.toolId ?? ctx.stepId}" returned a streamed result but the content store does not implement putStream() (ST-1, ADR 0059)`
205
+ );
206
+ }
207
+ let size = 0;
208
+ const source = result.stream;
209
+ const counted = (async function* () {
210
+ for await (const chunk of source) {
211
+ size += chunk.byteLength;
212
+ yield chunk;
213
+ }
214
+ })();
215
+ let streamedRef: { __loomContent: string };
216
+ try {
217
+ streamedRef = await options.store.putStream(counted, access);
218
+ } finally {
219
+ // W-audit leak fix: if putStream threw mid-consumption, `counted` is
220
+ // suspended at a yield and nobody would ever finalize it — resuming it
221
+ // with a return completion propagates through its for-await to the
222
+ // SOURCE iterator's return(), closing whatever the tool handed us
223
+ // (file handle, socket). A no-op when the stream was fully consumed.
224
+ await counted.return(undefined).then(
225
+ () => {},
226
+ () => {} // the offload is already failing; source cleanup errors don't mask it
227
+ );
228
+ }
229
+ const envelope: ClaimCheckEnvelope = {
230
+ __loomClaimCheck: true,
231
+ ref: { __loomContent: streamedRef.__loomContent },
232
+ size,
233
+ codec: result.codec ?? "bytes",
234
+ ...(result.digest !== undefined && { digest: result.digest }),
235
+ ...(retain && { retain: true as const }),
236
+ };
237
+ options.onOffload?.({
238
+ ...(ctx.toolId !== undefined && { toolId: ctx.toolId }),
239
+ stepId: ctx.stepId,
240
+ executionId: ctx.executionId,
241
+ workflowId: ctx.workflowId,
242
+ size,
243
+ contentAddress: streamedRef.__loomContent,
244
+ });
245
+ return envelope;
246
+ }
247
+
248
+ let json: string | undefined;
249
+ try {
250
+ json = JSON.stringify(result);
251
+ } catch {
252
+ return result;
253
+ }
254
+ if (json === undefined) return result;
255
+ const bytes = encoder.encode(json);
256
+ // ADR 0140 D11: under `envelopesAreData` a lookalike envelope NESTED anywhere in the result is data
257
+ // too: the whole result is force-offloaded, so the log never records a tool-authored
258
+ // `{ __loomClaimCheck, ref, retain }` the sweep or erasure could mistake for one this middleware wrote.
259
+ const nestedForged = options.envelopesAreData === true && json.includes('"__loomClaimCheck"');
260
+ if (!forged && !nestedForged && bytes.byteLength <= cap) return result;
261
+
262
+ const ref = await options.store.put(bytes, access);
263
+ const declared = ctx.toolId === undefined ? undefined : options.digests?.[ctx.toolId];
264
+ const projected =
265
+ declared !== undefined ? declared(result) : options.defaultDigest?.(result, ctx.toolId);
266
+ const envelope: ClaimCheckEnvelope = {
267
+ __loomClaimCheck: true,
268
+ ref: { __loomContent: ref.__loomContent },
269
+ size: bytes.byteLength,
270
+ ...(projected !== undefined && { digest: projected }),
271
+ ...(retain && { retain: true as const }),
272
+ };
273
+ options.onOffload?.({
274
+ ...(ctx.toolId !== undefined && { toolId: ctx.toolId }),
275
+ stepId: ctx.stepId,
276
+ executionId: ctx.executionId,
277
+ workflowId: ctx.workflowId,
278
+ size: bytes.byteLength,
279
+ contentAddress: ref.__loomContent,
280
+ });
281
+ // ADR 0085 / 0140 - an offloaded result that carried untrusted content (a tainted top level OR a
282
+ // nested marker) must not launder it into a clean-looking envelope + digest: the envelope itself is
283
+ // tainted, so the loop fences it and records the taint (the sweep still finds the inner envelope).
284
+ let origin: string | undefined;
285
+ if (json.includes("__loomTainted")) {
286
+ // Scan the SERIALIZED form (what the log and the prompt hold). A marker that is only text still
287
+ // taints (fail closed).
288
+ try {
289
+ origin = nestedTaintOrigin(JSON.parse(json)) ?? "unknown:taint-marker-in-text";
290
+ } catch {
291
+ origin = "unknown:taint-marker-in-text";
292
+ }
293
+ }
294
+ return origin === undefined ? envelope : tainted(envelope, origin);
295
+ };
296
+ }
297
+
298
+ /** What {@link previewDigest} records: the head of the result's JSON text. */
299
+ export interface PreviewDigest {
300
+ /** The first `maxChars` UTF-16 code units of `JSON.stringify(result)`, never ending mid-surrogate-pair. */
301
+ readonly preview: string;
302
+ /** `true` when the result's JSON continues past the preview (the tail is NOT shown); absent when the preview is the whole text. */
303
+ readonly truncated?: true;
304
+ }
305
+
306
+ /**
307
+ * A ready-made `defaultDigest` (CC-2): the first `maxChars` (default 256) characters of the result's
308
+ * JSON text, so the model sees what an offloaded tool result BEGAN with instead of a bare hash. Pure
309
+ * and deterministic (a function of the result only). It is NOT a summary: it is a head, and it can cut
310
+ * a JSON string anywhere.
311
+ *
312
+ * Untrusted content is never previewed: a result carrying an ADR 0085 taint marker ANYWHERE (a nested
313
+ * `{ __loomTainted }` wrapper, such as an A2A reply's `content`) yields no digest at all, because a
314
+ * digest rides into the model's prompt as ordinary text and would launder the taint. A result JSON cannot
315
+ * encode yields none either.
316
+ */
317
+ export function previewDigest(
318
+ maxChars = 256
319
+ ): (result: unknown, toolId?: string) => PreviewDigest | undefined {
320
+ return (result) => {
321
+ let json: string | undefined;
322
+ try {
323
+ json = JSON.stringify(result);
324
+ } catch {
325
+ return undefined;
326
+ }
327
+ if (json === undefined || json.includes('"__loomTainted"')) return undefined;
328
+ let end = Math.min(maxChars, json.length);
329
+ // Never end on a lone high surrogate (it would not round-trip through UTF-8).
330
+ if (end < json.length && end > 0) {
331
+ const last = json.charCodeAt(end - 1);
332
+ if (last >= 0xd800 && last <= 0xdbff) end--;
333
+ }
334
+ return { preview: json.slice(0, end), ...(end < json.length && { truncated: true as const }) };
335
+ };
336
+ }
337
+
338
+ /**
339
+ * CC-4 — resolve an envelope back to its payload, for use OUTSIDE the
340
+ * determinism boundary (rendering evidence in a UI, exporting a report).
341
+ * Returns `null` when the bytes were erased/never stored — the store's
342
+ * null-on-absence contract, surfaced as-is so callers decide loudness.
343
+ * Flow control must NOT branch on the resolved payload (CC-5 lint).
344
+ */
345
+ export async function resolveClaimCheck<T = unknown>(
346
+ reader: ClaimCheckReader,
347
+ envelope: ClaimCheckEnvelope,
348
+ access?: ClaimCheckAccess
349
+ ): Promise<T | null> {
350
+ if (envelope.codec !== undefined && envelope.codec !== "json") {
351
+ throw new TypeError(
352
+ `resolveClaimCheck: envelope codec is "${envelope.codec}", not JSON — use resolveClaimCheckBytes for stream-offloaded content`
353
+ );
354
+ }
355
+ const bytes = await reader.get(envelope.ref, access);
356
+ if (bytes === null) return null;
357
+ return JSON.parse(new TextDecoder().decode(bytes)) as T;
358
+ }
359
+
360
+ /**
361
+ * CC-4 for stream-offloaded content (ST-2): the raw bytes, `null` on absence.
362
+ * Same boundary rule as {@link resolveClaimCheck}: OUTSIDE the determinism
363
+ * boundary only.
364
+ */
365
+ export async function resolveClaimCheckBytes(
366
+ reader: ClaimCheckReader,
367
+ envelope: ClaimCheckEnvelope,
368
+ access?: ClaimCheckAccess
369
+ ): Promise<Uint8Array | null> {
370
+ return reader.get(envelope.ref, access);
371
+ }
372
+
373
+ /**
374
+ * TS-10 (ADR 0062) — the batch twin of {@link resolveClaimCheck}, for
375
+ * interactive read paths (frozen-snapshot serving: a completed run's envelopes,
376
+ * one human waiting). One `getMany` round-trip when the reader supports it
377
+ * (coalesced parallel `get`s otherwise), deduped by content address; results
378
+ * positionally aligned with `envelopes`, `null` per erased/absent entry.
379
+ * Same boundary rule: OUTSIDE the determinism boundary only.
380
+ */
381
+ export async function resolveClaimChecks<T = unknown>(
382
+ reader: ClaimCheckReader,
383
+ envelopes: readonly ClaimCheckEnvelope[],
384
+ access?: ClaimCheckAccess
385
+ ): Promise<(T | null)[]> {
386
+ for (const envelope of envelopes) {
387
+ if (envelope.codec !== undefined && envelope.codec !== "json") {
388
+ throw new TypeError(
389
+ `resolveClaimChecks: envelope codec is "${envelope.codec}", not JSON — use resolveClaimCheckBytes for stream-offloaded content`
390
+ );
391
+ }
392
+ }
393
+ const distinct = new Map<string, { __loomContent: string }>();
394
+ for (const e of envelopes) {
395
+ if (!distinct.has(e.ref.__loomContent)) distinct.set(e.ref.__loomContent, e.ref);
396
+ }
397
+ const refs = [...distinct.values()];
398
+ const fetched =
399
+ reader.getMany !== undefined
400
+ ? await reader.getMany(refs, access)
401
+ : await Promise.all(refs.map((r) => reader.get(r, access)));
402
+ const byAddress = new Map<string, Uint8Array | null>();
403
+ refs.forEach((r, i) => byAddress.set(r.__loomContent, fetched[i] ?? null));
404
+ const decoder = new TextDecoder();
405
+ return envelopes.map((e) => {
406
+ const bytes = byAddress.get(e.ref.__loomContent) ?? null;
407
+ // Decode per-occurrence so callers never alias one parsed object.
408
+ return bytes === null ? null : (JSON.parse(decoder.decode(bytes)) as T);
409
+ });
410
+ }
package/src/index.ts ADDED
@@ -0,0 +1,79 @@
1
+ export { effectTimingMiddleware } from "./profile";
2
+ export type { EffectProfile, EffectProfiler } from "./profile";
3
+ export { retryMiddleware, type RetryOptions } from "./retry";
4
+ export {
5
+ claimCheckMiddleware,
6
+ isClaimCheck,
7
+ isStreamResult,
8
+ previewDigest,
9
+ resolveClaimCheck,
10
+ resolveClaimCheckBytes,
11
+ resolveClaimChecks,
12
+ streamResult,
13
+ type ClaimCheckEnvelope,
14
+ type ClaimCheckInfo,
15
+ type ClaimCheckOptions,
16
+ type ClaimCheckAccess,
17
+ type ClaimCheckReader,
18
+ type ClaimCheckStore,
19
+ type PreviewDigest,
20
+ type StreamedResult,
21
+ } from "./claimCheck";
22
+ export {
23
+ meteringMiddleware,
24
+ llmUsageTokens,
25
+ QuotaExceededError,
26
+ type Budget,
27
+ type MeterInfo,
28
+ type MeteringOptions,
29
+ type Pricing,
30
+ type ResourceDelta,
31
+ } from "./metering";
32
+ export {
33
+ redactionMiddleware,
34
+ redactOutputFrom,
35
+ secretOutputPathsByStep,
36
+ secretRef,
37
+ isSecretRef,
38
+ InMemorySecretResolver,
39
+ type SecretRef,
40
+ type SecretResolver,
41
+ type RedactionOptions,
42
+ } from "./redaction";
43
+ export {
44
+ nestedBudgetMiddleware,
45
+ BudgetLedger,
46
+ type BudgetScope,
47
+ type NestedBudgetOptions,
48
+ type ScopeSpend,
49
+ } from "./nestedBudget";
50
+ export {
51
+ safetyMiddleware,
52
+ SafetyViolationError,
53
+ denyPatterns,
54
+ piiValidator,
55
+ schemaValidator,
56
+ killSwitch,
57
+ type SafetyPhase,
58
+ type SafetyCheckInfo,
59
+ type SafetyVerdict,
60
+ type SafetyValidator,
61
+ type SafetyOptions,
62
+ type SchemaLike,
63
+ } from "./safety";
64
+ export { estimateTokensByChars, streamTokenBudget } from "./streamTokenBudget";
65
+ export type { StreamTokenBudgetOptions, TokenEstimator } from "./streamTokenBudget";
66
+ export {
67
+ rateLimitMiddleware,
68
+ RateLimitExceededError,
69
+ type RateLimitOptions,
70
+ type RateLimitScope,
71
+ } from "./rateLimit";
72
+ export {
73
+ killSwitchMiddleware,
74
+ InMemoryKillSwitch,
75
+ KillSwitchEngagedError,
76
+ type KillScope,
77
+ type KillSwitchRegistry,
78
+ type KillVerdict,
79
+ } from "./killSwitchRegistry";