@loomwork/ui-hydration 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 (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 +264 -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 +259 -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 +312 -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 +135 -0
  139. package/dist/split/client.d.ts.map +1 -0
  140. package/dist/split/client.js +316 -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 +378 -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 +452 -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,657 @@
1
+ import { type TaintedValue, tainted } from "@loomwork/classify";
2
+ import { TypedFailure } from "@loomwork/core";
3
+ import type { TransientInvokeOptions } from "@loomwork/effect-runtime";
4
+ import { type Shape, shapeOf } from "@loomwork/plugin-sandbox";
5
+ import {
6
+ type ContentRefTable,
7
+ type ContractRegistry,
8
+ type Finding,
9
+ type MCPToolNode,
10
+ type ProviderSpec,
11
+ checkKindValue,
12
+ hasKindContract,
13
+ stepKind,
14
+ } from "@loomwork/ui-core";
15
+ import {
16
+ type ContentRefParamGate,
17
+ checkContentRefs,
18
+ contentRefEnv,
19
+ finishContentRefArgs,
20
+ } from "./contentRefs";
21
+ import {
22
+ DEFAULT_DATA_PROVIDERS,
23
+ type DataProvider,
24
+ type DataProviderRegistry,
25
+ } from "./dataProvider";
26
+ import type { TransientInvoker } from "./invoker";
27
+ import {
28
+ type ArgumentHook,
29
+ type LoadParams,
30
+ pickParams,
31
+ prepareArguments,
32
+ providerParams,
33
+ } from "./params";
34
+ import { terminalSteps, topoWaves } from "./plan";
35
+ import {
36
+ type ReshapeFailureKind,
37
+ type ReshaperRegistry,
38
+ outputShapeHashOf,
39
+ reshape,
40
+ safeStringify,
41
+ } from "./reshape";
42
+ import type { HandleLoadResult } from "./scheduler";
43
+
44
+ // ── Executing one provider ────────────────────────────────────────────────────
45
+ // The transient interpreter (ADRs 0060–0066): walk the graph in waves, invoke `mcp.invoke` per
46
+ // step with the pinned input-schema hash, reshape the terminal output, check it. Zero events on
47
+ // this path. Each step's arguments are prepared in a fixed order (params.ts): per-load params on
48
+ // the arguments the step declares overridable, relative-time tokens against ONE instant per
49
+ // call, `{{steps.…}}` templates, then the host's argument hook. The output is checked by its
50
+ // shape pin (`outputCheck` absent or "shape") or by the value contracts of the kinds it feeds
51
+ // ("contract"). A pin that drifts is REPORTED. By default the step fails and the handle goes
52
+ // stale; under `inputDrift: "report-and-proceed"` (a read-only viewer, which cannot persist a
53
+ // repair) the step is retried ONCE with the observed hash and the value is published — the drift
54
+ // is still returned, so the app can repair it. The durable repair is always the app's, and
55
+ // `remapOperation` (repair.ts) builds its operation. Output drift is never proceeded past.
56
+
57
+ export interface DriftRecord {
58
+ /**
59
+ * `input`: a step's input-schema pin moved — a pin move `remapOperation` can repair. `output`:
60
+ * the reshaping no longer works for the tool's output — the reshaped value fails its shape pin
61
+ * or value contract, OR the reshaping itself failed on it (`reshapeFailure`) — which only a
62
+ * re-authored binding (a planner) can repair.
63
+ */
64
+ readonly kind: "input" | "output";
65
+ readonly handle: string;
66
+ /**
67
+ * The step that drifted. Input drift: the step whose schema pin drifted (always present).
68
+ * Output drift: the terminal step whose output was reshaped, set only when the graph has
69
+ * exactly one terminal — with several terminals the raw sample is a record keyed by stepId, not
70
+ * one step's output, so no single step identity applies.
71
+ */
72
+ readonly stepId?: string;
73
+ readonly toolName?: string;
74
+ /**
75
+ * Input drift only, and ONLY for a step whose kind is not MCP: that kind (`step.kind`), so a
76
+ * repairer knows `toolName` is a target of that kind, not an MCP tool. Absent for MCP — a record
77
+ * for an MCP step is exactly what it was before kinds existed.
78
+ */
79
+ readonly providerKind?: string;
80
+ readonly expected: string;
81
+ readonly observed: string;
82
+ /**
83
+ * Output drift under `outputCheck: "contract"` only: the kind whose value contract the reshaped
84
+ * value failed. `expected` is then `contract:<kind>` and `observed` the value's shape hash.
85
+ */
86
+ readonly contractKind?: string;
87
+ /**
88
+ * Output drift only, set when the RESHAPING ITSELF failed on the tool's output — and only when
89
+ * the output demonstrably caused it (`reshapeFailureKind`): the failure's finding code
90
+ * (`reshape-<channel>` / `reshape-failed`). `expected` is then `reshape` and `observed` that
91
+ * same code, and there is no reshaped value, so `observedShape` is absent — the raw output
92
+ * (`observedRawShape`, `observedSample`) is what a repair is authored against. Infrastructure
93
+ * and transient failures (a `wallClock` limit, an untyped throw, a denial, a missing script or
94
+ * reshaper, an abort) record no drift. The handle's status stays `error` (not `stale`) and the
95
+ * public finding is unchanged; the drift is reported for repair (`onDrift`).
96
+ */
97
+ readonly reshapeFailure?: string;
98
+ /** Beside `reshapeFailure`: WHY, typed — so a consumer can exclude a kind without parsing findings. */
99
+ readonly reshapeFailureKind?: ReshapeFailureKind;
100
+ /**
101
+ * Output drift only: the structural shape loom's `shapeOf` saw of the RESHAPED value — the one
102
+ * that failed the check, so it explains the mismatch. Absent when the value could not be walked,
103
+ * and on a `reshapeFailure` (there is no reshaped value).
104
+ */
105
+ readonly observedShape?: Shape;
106
+ /**
107
+ * Output drift only: the structural shape of the RAW terminal output the reshaping was applied
108
+ * to. A planner re-authors a `reshaping.scriptRef`, which is a path into the RAW output — so
109
+ * without this a record carrying no sample gives it nothing to author a path against.
110
+ * Absent when the raw value could not be walked.
111
+ */
112
+ readonly observedRawShape?: Shape;
113
+ /**
114
+ * Output drift only, and ONLY when `ExecuteOptions.captureSample` is set: the RAW terminal
115
+ * output (the single terminal step's output, or the stepId-keyed record when there are several)
116
+ * — NOT the reshaped value, because the planner consumes a sample as a capability's
117
+ * `sampleOutput`, which is raw by contract and is what a `path` is authored against. ALWAYS
118
+ * wrapped as untrusted content (ADR 0085): it is tool-authored and will be shown to a model.
119
+ */
120
+ readonly observedSample?: TaintedValue<unknown>;
121
+ }
122
+
123
+ export type ProviderOutcome =
124
+ /** `drift` only when an input drift was proceeded past (`inputDrift: "report-and-proceed"`). */
125
+ | { ok: true; value: unknown; findings: Finding[]; drift?: DriftRecord[] }
126
+ | { ok: false; finding: Finding; drift: DriftRecord[]; findings: Finding[] };
127
+
128
+ /** What a load does on an input-schema drift: go stale (`report`), or record it and retry once with the observed pin. */
129
+ export type InputDriftMode = "report" | "report-and-proceed";
130
+
131
+ export interface ExecuteOptions {
132
+ readonly invoker: TransientInvoker;
133
+ readonly signal?: AbortSignal;
134
+ readonly principal?: TransientInvokeOptions["principal"];
135
+ /** Parallel steps per wave. Default 4. */
136
+ readonly concurrency?: number;
137
+ /** Attach the RAW terminal output to an output-drift record as a tainted sample. Default false. */
138
+ readonly captureSample?: boolean;
139
+ /**
140
+ * The kinds the handle's bound nodes resolve to on the surface being hydrated (core's
141
+ * `handleKinds`). Read only when the spec's `outputCheck` is `"contract"` — which REFUSES to run
142
+ * without them: a contract check over no kinds would pass every value.
143
+ */
144
+ readonly kinds?: readonly string[];
145
+ /**
146
+ * The host's value contracts for kinds outside the framework's (`SemanticCatalog.valueContracts`).
147
+ * A `"contract"` provider feeding a kind with neither contract is REFUSED (`output-contract-missing`).
148
+ */
149
+ readonly contracts?: ContractRegistry;
150
+ /**
151
+ * Per-load params: applied only to arguments a step declares `overridable`, never to a
152
+ * `{{steps.…}}` template. A param no step declares is ignored with a `param-not-overridable` warn.
153
+ */
154
+ readonly params?: LoadParams;
155
+ /** The instant relative-time tokens resolve against (epoch ms). Default: `Date.now()`, read once. */
156
+ readonly now?: number;
157
+ /** The host's last word on each step's arguments (after params, tokens, templates). */
158
+ readonly argumentHook?: ArgumentHook;
159
+ /**
160
+ * The marked content-reference argument paths per tool (`toolKey`; core `contentRefPaths`, the
161
+ * planner's `contentRefArgsFrom`). A step whose tool is listed is refused (`media-arg-literal`, the
162
+ * call not made) when a marked argument holds a literal. Absent = nothing is marked.
163
+ */
164
+ readonly contentRefArgs?: ContentRefTable;
165
+ /** Who the load runs for, as the gate's `principalId` (the controller's `principal.id`, serve's `principalId`). */
166
+ readonly principalId?: string;
167
+ /** The host's gate on a param that fills a marked argument; absent = every such param is refused. */
168
+ readonly acceptContentRefParam?: ContentRefParamGate;
169
+ /** On an input-schema drift. Default `"report"`. */
170
+ readonly inputDrift?: InputDriftMode;
171
+ /** Where `reshaping.engine: "Named"` looks its reshaper up. */
172
+ readonly reshapers?: ReshaperRegistry;
173
+ /**
174
+ * The data-provider kinds this host composed (`defineDataProviders`). Default: MCP only, which is
175
+ * every step of an experience written before kinds existed. A step naming a kind not in here
176
+ * fails the whole provider as `provider-kind-unregistered`, before any step runs.
177
+ */
178
+ readonly providers?: DataProviderRegistry;
179
+ }
180
+
181
+ async function mapLimit<T, R>(
182
+ items: readonly T[],
183
+ limit: number,
184
+ fn: (item: T) => Promise<R>
185
+ ): Promise<R[]> {
186
+ const results = new Array<R>(items.length);
187
+ let cursor = 0;
188
+ const worker = async (): Promise<void> => {
189
+ for (;;) {
190
+ const i = cursor++;
191
+ if (i >= items.length) return;
192
+ results[i] = await fn(items[i] as T);
193
+ }
194
+ };
195
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker));
196
+ return results;
197
+ }
198
+
199
+ const err = (code: string, message: string): Finding => ({ code, severity: "error", message });
200
+
201
+ /** A step's invocation failed for a reason other than an input-schema drift. */
202
+ function stepFailure(node: MCPToolNode, e: unknown, signal: AbortSignal | undefined): Finding {
203
+ if (e instanceof TypedFailure)
204
+ return err(
205
+ `tool-${e.channel}`,
206
+ `step "${node.stepId}" (${node.toolName}) failed on channel ${e.channel}: ${safeStringify(e.payload)}`
207
+ );
208
+ if (signal?.aborted) return err("aborted", `step "${node.stepId}" aborted`);
209
+ return err(
210
+ "tool-failed",
211
+ `step "${node.stepId}" (${node.toolName}) failed: ${e instanceof Error ? e.message : String(e)}`
212
+ );
213
+ }
214
+
215
+ /**
216
+ * The shapes an output-drift record carries. Neither value has necessarily been proved walkable
217
+ * (the raw output never went through `shapeHash`): one too deep or too wide simply leaves its
218
+ * field absent — a drift REPORT must never become a throw.
219
+ */
220
+ function observedShapes(
221
+ value: unknown,
222
+ raw: unknown
223
+ ): { observedShape?: Shape; observedRawShape?: Shape } {
224
+ const out: { observedShape?: Shape; observedRawShape?: Shape } = {};
225
+ try {
226
+ out.observedShape = shapeOf(value);
227
+ } catch {
228
+ // unwalkable: leave absent
229
+ }
230
+ try {
231
+ out.observedRawShape = shapeOf(raw);
232
+ } catch {
233
+ // unwalkable: leave absent
234
+ }
235
+ return out;
236
+ }
237
+
238
+ /** The raw output's shape alone — a failed reshaping has no reshaped value to walk. */
239
+ function observedRawShape(raw: unknown): { observedRawShape?: Shape } {
240
+ try {
241
+ return { observedRawShape: shapeOf(raw) };
242
+ } catch {
243
+ return {};
244
+ }
245
+ }
246
+
247
+ /** The PUBLIC code of a step refused by the content-reference guard (literal, unproven ref, or a param the gate refused). */
248
+ export const CONTENT_REF_REFUSED = "content-ref-refused";
249
+ /**
250
+ * The step-level refusals a CLIENT can cause. An explicit set, not a `media-arg-` prefix: the family
251
+ * also holds `media-arg-schema-unknown` (a host/schema defect) and `media-arg-suspect` (a hint), neither
252
+ * of which is a refused argument.
253
+ */
254
+ const CONTENT_REF_REFUSAL_CODES: ReadonlySet<string> = new Set([
255
+ "media-arg-literal",
256
+ "media-arg-unproven-ref",
257
+ "media-arg-param-refused",
258
+ ]);
259
+ const isContentRefRefusal = (f: Finding): boolean => CONTENT_REF_REFUSAL_CODES.has(f.code);
260
+
261
+ /**
262
+ * The first finding (graph order) when every step that failed ON ITS OWN failed with the same
263
+ * one of `schema-drift` / `upstream-empty` — the rest were skipped as its dependents
264
+ * (`dependency-failed`) — or when every one of them was refused by the content-reference guard
265
+ * (then `content-ref-refused`, whichever `media-arg-*` case it was). `undefined` for anything else.
266
+ */
267
+ function onlyFailure(
268
+ steps: readonly MCPToolNode[],
269
+ failed: ReadonlyMap<string, Finding>
270
+ ): Finding | undefined {
271
+ let first: Finding | undefined;
272
+ for (const step of steps) {
273
+ const f = failed.get(step.stepId);
274
+ if (f === undefined || f.code === "dependency-failed") continue;
275
+ if (isContentRefRefusal(f)) {
276
+ if (first !== undefined && first.code !== CONTENT_REF_REFUSED) return undefined;
277
+ first ??= err(
278
+ CONTENT_REF_REFUSED,
279
+ "a content-reference argument was refused: a content address is a capability and only a reference the host vouches for is accepted"
280
+ );
281
+ continue;
282
+ }
283
+ if (f.code !== "schema-drift" && f.code !== "upstream-empty") return undefined;
284
+ if (first !== undefined && f.code !== first.code) return undefined;
285
+ first ??= f;
286
+ }
287
+ return first;
288
+ }
289
+
290
+ /** A published value; carries `drift` only when an input drift was proceeded past. */
291
+ const okOutcome = (value: unknown, findings: Finding[], drift: DriftRecord[]): ProviderOutcome =>
292
+ drift.length > 0 ? { ok: true, value, findings, drift } : { ok: true, value, findings };
293
+
294
+ export async function executeProvider(
295
+ spec: ProviderSpec,
296
+ opts: ExecuteOptions
297
+ ): Promise<ProviderOutcome> {
298
+ const findings: Finding[] = [];
299
+ const drift: DriftRecord[] = [];
300
+ const steps = spec.executionGraph;
301
+ if (steps.length === 0)
302
+ return {
303
+ ok: false,
304
+ finding: err("empty-graph", `provider "${spec.handle}" has no steps`),
305
+ drift,
306
+ findings,
307
+ };
308
+ const { waves, cycle } = topoWaves(steps);
309
+ if (cycle.length > 0)
310
+ return {
311
+ ok: false,
312
+ finding: err(
313
+ "graph-cycle",
314
+ `provider "${spec.handle}" has a dependency cycle through ${cycle.join(", ")}`
315
+ ),
316
+ drift,
317
+ findings,
318
+ };
319
+
320
+ const registry = opts.providers ?? DEFAULT_DATA_PROVIDERS;
321
+ // Every kind is resolved BEFORE any step runs: an unregistered one must not leave the steps
322
+ // before it having called their targets for an answer nobody will publish.
323
+ const unregistered = [...new Set(steps.map(stepKind))].filter((k) => !registry.has(k)).sort();
324
+ if (unregistered.length > 0)
325
+ return {
326
+ ok: false,
327
+ finding: err(
328
+ "provider-kind-unregistered",
329
+ `provider "${spec.handle}" has steps of kind ${unregistered.map((k) => `"${k}"`).join(", ")}, which no data provider is registered for`
330
+ ),
331
+ drift,
332
+ findings,
333
+ };
334
+
335
+ const now = opts.now ?? Date.now();
336
+ const supplied: LoadParams = opts.params ?? {};
337
+ const names = providerParams(spec);
338
+ const declared = new Set(names);
339
+ const undeclared = Object.keys(supplied)
340
+ .filter((k) => !declared.has(k))
341
+ .sort();
342
+ // Only the params some step declares reach a step — and the argument hook. A hook that could
343
+ // read an undeclared param could make the answer depend on a value no cache key carries.
344
+ const params = pickParams(supplied, names);
345
+ if (undeclared.length > 0)
346
+ findings.push({
347
+ code: "param-not-overridable",
348
+ severity: "warn",
349
+ message: `provider "${spec.handle}" declares no overridable argument named ${undeclared.map((k) => `"${k}"`).join(", ")}; ignored`,
350
+ });
351
+
352
+ const outputs = new Map<string, unknown>();
353
+ const failed = new Map<string, Finding>();
354
+ const warnings = new Map<string, Finding[]>();
355
+ const warn = (stepId: string, f: Finding): void => {
356
+ const list = warnings.get(stepId);
357
+ if (list === undefined) warnings.set(stepId, [f]);
358
+ else list.push(f);
359
+ };
360
+ const invokeOpts: TransientInvokeOptions = {
361
+ label: "loom-ui.hydrate",
362
+ ...(opts.signal && { signal: opts.signal }),
363
+ ...(opts.principal && { principal: opts.principal }),
364
+ };
365
+
366
+ const refEnv =
367
+ opts.contentRefArgs === undefined
368
+ ? undefined
369
+ : contentRefEnv(opts.contentRefArgs, spec, opts.acceptContentRefParam, {
370
+ principalId: opts.principalId,
371
+ principal: opts.principal,
372
+ });
373
+
374
+ for (const wave of waves) {
375
+ await mapLimit(wave, opts.concurrency ?? 4, async (node: MCPToolNode) => {
376
+ if (opts.signal?.aborted) {
377
+ failed.set(node.stepId, err("aborted", `step "${node.stepId}" aborted before it ran`));
378
+ return;
379
+ }
380
+ if (node.dependsOn.some((d) => failed.has(d))) {
381
+ failed.set(
382
+ node.stepId,
383
+ err("dependency-failed", `step "${node.stepId}" skipped: a dependency failed`)
384
+ );
385
+ return;
386
+ }
387
+ const args = prepareArguments(node, outputs, {
388
+ handle: spec.handle,
389
+ params,
390
+ now,
391
+ ...(opts.argumentHook && { hook: opts.argumentHook }),
392
+ ...(refEnv !== undefined && {
393
+ guard: (n, applied) => checkContentRefs(n, applied, refEnv).finding,
394
+ finish: (a) => finishContentRefArgs(node, a, refEnv.table),
395
+ }),
396
+ });
397
+ if (!args.ok) {
398
+ failed.set(node.stepId, args.finding);
399
+ return;
400
+ }
401
+ for (const f of args.findings) warn(node.stepId, f);
402
+ const dataProvider = registry.get(stepKind(node)) as DataProvider;
403
+ const call = (expectedPin: string): Promise<unknown> =>
404
+ dataProvider.invoke(
405
+ { stepId: node.stepId, target: node.toolName, args: args.args, expectedPin },
406
+ opts.invoker,
407
+ invokeOpts
408
+ );
409
+ try {
410
+ outputs.set(node.stepId, await call(node.toolSchemaHash));
411
+ } catch (e) {
412
+ if (!(e instanceof TypedFailure && e.channel === "schemaDrift")) {
413
+ failed.set(node.stepId, stepFailure(node, e, opts.signal));
414
+ return;
415
+ }
416
+ const observed = (e.payload as { observedHash?: string } | undefined)?.observedHash;
417
+ drift.push({
418
+ kind: "input",
419
+ handle: spec.handle,
420
+ stepId: node.stepId,
421
+ toolName: node.toolName,
422
+ ...(node.kind !== undefined && { providerKind: node.kind }),
423
+ expected: node.toolSchemaHash,
424
+ observed: observed ?? "",
425
+ });
426
+ const proceed =
427
+ opts.inputDrift === "report-and-proceed" &&
428
+ observed !== undefined &&
429
+ observed !== "" &&
430
+ observed !== node.toolSchemaHash;
431
+ if (!proceed) {
432
+ failed.set(
433
+ node.stepId,
434
+ err(
435
+ "schema-drift",
436
+ `step "${node.stepId}" (${node.toolName}) input schema drifted from ${node.toolSchemaHash} to ${observed ?? "?"}`
437
+ )
438
+ );
439
+ return;
440
+ }
441
+ try {
442
+ outputs.set(node.stepId, await call(observed));
443
+ warn(node.stepId, {
444
+ code: "schema-drift-proceeded",
445
+ severity: "warn",
446
+ message: `step "${node.stepId}" (${node.toolName}) input schema drifted from ${node.toolSchemaHash} to ${observed}; loaded once with the observed pin — the provider still needs its repair`,
447
+ });
448
+ } catch (retry) {
449
+ failed.set(node.stepId, stepFailure(node, retry, opts.signal));
450
+ }
451
+ }
452
+ });
453
+ }
454
+
455
+ // GRAPH order, not completion order: two independent steps that fail (or warn) in a race must
456
+ // produce the same finding list every run, or the surface's text depends on which tool was slower.
457
+ for (const step of steps) for (const f of warnings.get(step.stepId) ?? []) findings.push(f);
458
+ for (const step of steps) {
459
+ const f = failed.get(step.stepId);
460
+ if (f) findings.push(f);
461
+ }
462
+ if (failed.size > 0) {
463
+ // A provider that failed ONLY because of input drift (every step that failed on its own
464
+ // drifted; the rest were skipped as its dependents) answers with the first drift in graph
465
+ // order, so the surface says "waiting for a repair" rather than a generic failure. The same
466
+ // rule for an EMPTY upstream (every step that failed on its own found no such row upstream,
467
+ // `upstream-empty`): the answer is the first one, published `empty` (loom ADR 0100). Any
468
+ // other failure alongside either — or the two mixed — keeps the generic `provider-failed`.
469
+ const only = onlyFailure(steps, failed);
470
+ if (only !== undefined) return { ok: false, finding: only, drift, findings };
471
+ return {
472
+ ok: false,
473
+ finding: err(
474
+ "provider-failed",
475
+ `provider "${spec.handle}": ${failed.size} of ${steps.length} steps failed`
476
+ ),
477
+ drift,
478
+ findings,
479
+ };
480
+ }
481
+
482
+ const terminals = terminalSteps(steps);
483
+ const raw =
484
+ terminals.length === 1
485
+ ? outputs.get((terminals[0] as MCPToolNode).stepId)
486
+ : Object.fromEntries(terminals.map((t) => [t.stepId, outputs.get(t.stepId)]));
487
+
488
+ const shaped = await reshape(spec.reshaping, raw, {
489
+ invoker: opts.invoker,
490
+ ...(opts.signal && { signal: opts.signal }),
491
+ ...(opts.principal && { principal: opts.principal }),
492
+ ...(opts.reshapers && { reshapers: opts.reshapers }),
493
+ });
494
+ if (!shaped.ok) {
495
+ findings.push(shaped.finding);
496
+ // Only a failure the OUTPUT demonstrably caused (typed by `reshape`, never read from a
497
+ // message) is output drift a planner can repair — so, like a failed pin, the record carries
498
+ // the raw output's shape and (under `captureSample`) its sample. Infrastructure and transient
499
+ // failures carry no `failureKind`, and stay an error with no drift.
500
+ if (shaped.failureKind !== undefined && opts.signal?.aborted !== true)
501
+ drift.push({
502
+ kind: "output",
503
+ handle: spec.handle,
504
+ expected: "reshape",
505
+ observed: shaped.finding.code,
506
+ reshapeFailure: shaped.finding.code,
507
+ reshapeFailureKind: shaped.failureKind,
508
+ ...(terminals.length === 1 && { stepId: (terminals[0] as MCPToolNode).stepId }),
509
+ ...observedRawShape(raw),
510
+ // An uncloneable output is not a data observation: no sample, even under captureSample.
511
+ ...(opts.captureSample === true &&
512
+ shaped.failureKind !== "uncloneable-output" && {
513
+ observedSample: tainted(raw, `provider:${spec.handle}`),
514
+ }),
515
+ });
516
+ return { ok: false, finding: shaped.finding, drift, findings };
517
+ }
518
+
519
+ if (spec.outputCheck === "contract")
520
+ return checkContract(spec, shaped.value, raw, opts, drift, findings, terminals);
521
+ if (spec.outputShapeHash === "") {
522
+ findings.push({
523
+ code: "output-shape-unpinned",
524
+ severity: "warn",
525
+ message: `provider "${spec.handle}" declares no outputShapeHash; the value is unchecked`,
526
+ });
527
+ return okOutcome(shaped.value, findings, drift);
528
+ }
529
+ // The shaped value is tool-authored: `shapeHash` refuses a nest past its depth cap (and any
530
+ // value it cannot walk). An unhashable value is an UNVERIFIABLE one — reported as a failure,
531
+ // never published as if the pin had matched, and never as a throw out of this total function.
532
+ let observed: string;
533
+ try {
534
+ observed = outputShapeHashOf(shaped.value);
535
+ } catch (e) {
536
+ const f = err(
537
+ "output-shape-unhashable",
538
+ `provider "${spec.handle}" output cannot be shape-hashed, so the pinned ${spec.outputShapeHash} cannot be checked: ${e instanceof Error ? e.message : String(e)}`
539
+ );
540
+ findings.push(f);
541
+ return { ok: false, finding: f, drift, findings };
542
+ }
543
+ if (observed !== spec.outputShapeHash) {
544
+ drift.push({
545
+ kind: "output",
546
+ handle: spec.handle,
547
+ expected: spec.outputShapeHash,
548
+ observed,
549
+ ...(terminals.length === 1 && { stepId: (terminals[0] as MCPToolNode).stepId }),
550
+ ...observedShapes(shaped.value, raw),
551
+ ...(opts.captureSample === true && {
552
+ observedSample: tainted(raw, `provider:${spec.handle}`),
553
+ }),
554
+ });
555
+ const f = err(
556
+ "output-shape-drift",
557
+ `provider "${spec.handle}" output shape ${observed} differs from the pinned ${spec.outputShapeHash}`
558
+ );
559
+ findings.push(f);
560
+ return { ok: false, finding: f, drift, findings };
561
+ }
562
+ return okOutcome(shaped.value, findings, drift);
563
+ }
564
+
565
+ /**
566
+ * `outputCheck: "contract"`: the reshaped value must satisfy the value contract of EVERY kind the
567
+ * handle feeds (each widget renders the same value); kinds are checked in sorted order and the
568
+ * first failure is the drift. `undefined` is refused explicitly — `checkKindValue` passes it as
569
+ * "not hydrated yet", but a load that produced nothing is not a healthy value.
570
+ */
571
+ function checkContract(
572
+ spec: ProviderSpec,
573
+ value: unknown,
574
+ raw: unknown,
575
+ opts: ExecuteOptions,
576
+ drift: DriftRecord[],
577
+ findings: Finding[],
578
+ terminals: readonly MCPToolNode[]
579
+ ): ProviderOutcome {
580
+ const kinds = [...new Set(opts.kinds ?? [])].sort();
581
+ if (kinds.length === 0) {
582
+ const f = err(
583
+ "output-contract-unbound",
584
+ `provider "${spec.handle}" checks its output by contract, but no bound kinds were supplied; the value cannot be checked`
585
+ );
586
+ findings.push(f);
587
+ return { ok: false, finding: f, drift, findings };
588
+ }
589
+ // A contract check over a kind with NO contract passes every value, and a green check nobody
590
+ // populated reads as coverage. So it is a refusal — and no drift record: reshaping the value
591
+ // differently cannot repair a missing contract, which is the catalogue's to declare.
592
+ const uncontracted = kinds.filter((k) => !hasKindContract(k, opts.contracts));
593
+ if (uncontracted.length > 0) {
594
+ const f = err(
595
+ "output-contract-missing",
596
+ `provider "${spec.handle}" checks its output by contract, but kind${uncontracted.length > 1 ? "s" : ""} ${uncontracted.map((k) => `"${k}"`).join(", ")} ${uncontracted.length > 1 ? "have" : "has"} no value contract (declare one in the catalogue's valueContracts); the value cannot be checked`
597
+ );
598
+ findings.push(f);
599
+ return { ok: false, finding: f, drift, findings };
600
+ }
601
+ for (const kind of kinds) {
602
+ const r =
603
+ value === undefined
604
+ ? {
605
+ ok: false as const,
606
+ finding: err(
607
+ "value-contract",
608
+ `value for kind "${kind}" is undefined: the reshaping produced nothing`
609
+ ),
610
+ }
611
+ : checkKindValue(kind, value, opts.contracts);
612
+ if (r.ok) continue;
613
+ let observed = "unhashable";
614
+ try {
615
+ observed = outputShapeHashOf(value);
616
+ } catch {
617
+ // Too deep to hash: the contract failure below is still reported.
618
+ }
619
+ drift.push({
620
+ kind: "output",
621
+ handle: spec.handle,
622
+ expected: `contract:${kind}`,
623
+ observed,
624
+ contractKind: kind,
625
+ ...(terminals.length === 1 && { stepId: (terminals[0] as MCPToolNode).stepId }),
626
+ ...observedShapes(value, raw),
627
+ ...(opts.captureSample === true && {
628
+ observedSample: tainted(raw, `provider:${spec.handle}`),
629
+ }),
630
+ });
631
+ const f = err("output-contract-drift", `provider "${spec.handle}": ${r.finding.message}`);
632
+ findings.push(f);
633
+ return { ok: false, finding: f, drift, findings };
634
+ }
635
+ return okOutcome(value, findings, drift);
636
+ }
637
+
638
+ /**
639
+ * What an outcome publishes — the ONE mapping both the in-process controller and `serveHandle`
640
+ * use, so split and in-process hydration cannot disagree: a value is `ready` (carrying the first
641
+ * warning, e.g. a proceeded drift); an `upstream-empty` failure is `empty` — checked FIRST, and
642
+ * carrying no value (loom ADR 0100: a producer MUST clear the value beside `empty`); a failure
643
+ * with drift is `stale` — unless its only drift is a failed reshaping (`reshapeFailure`), which
644
+ * stays `error` as it always was — and any other failure `error`.
645
+ */
646
+ export function outcomeResult(outcome: ProviderOutcome): HandleLoadResult {
647
+ if (outcome.ok) {
648
+ const warn = outcome.findings.find((f) => f.severity === "warn");
649
+ return warn === undefined
650
+ ? { state: "ready", value: outcome.value }
651
+ : { state: "ready", value: outcome.value, finding: warn };
652
+ }
653
+ if (outcome.finding.code === "upstream-empty")
654
+ return { state: "empty", finding: outcome.finding };
655
+ const stale = outcome.drift.some((d) => d.reshapeFailure === undefined);
656
+ return { state: stale ? "stale" : "error", finding: outcome.finding };
657
+ }