@loomwork/ui-hydration 0.0.0-stage → 0.1.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 (179) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/README.md +398 -2
  4. package/dist/cache.d.ts +71 -0
  5. package/dist/cache.d.ts.map +1 -0
  6. package/dist/cache.js +157 -0
  7. package/dist/cache.js.map +1 -0
  8. package/dist/chatSource.d.ts +105 -0
  9. package/dist/chatSource.d.ts.map +1 -0
  10. package/dist/chatSource.js +259 -0
  11. package/dist/chatSource.js.map +1 -0
  12. package/dist/client.d.ts +17 -0
  13. package/dist/client.d.ts.map +1 -0
  14. package/dist/client.js +36 -0
  15. package/dist/client.js.map +1 -0
  16. package/dist/contentRefs.d.ts +78 -0
  17. package/dist/contentRefs.d.ts.map +1 -0
  18. package/dist/contentRefs.js +129 -0
  19. package/dist/contentRefs.js.map +1 -0
  20. package/dist/controller.d.ts +228 -0
  21. package/dist/controller.d.ts.map +1 -0
  22. package/dist/controller.js +454 -0
  23. package/dist/controller.js.map +1 -0
  24. package/dist/dataProvider.d.ts +53 -0
  25. package/dist/dataProvider.d.ts.map +1 -0
  26. package/dist/dataProvider.js +77 -0
  27. package/dist/dataProvider.js.map +1 -0
  28. package/dist/esm/cache.js +151 -0
  29. package/dist/esm/cache.js.map +1 -0
  30. package/dist/esm/chatSource.js +254 -0
  31. package/dist/esm/chatSource.js.map +1 -0
  32. package/dist/esm/client.js +15 -0
  33. package/dist/esm/client.js.map +1 -0
  34. package/dist/esm/contentRefs.js +119 -0
  35. package/dist/esm/contentRefs.js.map +1 -0
  36. package/dist/esm/controller.js +449 -0
  37. package/dist/esm/controller.js.map +1 -0
  38. package/dist/esm/dataProvider.js +72 -0
  39. package/dist/esm/dataProvider.js.map +1 -0
  40. package/dist/esm/index.js +15 -0
  41. package/dist/esm/index.js.map +1 -0
  42. package/dist/esm/invoker.js +2 -0
  43. package/dist/esm/invoker.js.map +1 -0
  44. package/dist/esm/invokers.js +310 -0
  45. package/dist/esm/invokers.js.map +1 -0
  46. package/dist/esm/loomEffectProvider.js +96 -0
  47. package/dist/esm/loomEffectProvider.js.map +1 -0
  48. package/dist/esm/params.js +207 -0
  49. package/dist/esm/params.js.map +1 -0
  50. package/dist/esm/path.js +24 -0
  51. package/dist/esm/path.js.map +1 -0
  52. package/dist/esm/plan.js +190 -0
  53. package/dist/esm/plan.js.map +1 -0
  54. package/dist/esm/projection.js +230 -0
  55. package/dist/esm/projection.js.map +1 -0
  56. package/dist/esm/provider.js +416 -0
  57. package/dist/esm/provider.js.map +1 -0
  58. package/dist/esm/repair.js +53 -0
  59. package/dist/esm/repair.js.map +1 -0
  60. package/dist/esm/reshape.js +156 -0
  61. package/dist/esm/reshape.js.map +1 -0
  62. package/dist/esm/restricted.js +54 -0
  63. package/dist/esm/restricted.js.map +1 -0
  64. package/dist/esm/scheduler.js +247 -0
  65. package/dist/esm/scheduler.js.map +1 -0
  66. package/dist/esm/select.js +18 -0
  67. package/dist/esm/select.js.map +1 -0
  68. package/dist/esm/shape.js +34 -0
  69. package/dist/esm/shape.js.map +1 -0
  70. package/dist/esm/split/client.js +309 -0
  71. package/dist/esm/split/client.js.map +1 -0
  72. package/dist/esm/split/index.js +3 -0
  73. package/dist/esm/split/index.js.map +1 -0
  74. package/dist/esm/split/server.js +488 -0
  75. package/dist/esm/split/server.js.map +1 -0
  76. package/dist/esm/split/shared.js +13 -0
  77. package/dist/esm/split/shared.js.map +1 -0
  78. package/dist/index.d.ts +29 -0
  79. package/dist/index.d.ts.map +1 -0
  80. package/dist/index.js +63 -0
  81. package/dist/index.js.map +1 -0
  82. package/dist/invoker.d.ts +10 -0
  83. package/dist/invoker.d.ts.map +1 -0
  84. package/dist/invoker.js +3 -0
  85. package/dist/invoker.js.map +1 -0
  86. package/dist/invokers.d.ts +36 -0
  87. package/dist/invokers.d.ts.map +1 -0
  88. package/dist/invokers.js +315 -0
  89. package/dist/invokers.js.map +1 -0
  90. package/dist/loomEffectProvider.d.ts +67 -0
  91. package/dist/loomEffectProvider.d.ts.map +1 -0
  92. package/dist/loomEffectProvider.js +101 -0
  93. package/dist/loomEffectProvider.js.map +1 -0
  94. package/dist/params.d.ts +103 -0
  95. package/dist/params.d.ts.map +1 -0
  96. package/dist/params.js +221 -0
  97. package/dist/params.js.map +1 -0
  98. package/dist/path.d.ts +12 -0
  99. package/dist/path.d.ts.map +1 -0
  100. package/dist/path.js +28 -0
  101. package/dist/path.js.map +1 -0
  102. package/dist/plan.d.ts +56 -0
  103. package/dist/plan.d.ts.map +1 -0
  104. package/dist/plan.js +199 -0
  105. package/dist/plan.js.map +1 -0
  106. package/dist/projection.d.ts +122 -0
  107. package/dist/projection.d.ts.map +1 -0
  108. package/dist/projection.js +238 -0
  109. package/dist/projection.js.map +1 -0
  110. package/dist/provider.d.ts +152 -0
  111. package/dist/provider.d.ts.map +1 -0
  112. package/dist/provider.js +421 -0
  113. package/dist/provider.js.map +1 -0
  114. package/dist/repair.d.ts +18 -0
  115. package/dist/repair.d.ts.map +1 -0
  116. package/dist/repair.js +57 -0
  117. package/dist/repair.js.map +1 -0
  118. package/dist/reshape.d.ts +32 -0
  119. package/dist/reshape.d.ts.map +1 -0
  120. package/dist/reshape.js +164 -0
  121. package/dist/reshape.js.map +1 -0
  122. package/dist/restricted.d.ts +21 -0
  123. package/dist/restricted.d.ts.map +1 -0
  124. package/dist/restricted.js +61 -0
  125. package/dist/restricted.js.map +1 -0
  126. package/dist/scheduler.d.ts +82 -0
  127. package/dist/scheduler.d.ts.map +1 -0
  128. package/dist/scheduler.js +254 -0
  129. package/dist/scheduler.js.map +1 -0
  130. package/dist/select.d.ts +34 -0
  131. package/dist/select.d.ts.map +1 -0
  132. package/dist/select.js +21 -0
  133. package/dist/select.js.map +1 -0
  134. package/dist/shape.d.ts +3 -0
  135. package/dist/shape.d.ts.map +1 -0
  136. package/dist/shape.js +37 -0
  137. package/dist/shape.js.map +1 -0
  138. package/dist/split/client.d.ts +132 -0
  139. package/dist/split/client.d.ts.map +1 -0
  140. package/dist/split/client.js +313 -0
  141. package/dist/split/client.js.map +1 -0
  142. package/dist/split/index.d.ts +5 -0
  143. package/dist/split/index.d.ts.map +1 -0
  144. package/dist/split/index.js +14 -0
  145. package/dist/split/index.js.map +1 -0
  146. package/dist/split/server.d.ts +160 -0
  147. package/dist/split/server.d.ts.map +1 -0
  148. package/dist/split/server.js +496 -0
  149. package/dist/split/server.js.map +1 -0
  150. package/dist/split/shared.d.ts +6 -0
  151. package/dist/split/shared.d.ts.map +1 -0
  152. package/dist/split/shared.js +19 -0
  153. package/dist/split/shared.js.map +1 -0
  154. package/package.json +77 -3
  155. package/src/cache.ts +211 -0
  156. package/src/chatSource.ts +374 -0
  157. package/src/client.ts +46 -0
  158. package/src/contentRefs.ts +195 -0
  159. package/src/controller.ts +708 -0
  160. package/src/dataProvider.ts +141 -0
  161. package/src/index.ts +86 -0
  162. package/src/invoker.ts +14 -0
  163. package/src/invokers.ts +348 -0
  164. package/src/loomEffectProvider.ts +136 -0
  165. package/src/params.ts +314 -0
  166. package/src/path.ts +26 -0
  167. package/src/plan.ts +197 -0
  168. package/src/projection.ts +297 -0
  169. package/src/provider.ts +657 -0
  170. package/src/repair.ts +70 -0
  171. package/src/reshape.ts +193 -0
  172. package/src/restricted.ts +69 -0
  173. package/src/scheduler.ts +284 -0
  174. package/src/select.ts +59 -0
  175. package/src/shape.ts +39 -0
  176. package/src/split/client.ts +449 -0
  177. package/src/split/index.ts +18 -0
  178. package/src/split/server.ts +746 -0
  179. package/src/split/shared.ts +18 -0
@@ -0,0 +1,746 @@
1
+ import { MCP_INVOKE_ERRORS } from "@loomwork/plugin-mcp";
2
+ import { SANDBOX_ERRORS } from "@loomwork/plugin-sandbox";
3
+ import {
4
+ type Audience,
5
+ type ContentRefTable,
6
+ type DeclaredToolClasses,
7
+ type Experience,
8
+ type Finding,
9
+ MAX_TREE_DEPTH,
10
+ type ProviderSpec,
11
+ type ReadPolicy,
12
+ type SemanticCatalog,
13
+ type Surface,
14
+ type UndeclaredToolPolicy,
15
+ type Viewer,
16
+ experienceHandles,
17
+ handleKindsWithMedia,
18
+ hasMediaTypeNodes,
19
+ isChatKind,
20
+ isMediaKind,
21
+ mergeReadPolicy,
22
+ treeDepth,
23
+ } from "@loomwork/ui-core";
24
+ import { type HydrationCache, specFingerprint } from "../cache";
25
+ import {
26
+ assertContentRefConfig,
27
+ contentRefEnv,
28
+ inspectContentRefs,
29
+ principalScoped,
30
+ } from "../contentRefs";
31
+ import type { DriftReport } from "../controller";
32
+ import type { TransientInvoker } from "../invoker";
33
+ import { limitInvoker, memoInvoker } from "../invokers";
34
+ import { providerParams, relevantParams, snapshotParams } from "../params";
35
+ import { isReservedHandle } from "../projection";
36
+ import { type ExecuteOptions, executeProvider, outcomeResult } from "../provider";
37
+ import { partitionByViewer, restrictedFinding, snapshotViewer } from "../restricted";
38
+ import { type HandleLoadResult, tooDeep } from "../scheduler";
39
+ import type { HandleInventory, HandleLoadRequest, HandleRef } from "./client";
40
+ import { errorMessage, refuse, reservedFinding } from "./shared";
41
+
42
+ // The SERVER half of split hydration (see `./client` for the trust boundary): the inventory a
43
+ // client may hold, and the answers to its loads. It executes providers, so it imports the sandbox
44
+ // and the MCP plugin — never bundle it for a browser.
45
+
46
+ /**
47
+ * Derives a handle's public revision from the provider's content fingerprint (`specFingerprint`).
48
+ * It must be KEYED: an unkeyed function of the fingerprint lets a client confirm a guessed provider
49
+ * (tool, arguments) offline. In production use an HMAC over a server secret, e.g.
50
+ * `createHmac("sha256", secret).update(handle + "\u0000" + fingerprint).digest("hex")`. It must be
51
+ * deterministic for as long as clients hold inventories (a rotated key re-asks every handle once),
52
+ * and it must move with the fingerprint: a per-handle constant cannot be detected here, and it
53
+ * defeats re-asking — a client never learns that the provider behind a handle changed.
54
+ */
55
+ export type RevisionFn = (handle: string, fingerprint: string) => string;
56
+
57
+ export interface InventoryOptions {
58
+ readonly revisionOf: RevisionFn;
59
+ /**
60
+ * Who the inventory is for. A handle classified above the viewer's clearance is OMITTED — not
61
+ * listed with a placeholder, so the client learns neither its name nor its revision. FAIL-CLOSED:
62
+ * absent = no clearance (one `viewer-not-supplied` warn is returned if anything is classified).
63
+ * Pass the same viewer to `serveHandles`, which refuses such a handle even if a client names it.
64
+ */
65
+ readonly viewer?: Viewer;
66
+ /**
67
+ * Who the surface is for (`@loomwork/ui-core` `Audience`, the value `compile` took). `"broadcast"`
68
+ * lists only unclassified handles, whoever the `viewer` is. Pass the same to `serveHandles`.
69
+ */
70
+ readonly audience?: Audience;
71
+ /**
72
+ * The host's DECLARED class per tool (`toolKey`), a read-time floor under every handle calling
73
+ * that tool (`@loomwork/ui-core` `DeclaredToolClasses`): a handle whose stored nodes carry no class
74
+ * is still gated as strictly as its tool is declared. Absent = only the experience's own classes.
75
+ */
76
+ readonly declaredToolClasses?: DeclaredToolClasses;
77
+ /**
78
+ * What a tool the `declaredToolClasses` table does not list reads as: `"allow"` (default) = as the
79
+ * experience itself says; `"deny"` = no viewer may read it (only with a table present).
80
+ */
81
+ readonly undeclared?: UndeclaredToolPolicy;
82
+ }
83
+
84
+ /**
85
+ * What a client may know about an experience's data plane: never a provider. Throws `TypeError`
86
+ * without a `revisionOf`, or when it returns a non-string, an empty string or the fingerprint.
87
+ */
88
+ export function handleInventory(
89
+ exp: Experience,
90
+ opts: InventoryOptions
91
+ ): {
92
+ inventory: HandleInventory;
93
+ findings: Finding[];
94
+ } {
95
+ const revisionOf = opts?.revisionOf;
96
+ if (typeof revisionOf !== "function")
97
+ throw new TypeError("handleInventory: a revisionOf (a keyed hash) is required");
98
+ const findings: Finding[] = [];
99
+ const handles: HandleRef[] = [];
100
+ if (treeDepth(exp.root) > MAX_TREE_DEPTH) {
101
+ findings.push(tooDeep(exp.experienceId, MAX_TREE_DEPTH));
102
+ } else {
103
+ const split = partitionByViewer(
104
+ experienceHandles(exp, opts.declaredToolClasses, opts.undeclared),
105
+ snapshotViewer(opts.viewer, opts.audience)
106
+ );
107
+ findings.push(...split.advisory);
108
+ for (const use of split.allowed) {
109
+ if (isReservedHandle(use.handle)) {
110
+ findings.push(reservedFinding(use.handle));
111
+ continue;
112
+ }
113
+ const spec = Object.hasOwn(exp.providers, use.handle) ? exp.providers[use.handle] : undefined;
114
+ if (spec === undefined) {
115
+ const f: Finding = {
116
+ code: "unknown-handle",
117
+ severity: "error",
118
+ message: `nodes ${use.nodeIds.join(", ")} bind "${use.handle}", which has no provider`,
119
+ };
120
+ findings.push(f);
121
+ handles.push({
122
+ handle: use.handle,
123
+ priority: use.priority,
124
+ params: [],
125
+ revision: "",
126
+ finding: f,
127
+ });
128
+ continue;
129
+ }
130
+ handles.push({
131
+ handle: use.handle,
132
+ priority: use.priority,
133
+ params: providerParams(spec),
134
+ revision: publicRevision(revisionOf, use.handle, spec),
135
+ });
136
+ }
137
+ }
138
+ return {
139
+ inventory: { experienceId: exp.experienceId, versionHash: exp.versionHash, handles },
140
+ findings,
141
+ };
142
+ }
143
+
144
+ function publicRevision(revisionOf: RevisionFn, handle: string, spec: ProviderSpec): string {
145
+ const fingerprint = specFingerprint(spec);
146
+ const revision = revisionOf(handle, fingerprint);
147
+ if (typeof revision !== "string" || revision === "")
148
+ throw new TypeError(
149
+ `handleInventory: revisionOf returned ${revision === "" ? "an empty string" : `a ${typeof revision}`} for "${handle}"`
150
+ );
151
+ if (revision.includes(fingerprint))
152
+ throw new TypeError(
153
+ `handleInventory: revisionOf returned the provider fingerprint for "${handle}"; it must be keyed`
154
+ );
155
+ return revision;
156
+ }
157
+
158
+ /** The most requests one `serveHandles` call serves by default (`ServeOptions.maxBatch`). */
159
+ export const DEFAULT_MAX_BATCH = 256;
160
+
161
+ /**
162
+ * The most transient calls one `serveHandles` batch has in flight by default
163
+ * (`ServeOptions.maxConcurrentCalls`): the batch is client-sized, so the server bounds its fan-out.
164
+ */
165
+ export const DEFAULT_MAX_CONCURRENT_CALLS = 32;
166
+
167
+ export interface ServeOptions extends Omit<ExecuteOptions, "params" | "kinds" | "contentRefArgs"> {
168
+ /**
169
+ * REQUIRED (U9b): the marked content-reference argument paths per tool, derived from the offered
170
+ * capabilities with the planner's `contentRefArgsFrom(capabilities)` (persist it with the capability
171
+ * set; `parseContentRefTable` re-reads it). `"none"` is the explicit opt-out: nothing is guarded,
172
+ * and serving a media-bound handle then reports `media-content-refs-unguarded` (warn).
173
+ */
174
+ readonly contentRefArgs: ContentRefTable | "none";
175
+ /** Kinds for `"contract"` providers: the nodes binding the handle, resolved on this surface. */
176
+ readonly outputKinds?: { readonly catalog: SemanticCatalog; readonly surface: Surface };
177
+ readonly cache?: HydrationCache;
178
+ /**
179
+ * Who the answers are for. A handle bound by any node classified above the viewer's clearance is
180
+ * refused BEFORE its provider is looked up, cached or invoked, and the client is told exactly
181
+ * what it is told for a handle the experience does not bind (`unknown-handle`) — no oracle for
182
+ * which handles exist but are restricted; the full `restricted` finding goes to `onFinding`.
183
+ * FAIL-CLOSED: absent = no clearance. Unclassified handles are unaffected.
184
+ */
185
+ readonly viewer?: Viewer;
186
+ /**
187
+ * Who the surface is for (the value `compile` took). `"broadcast"` refuses every classified
188
+ * handle exactly as `unknown-handle`, even a client naming one the requesting `viewer` may read.
189
+ */
190
+ readonly audience?: Audience;
191
+ /**
192
+ * The host's DECLARED class per tool (`toolKey`), a read-time floor under every handle calling
193
+ * that tool (`@loomwork/ui-core` `DeclaredToolClasses`): a handle whose stored nodes carry no class
194
+ * is still gated as strictly as its tool is declared. Absent = only the experience's own classes.
195
+ */
196
+ readonly declaredToolClasses?: DeclaredToolClasses;
197
+ /**
198
+ * What a tool the `declaredToolClasses` table does not list reads as: `"allow"` (default) = as the
199
+ * experience itself says; `"deny"` = no viewer may read it (only with a table present).
200
+ */
201
+ readonly undeclared?: UndeclaredToolPolicy;
202
+ /** Keys a `principal`-scoped cache entry; without it such a provider is never cached. */
203
+ readonly principalId?: string;
204
+ readonly onDrift?: (report: DriftReport) => void;
205
+ /**
206
+ * Every finding a load produced, IN FULL (tool, step, script, hashes, exception text), for the
207
+ * server's own logs. What the client receives is only its public form (`publicFinding`). A
208
+ * throw here, or a rejection of the promise it returns, is swallowed: a logging hook never
209
+ * changes what a client is answered.
210
+ */
211
+ readonly onFinding?: (handle: string, finding: Finding) => unknown;
212
+ /**
213
+ * At most this many transient calls in flight across the whole batch. Default
214
+ * `DEFAULT_MAX_CONCURRENT_CALLS` (32): a client decides how many handles a batch carries, so
215
+ * the server bounds the upstream fan-out unless it is told otherwise. It bounds ONE batch: for
216
+ * a server-wide bound, wrap the invoker once in the exported `limitInvoker` and share that
217
+ * wrapped invoker across every batch.
218
+ */
219
+ readonly maxConcurrentCalls?: number;
220
+ /**
221
+ * A batch longer than this is refused whole (`batch-too-large`). Default `DEFAULT_MAX_BATCH`.
222
+ * The refusal is still one result per request, so a transport should bound request bodies.
223
+ */
224
+ readonly maxBatch?: number;
225
+ }
226
+
227
+ /**
228
+ * The failure channels loom itself declares (ADR 0060) — `mcp.invoke`'s and the sandbox's, derived
229
+ * from loom's own exports so the list cannot drift. A `tool-<channel>` / `reshape-<channel>` code
230
+ * is built from whatever channel the (pluggable) invoker threw, which is arbitrary text; only
231
+ * these are served as they are.
232
+ */
233
+ const TOOL_CHANNELS: ReadonlySet<string> = new Set(Object.keys(MCP_INVOKE_ERRORS));
234
+ const RESHAPE_CHANNELS: ReadonlySet<string> = new Set(Object.keys(SANDBOX_ERRORS));
235
+
236
+ /**
237
+ * Every other code a served finding may carry: the fixed codes hydration and the server mint
238
+ * themselves. Anything else is replaced (`load-failed` / `load-warning`) — the code is a fixed
239
+ * vocabulary, never text a provider or an invoker chose.
240
+ */
241
+ const FIXED_CODES: ReadonlySet<string> = new Set([
242
+ "tool-failed",
243
+ "tool-targetUnregistered",
244
+ "provider-kind-unregistered",
245
+ "reshape-failed",
246
+ "reshape-unregistered",
247
+ "schema-drift",
248
+ "schema-drift-proceeded",
249
+ "output-shape-drift",
250
+ "output-shape-unhashable",
251
+ "output-shape-unpinned",
252
+ "output-contract-drift",
253
+ "output-contract-missing",
254
+ "output-contract-unbound",
255
+ "param-not-overridable",
256
+ "param-templated",
257
+ "param-template-refused",
258
+ "invalid-params",
259
+ "invalid-time-token",
260
+ "argument-hook-failed",
261
+ "unresolved-step-reference",
262
+ "upstream-empty",
263
+ "dependency-failed",
264
+ "provider-failed",
265
+ "content-ref-refused",
266
+ "empty-graph",
267
+ "graph-cycle",
268
+ "aborted",
269
+ "invalid-request",
270
+ "version-mismatch",
271
+ "invalid-handle",
272
+ "unknown-handle",
273
+ "batch-too-large",
274
+ "tree-too-deep",
275
+ "hydration-internal",
276
+ ]);
277
+
278
+ function publicCode(finding: Finding): string {
279
+ const { code } = finding;
280
+ if (FIXED_CODES.has(code)) return code;
281
+ if (code.startsWith("tool-")) return TOOL_CHANNELS.has(code.slice(5)) ? code : "tool-failed";
282
+ if (code.startsWith("reshape-"))
283
+ return RESHAPE_CHANNELS.has(code.slice(8)) ? code : "reshape-failed";
284
+ return finding.severity === "warn" ? "load-warning" : "load-failed";
285
+ }
286
+
287
+ /**
288
+ * What a client may be told about a finding: a fixed text keyed by its PUBLIC code. The fixed
289
+ * codes' own cases run FIRST, so one of them that happens to share a `tool-`/`reshape-` prefix
290
+ * with an allow-listed channel code (`reshape-unregistered`, a hydration-minted code, not a
291
+ * channel) gets its own text instead of the generic prefix fallback below.
292
+ */
293
+ function publicText(code: string, severity: Finding["severity"]): string {
294
+ switch (code) {
295
+ case "schema-drift-proceeded":
296
+ return "input schema changed; served with the observed schema";
297
+ case "schema-drift":
298
+ return "input schema changed; waiting for a repair";
299
+ case "output-shape-drift":
300
+ case "output-shape-unhashable":
301
+ case "output-contract-drift":
302
+ return "the output no longer matches what it declares; waiting for a repair";
303
+ case "output-shape-unpinned":
304
+ return "the output is served unchecked";
305
+ case "output-contract-missing":
306
+ case "output-contract-unbound":
307
+ return "the output cannot be checked";
308
+ case "provider-kind-unregistered":
309
+ return "no data provider is registered for this view's source";
310
+ case "tool-targetUnregistered":
311
+ return "this view's source is not available";
312
+ case "reshape-unregistered":
313
+ return "the output could not be reshaped: the named reshaper is not registered";
314
+ case "upstream-empty":
315
+ return "no data for this view";
316
+ case "param-not-overridable":
317
+ return "a param was ignored";
318
+ case "invalid-params":
319
+ case "invalid-time-token":
320
+ return "the params were refused";
321
+ case "content-ref-refused":
322
+ return "a content-reference argument was refused";
323
+ case "aborted":
324
+ return "the load was aborted";
325
+ case "invalid-request":
326
+ return "the request is malformed";
327
+ case "version-mismatch":
328
+ return "the request is for another version of the experience";
329
+ case "invalid-handle":
330
+ return "the handle is reserved";
331
+ case "unknown-handle":
332
+ return "not a handle this experience binds";
333
+ case "batch-too-large":
334
+ return "the batch is too large";
335
+ case "tree-too-deep":
336
+ return "the experience cannot be served";
337
+ case "hydration-internal":
338
+ return "an internal error occurred";
339
+ }
340
+ if (code.startsWith("tool-")) return "a tool call failed";
341
+ if (code.startsWith("reshape-")) return "the output could not be reshaped";
342
+ return severity === "warn" ? "loaded with a warning" : "could not be loaded";
343
+ }
344
+
345
+ /**
346
+ * The public form of a finding: `severity` kept; the code kept when it is one of the fixed codes
347
+ * or a loom-declared channel (otherwise the generic `tool-failed` / `reshape-failed` /
348
+ * `load-failed` / `load-warning`); the message a template keyed by that code, naming only the
349
+ * (client-supplied) handle — never a node, step, tool, script, hash, argument, channel or
350
+ * exception text.
351
+ */
352
+ export function publicFinding(handle: string | undefined, finding: Finding): Finding {
353
+ const code = publicCode(finding);
354
+ const text = publicText(code, finding.severity);
355
+ return {
356
+ code,
357
+ severity: finding.severity,
358
+ message:
359
+ handle === undefined ? text : `handle ${JSON.stringify(handle.slice(0, 128))}: ${text}`,
360
+ };
361
+ }
362
+
363
+ type Report = ((finding: Finding) => void) & {
364
+ /** Mark a finding as already accounted for, so a later `report` of it is a no-op. */
365
+ readonly silence: (finding: Finding) => void;
366
+ };
367
+
368
+ /** Hands each finding to `onFinding` once, in full; a throwing hook is swallowed. */
369
+ function reporter(opts: ServeOptions, handle: string): Report {
370
+ const seen = new Set<Finding>();
371
+ const report = (finding: Finding): void => {
372
+ if (seen.has(finding)) return;
373
+ seen.add(finding);
374
+ try {
375
+ const ret: unknown = opts.onFinding?.(handle, finding);
376
+ // An async hook's rejection is its own: never an unhandled rejection in the server.
377
+ if (
378
+ ret !== null &&
379
+ typeof ret === "object" &&
380
+ typeof (ret as PromiseLike<unknown>).then === "function"
381
+ )
382
+ Promise.resolve(ret).catch(() => {});
383
+ } catch {
384
+ // A logging hook must never change what the client is answered (nor, if its `then`
385
+ // getter throws, what the server does).
386
+ }
387
+ };
388
+ return Object.assign(report, {
389
+ silence: (finding: Finding) => {
390
+ seen.add(finding);
391
+ },
392
+ });
393
+ }
394
+
395
+ /** Report the result's finding in full, and answer the client with its public form. */
396
+ function publicResult(
397
+ handle: string | undefined,
398
+ result: HandleLoadResult,
399
+ report: Report
400
+ ): HandleLoadResult {
401
+ if (result.finding === undefined) return result;
402
+ report(result.finding);
403
+ const finding = publicFinding(handle, result.finding);
404
+ return result.state === "ready"
405
+ ? { state: "ready", value: result.value, finding }
406
+ : { state: result.state, finding };
407
+ }
408
+
409
+ /** What one batch shares: computed once, whatever the batch asks. */
410
+ interface ServePass {
411
+ readonly exec: ExecuteOptions;
412
+ readonly bound: ReadonlySet<string>;
413
+ /** Handles bound by a node the viewer may not see: refused before anything else about them. */
414
+ readonly restricted: ReadonlySet<string>;
415
+ readonly kinds: ReadonlyMap<string, readonly string[]>;
416
+ /** Media nodes exist but the host gave no `outputKinds` (a dev warning, reported when a media handle is served). */
417
+ readonly mediaWithoutKinds: boolean;
418
+ /** Media nodes exist and the host opted out of the content-reference guard (`contentRefArgs: "none"`). */
419
+ readonly refsUnguarded: boolean;
420
+ }
421
+
422
+ /**
423
+ * The server half of split hydration: answer a BATCH of handle loads for `exp` in one pass — one
424
+ * clock read (`opts.now`, else `Date.now()` once), one step memo in front of one call cap
425
+ * (`maxConcurrentCalls`, default 32), so identical steps across the batch's providers run once.
426
+ * The memo is keyed by principal too, and one call serves one principal (`opts.principal`), so
427
+ * two viewers never share a call. Every failure, a malformed request included, is a result, one per request
428
+ * in request order (a batch that is not a list gets one `invalid-request`) — and every finding a
429
+ * client receives is its PUBLIC form (`publicFinding`); the full one goes to `onFinding`. Rejects
430
+ * with `TypeError` only for a server misconfiguration (`maxBatch`, `maxConcurrentCalls`).
431
+ */
432
+ export async function serveHandles(
433
+ exp: Experience,
434
+ requests: readonly HandleLoadRequest[],
435
+ opts: ServeOptions
436
+ ): Promise<HandleLoadResult[]> {
437
+ // Server configuration, not client input: a bad bound is a programming error, and loud.
438
+ assertContentRefConfig(opts.contentRefArgs, "serveHandles");
439
+ const max = opts.maxBatch ?? DEFAULT_MAX_BATCH;
440
+ if (!Number.isInteger(max) || max < 1)
441
+ throw new TypeError(`serveHandles: maxBatch must be a positive integer, got ${String(max)}`);
442
+ const cap = opts.maxConcurrentCalls ?? DEFAULT_MAX_CONCURRENT_CALLS;
443
+ if (!Number.isInteger(cap) || cap < 1)
444
+ throw new TypeError(
445
+ `serveHandles: maxConcurrentCalls must be a positive integer, got ${String(cap)}`
446
+ );
447
+ const batch = reporter(opts, "");
448
+ if (!Array.isArray(requests))
449
+ return [
450
+ publicResult(
451
+ undefined,
452
+ refuse("invalid-request", "a batch load is a list of load requests"),
453
+ batch
454
+ ),
455
+ ];
456
+ if (requests.length > max) {
457
+ const tooMany = publicResult(
458
+ undefined,
459
+ refuse(
460
+ "batch-too-large",
461
+ `a batch of ${requests.length} requests exceeds the bound of ${max}`
462
+ ),
463
+ batch
464
+ );
465
+ return requests.map(() => tooMany);
466
+ }
467
+ // Checked before anything walks the tree for handles or kinds.
468
+ if (treeDepth(exp.root) > MAX_TREE_DEPTH) {
469
+ const deep = publicResult(
470
+ undefined,
471
+ { state: "error", finding: tooDeep(exp.experienceId, MAX_TREE_DEPTH) },
472
+ batch
473
+ );
474
+ return requests.map(() => deep);
475
+ }
476
+ let pass: ServePass;
477
+ try {
478
+ pass = openPass(exp, opts);
479
+ } catch (e) {
480
+ const failed = publicResult(undefined, refuse("hydration-internal", errorMessage(e)), batch);
481
+ return requests.map(() => failed);
482
+ }
483
+ return Promise.all(
484
+ requests.map(async (r) => {
485
+ // Snapshotted ONCE, inside a guard: a hostile getter or Proxy costs this request, not the
486
+ // batch. `experienceId`/`versionHash`/`handle` are primitives, captured by value right here.
487
+ // `params`, an object, is NOT safe by the same copy — a field holding a live reference can
488
+ // still answer a later read differently — so `serveOne` takes its own single deep-copy-and-
489
+ // validate pass over it (`snapshotParams`) before anything reads a key of it twice.
490
+ let snapshot: HandleLoadRequest | null;
491
+ try {
492
+ snapshot =
493
+ r !== null && typeof r === "object"
494
+ ? {
495
+ experienceId: r.experienceId,
496
+ versionHash: r.versionHash,
497
+ handle: r.handle,
498
+ params: r.params,
499
+ }
500
+ : null;
501
+ } catch {
502
+ const report = reporter(opts, "");
503
+ return publicResult(
504
+ undefined,
505
+ refuse("invalid-request", "the request cannot be read"),
506
+ report
507
+ );
508
+ }
509
+ const handle = typeof snapshot?.handle === "string" ? snapshot.handle : undefined;
510
+ const report = reporter(opts, handle ?? "");
511
+ return publicResult(handle, await serveOne(exp, snapshot, opts, pass, report), report);
512
+ })
513
+ );
514
+ }
515
+
516
+ /**
517
+ * Answer ONE handle load for `exp` — a batch of one (`serveHandles`). `opts.now` should be the
518
+ * server's clock: relative time is resolved here, never on the client.
519
+ */
520
+ export async function serveHandle(
521
+ exp: Experience,
522
+ request: HandleLoadRequest,
523
+ opts: ServeOptions
524
+ ): Promise<HandleLoadResult> {
525
+ const [result] = await serveHandles(exp, [request], opts);
526
+ return result ?? refuse("hydration-internal", "no result for the request");
527
+ }
528
+
529
+ function openPass(exp: Experience, opts: ServeOptions): ServePass {
530
+ const { outputKinds, invoker, now, signal, principal } = opts;
531
+ const capped: TransientInvoker = limitInvoker(
532
+ invoker,
533
+ opts.maxConcurrentCalls ?? DEFAULT_MAX_CONCURRENT_CALLS
534
+ );
535
+ const exec: ExecuteOptions = {
536
+ // One memo per batch, in front of the cap: a memo hit never takes a slot.
537
+ invoker: memoInvoker(capped),
538
+ now: now ?? Date.now(),
539
+ ...(signal !== undefined && { signal }),
540
+ ...(principal !== undefined && { principal }),
541
+ ...(opts.concurrency !== undefined && { concurrency: opts.concurrency }),
542
+ ...(opts.captureSample !== undefined && { captureSample: opts.captureSample }),
543
+ ...(opts.argumentHook !== undefined && { argumentHook: opts.argumentHook }),
544
+ ...(opts.contentRefArgs !== "none" && { contentRefArgs: opts.contentRefArgs }),
545
+ ...(opts.acceptContentRefParam !== undefined && {
546
+ acceptContentRefParam: opts.acceptContentRefParam,
547
+ }),
548
+ ...(opts.inputDrift !== undefined && { inputDrift: opts.inputDrift }),
549
+ ...(opts.reshapers !== undefined && { reshapers: opts.reshapers }),
550
+ ...(opts.providers !== undefined && { providers: opts.providers }),
551
+ ...(outputKinds?.catalog.valueContracts !== undefined && {
552
+ contracts: outputKinds.catalog.valueContracts,
553
+ }),
554
+ };
555
+ return {
556
+ exec,
557
+ // `bound` is handle-id collection only (no class read); `restricted` below takes the table.
558
+ bound: new Set(experienceHandles(exp).map((u) => u.handle)),
559
+ restricted: new Set(
560
+ partitionByViewer(
561
+ experienceHandles(exp, opts.declaredToolClasses, opts.undeclared),
562
+ snapshotViewer(opts.viewer, opts.audience)
563
+ ).restricted.map((u) => u.handle)
564
+ ),
565
+ // U9: media-typed nodes mark their handle as media even with NO `outputKinds` (fail closed).
566
+ kinds: handleKindsWithMedia(exp, outputKinds?.catalog, outputKinds?.surface),
567
+ mediaWithoutKinds: outputKinds === undefined && hasMediaTypeNodes(exp),
568
+ refsUnguarded: opts.contentRefArgs === "none" && hasMediaTypeNodes(exp),
569
+ };
570
+ }
571
+
572
+ async function serveOne(
573
+ exp: Experience,
574
+ request: HandleLoadRequest | null,
575
+ opts: ServeOptions,
576
+ pass: ServePass,
577
+ report: Report
578
+ ): Promise<HandleLoadResult> {
579
+ try {
580
+ if (request === null || typeof request !== "object" || typeof request.handle !== "string")
581
+ return refuse("invalid-request", "a load request names a handle");
582
+ if (request.experienceId !== exp.experienceId || request.versionHash !== exp.versionHash)
583
+ return refuse(
584
+ "version-mismatch",
585
+ `the request is for ${String(request.experienceId)}@${String(request.versionHash)}, not ${exp.experienceId}@${exp.versionHash}`
586
+ );
587
+ const handle = request.handle;
588
+ if (isReservedHandle(handle)) return { state: "error", finding: reservedFinding(handle) };
589
+ // Checked first, before the provider is even looked up: a restricted handle is never loaded,
590
+ // cached or invoked, for any request, whatever its params.
591
+ if (pass.restricted.has(handle)) {
592
+ // The server's log gets the true reason ONCE; the client's answer is the public
593
+ // `unknown-handle`, which is silenced so it is not logged as a second, contradicting finding.
594
+ report(restrictedFinding());
595
+ const answer = refuse("unknown-handle", `"${handle}" is not a handle this experience binds`);
596
+ if (answer.finding !== undefined) report.silence(answer.finding);
597
+ return answer;
598
+ }
599
+ // Only what a node binds is served: a provider no node binds is not the client's to ask for.
600
+ const declared =
601
+ pass.bound.has(handle) && Object.hasOwn(exp.providers, handle)
602
+ ? exp.providers[handle]
603
+ : undefined;
604
+ if (declared === undefined)
605
+ return refuse("unknown-handle", `"${handle}" is not a handle this experience binds`);
606
+ // U9: a handle feeding a media widget is ALWAYS contract-checked, whatever the provider
607
+ // declared (a shape pin passes inline bytes or a locator into a client payload and the cache).
608
+ const mediaBound = (pass.kinds.get(handle) ?? []).some((k) => isMediaKind(k) || isChatKind(k));
609
+ const spec: ProviderSpec =
610
+ mediaBound && declared.outputCheck !== "contract"
611
+ ? { ...declared, outputCheck: "contract" }
612
+ : declared;
613
+ if (mediaBound && pass.mediaWithoutKinds)
614
+ report({
615
+ code: "media-output-kinds-missing",
616
+ severity: "warn",
617
+ message:
618
+ "the experience has media nodes and serve has no outputKinds: media handles are contract-checked by node type, but pass outputKinds so kinds resolve through the catalogue",
619
+ });
620
+ if (mediaBound && pass.refsUnguarded)
621
+ report({
622
+ code: "media-content-refs-unguarded",
623
+ severity: "warn",
624
+ message:
625
+ 'the experience has media nodes and serve runs with contentRefArgs: "none": a content-reference tool argument is not checked; derive the table with contentRefArgsFrom(capabilities)',
626
+ });
627
+ // ONE deep-copy-and-validate pass over the request's params (`snapshotParams`): the result is
628
+ // plain, detached data with no accessor of its own, so `relevantParams`/`pickParams` below can
629
+ // never re-trigger a caller-supplied getter that answered differently than the check just did.
630
+ const snap = snapshotParams(request.params ?? {});
631
+ if (!snap.ok) return { state: "error", finding: snap.finding };
632
+ const params = relevantParams(spec, snap.params);
633
+ // As in-process: only a contract provider's value is answerable to a kind.
634
+ const kinds = spec.outputCheck === "contract" ? (pass.kinds.get(handle) ?? []) : undefined;
635
+ const variant =
636
+ kinds === undefined
637
+ ? { params }
638
+ : {
639
+ params,
640
+ kinds,
641
+ ...(pass.exec.contracts !== undefined && { contracts: pass.exec.contracts }),
642
+ };
643
+ // U9b: the content-reference guard (gate included) runs BEFORE the cache is consulted: a cached
644
+ // value was produced for an argument some principal's gate accepted once, and serving it to another
645
+ // principal, or after the host revoked it, would skip the gate. A refusal is not served from the
646
+ // cache (the load below refuses it); a param that fills a guarded argument is cached per principal.
647
+ const refs = pass.exec.contentRefArgs;
648
+ const verdict =
649
+ refs === undefined
650
+ ? undefined
651
+ : inspectContentRefs(
652
+ spec,
653
+ params,
654
+ contentRefEnv(refs, spec, pass.exec.acceptContentRefParam, {
655
+ principalId: opts.principalId,
656
+ principal: pass.exec.principal,
657
+ })
658
+ );
659
+ const cacheSpec = verdict?.paramFilled ? principalScoped(spec) : spec;
660
+ const hit =
661
+ verdict?.refusal === undefined
662
+ ? opts.cache?.get(cacheSpec, opts.principalId, variant)
663
+ : undefined;
664
+ if (hit) return { state: "ready", value: hit.value };
665
+ const outcome = await executeProvider(spec, {
666
+ ...pass.exec,
667
+ params,
668
+ ...(opts.principalId !== undefined && { principalId: opts.principalId }),
669
+ ...(kinds !== undefined && { kinds }),
670
+ });
671
+ for (const f of outcome.findings) report(f);
672
+ if (!outcome.ok) report(outcome.finding);
673
+ const drift = outcome.drift ?? [];
674
+ // Never cache a result that proceeded past an input drift (`report-and-proceed`): the key
675
+ // does not carry the drift mode, so a later request served under the default (`report`)
676
+ // would get it as a clean `ready` — no stale, no `onDrift`, no `onFinding`.
677
+ if (outcome.ok && drift.length === 0)
678
+ opts.cache?.set(cacheSpec, opts.principalId, outcome.value, variant);
679
+ if (drift.length > 0)
680
+ opts.onDrift?.({
681
+ experienceId: exp.experienceId,
682
+ versionHash: exp.versionHash,
683
+ handle,
684
+ spec,
685
+ drift,
686
+ findings: outcome.findings,
687
+ // Only a PROCEEDED input drift reaches here beside a delivered answer — a value, or an
688
+ // `empty` one (the retried upstream answered with no rows). `empty` itself is never drift.
689
+ proceeded: outcome.ok || outcome.finding.code === "upstream-empty",
690
+ });
691
+ return outcomeResult(outcome);
692
+ } catch (e) {
693
+ return refuse("hydration-internal", errorMessage(e));
694
+ }
695
+ }
696
+
697
+ // ── The bound serve surface ───────────────────────────────────────────────────
698
+
699
+ /**
700
+ * `handleInventory`, `serveHandle` and `serveHandles` with the host's {@link ReadPolicy} bound
701
+ * ONCE. Every call applies it, so a host that wires the policy here cannot leak by forgetting it on
702
+ * one call (the inventory naming a handle the loader then refuses, or the reverse). A per-call
703
+ * `declaredToolClasses` / `undeclared` may only ADD to it (`mergeReadPolicy`): `{}` or `undefined`
704
+ * cannot lower what was bound. Prefer this to the free functions, which take the policy per call
705
+ * and are the low-level API.
706
+ */
707
+ export interface ServeSurface {
708
+ /** The bound policy (a copy; read-only). */
709
+ readonly policy: ReadPolicy;
710
+ readonly handleInventory: (
711
+ exp: Experience,
712
+ opts: InventoryOptions
713
+ ) => ReturnType<typeof handleInventory>;
714
+ readonly serveHandles: (
715
+ exp: Experience,
716
+ requests: readonly HandleLoadRequest[],
717
+ opts: ServeOptions
718
+ ) => Promise<HandleLoadResult[]>;
719
+ readonly serveHandle: (
720
+ exp: Experience,
721
+ request: HandleLoadRequest,
722
+ opts: ServeOptions
723
+ ) => Promise<HandleLoadResult>;
724
+ }
725
+
726
+ export function createServeSurface(policy: ReadPolicy = {}): ServeSurface {
727
+ const bound = mergeReadPolicy(policy);
728
+ const joined = <
729
+ O extends { declaredToolClasses?: DeclaredToolClasses; undeclared?: UndeclaredToolPolicy },
730
+ >(
731
+ opts: O
732
+ ): O => {
733
+ const m = mergeReadPolicy(bound, opts);
734
+ return {
735
+ ...opts,
736
+ ...(m.declaredToolClasses !== undefined && { declaredToolClasses: m.declaredToolClasses }),
737
+ ...(m.undeclared !== undefined && { undeclared: m.undeclared }),
738
+ };
739
+ };
740
+ return {
741
+ policy: bound,
742
+ handleInventory: (exp, opts) => handleInventory(exp, joined(opts)),
743
+ serveHandles: (exp, requests, opts) => serveHandles(exp, requests, joined(opts)),
744
+ serveHandle: (exp, request, opts) => serveHandle(exp, request, joined(opts)),
745
+ };
746
+ }