@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,708 @@
1
+ import type { TransientInvokeOptions } from "@loomwork/effect-runtime";
2
+ import {
3
+ type Audience,
4
+ type ContentRefTable,
5
+ type DeclaredToolClasses,
6
+ type Experience,
7
+ type Finding,
8
+ MAX_TREE_DEPTH,
9
+ type ProviderSpec,
10
+ type ReadPolicy,
11
+ type SemanticCatalog,
12
+ type Surface,
13
+ type UndeclaredToolPolicy,
14
+ type Viewer,
15
+ checkKindValue,
16
+ experienceHandles,
17
+ handleKindsWithMedia,
18
+ hasKindContract,
19
+ hasMediaTypeNodes,
20
+ isBroadcast,
21
+ isChatKind,
22
+ isMediaKind,
23
+ mergeReadPolicy,
24
+ treeDepth,
25
+ } from "@loomwork/ui-core";
26
+ import { HydrationCache, normaliseKinds, specFingerprint } from "./cache";
27
+ import {
28
+ type ContentRefParamGate,
29
+ assertContentRefConfig,
30
+ contentRefEnv,
31
+ inspectContentRefs,
32
+ principalScoped,
33
+ } from "./contentRefs";
34
+ import type { DataProviderRegistry } from "./dataProvider";
35
+ import type { TransientInvoker } from "./invoker";
36
+ import { limitInvoker, memoInvoker, signalInvoker } from "./invokers";
37
+ import {
38
+ type ArgumentHook,
39
+ type LoadParams,
40
+ checkParams,
41
+ paramsKey,
42
+ relevantParams,
43
+ } from "./params";
44
+ import {
45
+ HydrationAudienceMismatchError,
46
+ type HydrationStore,
47
+ isReservedHandle,
48
+ } from "./projection";
49
+ import { type DriftRecord, type InputDriftMode, executeProvider, outcomeResult } from "./provider";
50
+ import type { ReshaperRegistry } from "./reshape";
51
+ import { partitionByViewer, restrictedFinding, snapshotViewer } from "./restricted";
52
+ import {
53
+ type HandleLoadResult,
54
+ HandleScheduler,
55
+ KEY_SEP,
56
+ type ScheduledJob,
57
+ defaultTimer,
58
+ disposedFinding,
59
+ tooDeep,
60
+ } from "./scheduler";
61
+
62
+ // ── The in-process controller ─────────────────────────────────────────────────
63
+ // Schedules the handles an experience binds (scheduler.ts: once per QUESTION, P0/P1/P2) and
64
+ // answers each through `executeProvider`, publishing to the store. A QUESTION is the provider
65
+ // spec, the load's params (defaults < global < handle-scoped, filtered to the arguments the
66
+ // provider declares overridable) and — for a `"contract"` provider — the kinds it is checked
67
+ // against: a repaired provider or a changed filter re-runs exactly once, an unrelated param
68
+ // change re-runs nothing, and a `stale` handle is not re-invoked while its question stands.
69
+ //
70
+ // Loads started together form a PASS: one instant for every relative-time token in it, and one
71
+ // memo, so an identical step (same tool, resolved args, schema pin, principal) shared by several
72
+ // providers runs once. The memo sits IN FRONT of the one controller-wide call cap
73
+ // (`maxConcurrentCalls`), so a memo hit never takes a slot; beneath both, every call is bound to
74
+ // the controller's lifetime, so `dispose` cancels each upstream call at once (a memo defers the
75
+ // cancel of a call only its own callers abandoned by a microtask — see invokers.ts). A lazy (P2)
76
+ // handle whose question changes (new params, `invalidate`) waits for its next `markVisible`,
77
+ // keeping its last value on screen until then.
78
+
79
+ export interface DriftReport {
80
+ readonly experienceId: string;
81
+ readonly versionHash: string;
82
+ readonly handle: string;
83
+ readonly spec: ProviderSpec;
84
+ readonly drift: readonly DriftRecord[];
85
+ readonly findings: readonly Finding[];
86
+ /** `true` when the load retried with the observed pin and published (`inputDrift: "report-and-proceed"`). */
87
+ readonly proceeded: boolean;
88
+ }
89
+
90
+ export interface ControllerOptions {
91
+ readonly invoker: TransientInvoker;
92
+ readonly store: HydrationStore;
93
+ /** Cache TTLs AND relative-time tokens run on this clock (read once per pass). Default `Date.now`. */
94
+ readonly clock?: () => number;
95
+ /** Schedule `fn` after `ms`; returns a cancel. Default: setTimeout/clearTimeout. */
96
+ readonly setTimer?: (fn: () => void, ms: number) => () => void;
97
+ readonly batchWindowMs?: number;
98
+ /** Parallel steps per wave within ONE provider. Default 4. */
99
+ readonly concurrency?: number;
100
+ /** At most this many transient calls in flight across the WHOLE controller. Default: unbounded. */
101
+ readonly maxConcurrentCalls?: number;
102
+ readonly principal?: {
103
+ readonly id: string;
104
+ readonly chain?: TransientInvokeOptions["principal"];
105
+ };
106
+ readonly cache?: HydrationCache;
107
+ /**
108
+ * Who the data is for (`@loomwork/ui-core`'s `Viewer`). A handle bound by any node classified above
109
+ * the viewer's clearance is never loaded: its provider is not invoked, nothing is cached for it,
110
+ * and NOTHING is written to the store for it (no value, no status: a store projected to a client
111
+ * must not even name it). SNAPSHOT at construction: later mutation of this object changes
112
+ * nothing; a different viewer needs {@link HydrationController.setViewer} (or a new
113
+ * controller). FAIL-CLOSED: absent = a viewer
114
+ * with no clearance (and `load` reports one `viewer-not-supplied` warn if anything is
115
+ * classified); unclassified handles are unaffected. Pass `{ clearance: "secret" }` for full access.
116
+ */
117
+ readonly viewer?: Viewer;
118
+ /**
119
+ * Who the surface is for, the SAME value `compile` takes (`@loomwork/ui-core` `Audience`). `"broadcast"`
120
+ * clamps the viewer to the audience's ceiling (unclassified data only): every handle `compile`
121
+ * withheld for the audience is never loaded, cached or written to the store, and `appendStream`
122
+ * and friends refuse it, whoever the `viewer` is. Absent / `"viewer"` = the viewer as given.
123
+ */
124
+ readonly audience?: Audience;
125
+ /**
126
+ * The host's DECLARED class per tool (`toolKey`), a read-time floor under every handle calling
127
+ * that tool (`@loomwork/ui-core` `DeclaredToolClasses`): a handle whose stored nodes carry no class
128
+ * is still gated as strictly as its tool is declared. Absent = only the experience's own classes.
129
+ */
130
+ readonly declaredToolClasses?: DeclaredToolClasses;
131
+ /**
132
+ * The host's whole {@link ReadPolicy}, the SAME value `createServeSurface` takes, so one policy
133
+ * serves both. Bound first; the separate `declaredToolClasses` / `undeclared` options join it
134
+ * raise-only (`mergeReadPolicy`), so neither can lower the other.
135
+ */
136
+ readonly readPolicy?: ReadPolicy;
137
+ /**
138
+ * What a tool the `declaredToolClasses` table does not list reads as: `"allow"` (default) = as the
139
+ * experience itself says; `"deny"` = no viewer may read it (only with a table present).
140
+ */
141
+ readonly undeclared?: UndeclaredToolPolicy;
142
+ readonly onDrift?: (report: DriftReport) => void;
143
+ /**
144
+ * Where `outputCheck: "contract"` providers get their kinds: every bound node resolved on this
145
+ * surface through this catalogue (`handleKinds`). Without it a contract provider is an error.
146
+ */
147
+ readonly outputKinds?: { readonly catalog: SemanticCatalog; readonly surface: Surface };
148
+ /** Params every load starts from (e.g. `DEFAULT_TIME_RANGE`); `setParams` layers over them. */
149
+ readonly defaultParams?: LoadParams;
150
+ /** On an input-schema drift: `"report"` (default) goes stale; `"report-and-proceed"` publishes. */
151
+ readonly inputDrift?: InputDriftMode;
152
+ /** The host's last word on each step's arguments (after params, time tokens, templates). */
153
+ readonly argumentHook?: ArgumentHook;
154
+ /**
155
+ * REQUIRED (U9b): the marked content-reference argument paths per tool, derived from the offered
156
+ * capabilities with the planner's `contentRefArgsFrom(capabilities)` (persist it with the capability
157
+ * set; `parseContentRefTable` re-reads it). A step whose marked argument holds a literal, or a
158
+ * reference to an output its tool does not declare to be one, is refused (`media-arg-literal` /
159
+ * `media-arg-unproven-ref`) and its call is NOT made, whatever validation did or did not run. `"none"`
160
+ * is the explicit opt-out: nothing is guarded, and loading an experience with media nodes then returns
161
+ * a `media-content-refs-unguarded` warning.
162
+ */
163
+ readonly contentRefArgs: ContentRefTable | "none";
164
+ /**
165
+ * U9b: the host's gate on a PARAM that fills a marked argument. Return `true` only for a reference
166
+ * the host vouches for, i.e. one that `acceptMediaAttach` accepted for this principal. Absent =
167
+ * every such param is refused.
168
+ */
169
+ readonly acceptContentRefParam?: ContentRefParamGate;
170
+ /** Where `Named` reshaping looks its reshapers up. */
171
+ readonly reshapers?: ReshaperRegistry;
172
+ /** The data-provider kinds composed for this controller (default: MCP only). */
173
+ readonly providers?: DataProviderRegistry;
174
+ /**
175
+ * Attach the RAW terminal output to each output-drift record `onDrift` reports, as a tainted
176
+ * sample (`DriftRecord.observedSample`) — what a planner repair pins against. Default false.
177
+ * The same option `serveHandle` takes, so in-process and split drift reports carry the same.
178
+ */
179
+ readonly captureSample?: boolean;
180
+ }
181
+
182
+ /**
183
+ * The question a load asks: the spec, and — for a `outputCheck: "contract"` provider only — the
184
+ * kinds it is checked against. A `"shape"` (or unset) provider's fingerprint must NEVER fold in
185
+ * kinds: its bound nodes' resolved kinds can change (a catalogue edit, a widened binding) without
186
+ * the provider itself changing, and that must not abort an in-flight load or force a re-run.
187
+ */
188
+ /**
189
+ * A kind whose value is a STRUCTURED contract the data boundary always enforces: a media display
190
+ * widget (a MediaRef, U9) or a chat transcript (a ChatValue, U8). Neither may ever be a streamed
191
+ * string, and neither may rely on a provider that declared only a shape.
192
+ */
193
+ const isStrictValueKind = (kind: string): boolean => isMediaKind(kind) || isChatKind(kind);
194
+ /** Does a handle feed a media display widget or a chat transcript (so its value must meet that contract, always)? */
195
+ const feedsMedia = (kinds: readonly string[]): boolean => kinds.some(isStrictValueKind);
196
+
197
+ export function loadFingerprint(spec: ProviderSpec, kinds: readonly string[]): string {
198
+ if (spec.outputCheck !== "contract" || kinds.length === 0) return specFingerprint(spec);
199
+ // Normalised exactly as the cache key normalises them: a reordered or repeated kinds list is
200
+ // the same question, and must not abort an in-flight load or force a re-run.
201
+ return `${specFingerprint(spec)}${KEY_SEP}${normaliseKinds(kinds).join(",")}`;
202
+ }
203
+
204
+ interface ProviderJob extends ScheduledJob {
205
+ readonly spec: ProviderSpec;
206
+ /** Already filtered to the params this provider declares overridable (`relevantParams`). */
207
+ readonly params: LoadParams;
208
+ /** The kinds a `"contract"` provider is checked against; `undefined` for any other provider. */
209
+ readonly kinds: readonly string[] | undefined;
210
+ readonly experienceId: string;
211
+ readonly versionHash: string;
212
+ }
213
+
214
+ /** Loads started together: one instant for their time tokens, one step memo for their calls. */
215
+ interface Pass {
216
+ readonly now: number;
217
+ readonly invoker: TransientInvoker;
218
+ }
219
+
220
+ type ProviderResult = HandleLoadResult & {
221
+ readonly drift: readonly DriftRecord[];
222
+ readonly findings: readonly Finding[];
223
+ };
224
+
225
+ export class HydrationController {
226
+ private readonly cache: HydrationCache;
227
+ private readonly clock: () => number;
228
+ private readonly sched: HandleScheduler<ProviderJob, Pass, ProviderResult>;
229
+ private current: Experience | undefined;
230
+ private kinds: ReadonlyMap<string, readonly string[]> = new Map();
231
+ private globalParams: LoadParams = {};
232
+ private readonly scopedParams = new Map<string, LoadParams>();
233
+ private readonly lifetime = new AbortController();
234
+
235
+ /**
236
+ * Throws `RangeError` for a `maxConcurrentCalls` that is not a positive integer, or
237
+ * `defaultParams` that `checkParams` refuses.
238
+ */
239
+ /** The viewer, copied once at construction so a live mutation of the caller's object is inert. */
240
+ private viewer: Viewer | undefined;
241
+ /**
242
+ * Handles that may take token chunks RIGHT NOW: bound by the current experience, cleared for the
243
+ * viewer, and (when output kinds are known) resolving to `StreamingText`. Rebuilt on every bind.
244
+ */
245
+ private streamable: ReadonlySet<string> = new Set();
246
+ private bound: ReadonlySet<string> = new Set();
247
+
248
+ private readonly policy: ReadPolicy;
249
+ private readonly broadcast: boolean;
250
+ private readonly audience: Audience;
251
+
252
+ constructor(private readonly opts: ControllerOptions) {
253
+ assertContentRefConfig(opts.contentRefArgs, "HydrationController");
254
+ // Snapshotted ONCE: later mutation of `opts` must not lift the ceiling (see `setViewer`).
255
+ this.broadcast = isBroadcast(opts.audience);
256
+ this.audience = this.broadcast ? "broadcast" : "viewer";
257
+ if (this.broadcast !== isBroadcast(opts.store.scope.audience))
258
+ throw new HydrationAudienceMismatchError(this.broadcast);
259
+ this.policy = mergeReadPolicy(opts.readPolicy, {
260
+ ...(opts.declaredToolClasses !== undefined && {
261
+ declaredToolClasses: opts.declaredToolClasses,
262
+ }),
263
+ ...(opts.undeclared !== undefined && { undeclared: opts.undeclared }),
264
+ });
265
+ this.viewer = snapshotViewer(opts.viewer, this.audience);
266
+ opts.store.guardAppend((handle) => this.appendRefusal(handle));
267
+ this.clock = opts.clock ?? Date.now;
268
+ this.cache = opts.cache ?? new HydrationCache(this.clock);
269
+ if (opts.defaultParams !== undefined) {
270
+ const bad = checkParams(opts.defaultParams);
271
+ if (bad) throw new RangeError(bad.message);
272
+ }
273
+ // ONE cap and ONE lifetime for the whole controller; a fresh memo per pass in front of them.
274
+ const calls = signalInvoker(
275
+ opts.maxConcurrentCalls === undefined
276
+ ? opts.invoker
277
+ : limitInvoker(opts.invoker, opts.maxConcurrentCalls),
278
+ this.lifetime.signal
279
+ );
280
+ const principalId = opts.principal?.id;
281
+ const contracts = opts.outputKinds?.catalog.valueContracts;
282
+ const variant = (job: ProviderJob) =>
283
+ job.kinds === undefined
284
+ ? { params: job.params }
285
+ : {
286
+ params: job.params,
287
+ kinds: job.kinds,
288
+ ...(contracts !== undefined && { contracts }),
289
+ };
290
+ this.sched = new HandleScheduler<ProviderJob, Pass, ProviderResult>(
291
+ opts.store,
292
+ {
293
+ newGroup: () => ({ now: this.clock(), invoker: memoInvoker(calls) }),
294
+ run: (job, signal, pass) => this.run(job, signal, pass),
295
+ // U9b: the content-reference guard (gate included) runs BEFORE the cache is read: a cached value
296
+ // was produced for an argument some principal's gate accepted once; serving it to another
297
+ // principal, or after the host revoked it, would skip the gate. A refusal is a miss (the load
298
+ // refuses it); a param that fills a guarded argument is cached per principal only.
299
+ cached: (job) => {
300
+ const verdict = this.refsVerdict(job);
301
+ return verdict?.refusal !== undefined
302
+ ? undefined
303
+ : this.cache.get(
304
+ verdict?.paramFilled ? principalScoped(job.spec) : job.spec,
305
+ principalId,
306
+ variant(job)
307
+ );
308
+ },
309
+ commit: (job, r) => {
310
+ // Only `ready` is cached — an `empty` answer has no value (loom ADR 0100), so nothing
311
+ // is stored. The scheduler still treats it as ANSWERED for the same question: it is
312
+ // re-asked only when the question changes, on `invalidate`, or on a new bind (and then
313
+ // never served from the cache). A result that proceeded past an input drift (`report-and-proceed`) is never cached:
314
+ // the key does not carry the drift mode, so a controller sharing the cache under the
315
+ // default (`report`) would be served it as a clean `ready` — no stale, no `onDrift`.
316
+ if (r.state === "ready" && r.drift.length === 0) {
317
+ const verdict = this.refsVerdict(job);
318
+ if (verdict?.refusal === undefined)
319
+ this.cache.set(
320
+ verdict?.paramFilled ? principalScoped(job.spec) : job.spec,
321
+ principalId,
322
+ r.value,
323
+ variant(job)
324
+ );
325
+ }
326
+ },
327
+ published: (job, r) => {
328
+ if (r.drift.length === 0) return;
329
+ this.opts.onDrift?.({
330
+ experienceId: job.experienceId,
331
+ versionHash: job.versionHash,
332
+ handle: job.handle,
333
+ spec: job.spec,
334
+ drift: r.drift,
335
+ findings: r.findings,
336
+ // A proceeded input drift can sit beside an `empty` answer (the retried upstream had
337
+ // no rows): still proceeded. `empty` itself never adds drift, so it alone never lands here.
338
+ proceeded: r.state === "ready" || r.state === "empty",
339
+ });
340
+ },
341
+ },
342
+ opts.setTimer ?? defaultTimer,
343
+ opts.batchWindowMs ?? 50
344
+ );
345
+ }
346
+
347
+ /**
348
+ * Bind an experience: schedule every handle it binds. Findings for handles that cannot load.
349
+ * A second `load` while one experience is already bound IS a recompose — the previous
350
+ * experience's handles are aborted and dropped rather than left published forever.
351
+ */
352
+ load(exp: Experience): { findings: Finding[] } {
353
+ if (this.sched.isDisposed) return { findings: [disposedFinding("load")] };
354
+ if (this.current !== undefined) return this.recompose(exp);
355
+ return this.bind(exp);
356
+ }
357
+
358
+ /**
359
+ * Replace the viewer and re-plan the bound experience: a handle the new viewer may not see is
360
+ * aborted and dropped (an in-flight result is discarded, a loaded value removed), one it newly
361
+ * may see is scheduled. Before any `load` it only records the viewer.
362
+ */
363
+ setViewer(viewer: Viewer | undefined): { findings: Finding[] } {
364
+ if (this.sched.isDisposed) return { findings: [disposedFinding("setViewer")] };
365
+ this.viewer = snapshotViewer(viewer, this.audience);
366
+ return this.current === undefined ? { findings: [] } : this.recompose(this.current);
367
+ }
368
+
369
+ /**
370
+ * Append one token chunk to a `StreamingText` handle, through the same viewer gate as `load`:
371
+ * refused (a finding, nothing written) for a handle that is not bound by the current
372
+ * experience (`stream-unbound`), is restricted for the viewer (`restricted`, the fixed text that
373
+ * names nothing), or — when `outputKinds` is configured — does not resolve to `StreamingText`
374
+ * (`stream-kind-mismatch`). Without `outputKinds` the kind is unknown and is not checked. The
375
+ * store's own `append` is gated by the same rule once a controller fronts it.
376
+ */
377
+ append(
378
+ handle: string,
379
+ chunk: unknown
380
+ ): { ok: true; applied: boolean } | { ok: false; finding: Finding } {
381
+ return this.opts.store.append(handle, chunk);
382
+ }
383
+
384
+ /**
385
+ * Publish a value for a handle that is NOT loaded by a provider (a live transcript a browser
386
+ * connection folds, ...), through the same gates as a load: the handle must be bound by the current
387
+ * experience, must not be restricted for the viewer (`restricted`, the fixed text that names
388
+ * nothing), must resolve to the kind the caller names (when it names one), and the value must pass
389
+ * EVERY bound kind's contract (a kind with none is refused: nothing is published unchecked).
390
+ * Refused = a finding and nothing written.
391
+ */
392
+ publishValue(
393
+ handle: string,
394
+ value: unknown,
395
+ options: { readonly state?: "ready" | "pending"; readonly kind?: string } = {}
396
+ ): { ok: true } | { ok: false; finding: Finding } {
397
+ const refuse = (code: string, message: string): { ok: false; finding: Finding } => ({
398
+ ok: false,
399
+ finding: { code, severity: "error", message },
400
+ });
401
+ if (this.sched.isDisposed || isReservedHandle(handle) || !this.bound.has(handle))
402
+ return refuse(
403
+ "publish-unbound",
404
+ "the handle is not bound by the current experience; the value was refused"
405
+ );
406
+ if (this.restrictedHandles.has(handle)) return { ok: false, finding: restrictedFinding() };
407
+ const kinds = this.kinds.get(handle) ?? [];
408
+ if (kinds.length === 0 || (options.kind !== undefined && !kinds.includes(options.kind)))
409
+ return refuse(
410
+ "publish-kind-mismatch",
411
+ "the handle's widget does not take this value; refused"
412
+ );
413
+ const contracts = this.opts.outputKinds?.catalog.valueContracts;
414
+ for (const kind of kinds) {
415
+ if (!hasKindContract(kind, contracts))
416
+ return refuse("publish-uncontracted", "the handle's widget has no value contract; refused");
417
+ const check = checkKindValue(kind, value, contracts);
418
+ if (!check.ok) return { ok: false, finding: check.finding };
419
+ }
420
+ this.opts.store.setValue(handle, value, { state: options.state ?? "ready" });
421
+ return { ok: true };
422
+ }
423
+
424
+ private appendRefusal(handle: string): Finding | undefined {
425
+ if (this.sched.isDisposed || !this.bound.has(handle))
426
+ return {
427
+ code: "stream-unbound",
428
+ severity: "error",
429
+ message: "the handle is not bound by the current experience; the chunk was refused",
430
+ };
431
+ if (this.streamable.has(handle)) return undefined;
432
+ // Bound but not streamable: restricted for the viewer, or the wrong widget kind.
433
+ return this.restrictedHandles.has(handle)
434
+ ? restrictedFinding()
435
+ : {
436
+ code: "stream-kind-mismatch",
437
+ severity: "error",
438
+ message: "the handle's widget does not take a token stream (StreamingText); refused",
439
+ };
440
+ }
441
+ private restrictedHandles: ReadonlySet<string> = new Set();
442
+
443
+ /** The surface reports a lazy (P2) widget entered the viewport. */
444
+ markVisible(handle: string): void {
445
+ this.sched.markVisible(handle);
446
+ }
447
+
448
+ /** A new artifact replaces the current one: abort and drop unbound handles, keep the rest, load new ones. */
449
+ recompose(next: Experience): { findings: Finding[] } {
450
+ if (this.sched.isDisposed) return { findings: [disposedFinding("recompose")] };
451
+ // Before anything is aborted or dropped: a tree we refuse to bind must leave the CURRENT
452
+ // one exactly as it was, not half-torn-down between two experiences.
453
+ if (treeDepth(next.root) > MAX_TREE_DEPTH)
454
+ return { findings: [tooDeep(next.experienceId, MAX_TREE_DEPTH)] };
455
+ this.sched.retain(
456
+ new Set(
457
+ partitionByViewer(
458
+ experienceHandles(next, this.policy.declaredToolClasses, this.policy.undeclared),
459
+ this.viewer
460
+ ).allowed.map((h) => h.handle)
461
+ )
462
+ );
463
+ return this.bind(next);
464
+ }
465
+
466
+ /**
467
+ * Replace the global params (no `scope.handle`) or one handle's scoped params, then re-ask
468
+ * every bound handle whose question changed — only those, so a handle that ended on `error`
469
+ * with an unchanged question is NOT retried here (`invalidate` retries it). `{}` clears.
470
+ * Refused, with nothing changed, for params `checkParams` refuses or a reserved handle. A lazy
471
+ * (P2) handle whose question changed waits for its next `markVisible`. Params scoped to a handle
472
+ * the experience does not bind are kept only until the next bind, which forgets them.
473
+ */
474
+ setParams(params: LoadParams, scope: { readonly handle?: string } = {}): { findings: Finding[] } {
475
+ if (this.sched.isDisposed) return { findings: [disposedFinding("setParams")] };
476
+ const bad = checkParams(params);
477
+ if (bad) return { findings: [bad] };
478
+ if (scope.handle !== undefined && isReservedHandle(scope.handle))
479
+ return {
480
+ findings: [
481
+ {
482
+ code: "invalid-handle",
483
+ severity: "error",
484
+ message: `handle "${scope.handle}" is reserved`,
485
+ },
486
+ ],
487
+ };
488
+ const before =
489
+ this.current === undefined
490
+ ? new Map<string, string>()
491
+ : new Map(this.plan(this.current).jobs.map((j) => [j.handle, j.fingerprint]));
492
+ // A copy (own enumerable keys only): a caller mutating its object later changes nothing.
493
+ const copy: LoadParams = Object.fromEntries(Object.entries(params));
494
+ if (scope.handle === undefined) this.globalParams = copy;
495
+ else this.scopedParams.set(scope.handle, copy);
496
+ if (this.current !== undefined)
497
+ this.sched.schedule(
498
+ this.plan(this.current).jobs.filter((j) => before.get(j.handle) !== j.fingerprint)
499
+ );
500
+ return { findings: [] };
501
+ }
502
+
503
+ /**
504
+ * Drop cached results (one handle or all) and reload the affected bound handles. An in-flight
505
+ * load answering the invalidated question is superseded; a lazy (P2) widget stays lazy.
506
+ */
507
+ invalidate(handle?: string): { findings: Finding[] } {
508
+ if (this.sched.isDisposed) return { findings: [disposedFinding("invalidate")] };
509
+ this.cache.invalidate(handle);
510
+ if (this.current === undefined) return { findings: [] };
511
+ const jobs = this.plan(this.current).jobs;
512
+ this.sched.reload(handle === undefined ? jobs : jobs.filter((j) => j.handle === handle));
513
+ return { findings: [] };
514
+ }
515
+
516
+ inFlight(): string[] {
517
+ return this.sched.inFlight();
518
+ }
519
+
520
+ /** Terminal: every in-flight load is aborted and reported, and nothing schedules again. */
521
+ dispose(): void {
522
+ this.sched.dispose();
523
+ this.lifetime.abort();
524
+ this.current = undefined;
525
+ this.bound = new Set();
526
+ this.streamable = new Set();
527
+ this.restrictedHandles = new Set();
528
+ this.globalParams = {};
529
+ this.scopedParams.clear();
530
+ }
531
+
532
+ private bind(exp: Experience): { findings: Finding[] } {
533
+ if (treeDepth(exp.root) > MAX_TREE_DEPTH)
534
+ return { findings: [tooDeep(exp.experienceId, MAX_TREE_DEPTH)] };
535
+ this.current = exp;
536
+ // U9: media-typed nodes mark their handle as media even with NO `outputKinds` (fail closed).
537
+ this.kinds = handleKindsWithMedia(
538
+ exp,
539
+ this.opts.outputKinds?.catalog,
540
+ this.opts.outputKinds?.surface
541
+ );
542
+ // Scoped params outlive a bind only for handles it binds (set before a load, or kept across
543
+ // a recompose); the rest are forgotten, so they never resurface on a later experience.
544
+ // Handle-id collection only (which handles are bound at all): no class is read, so the declared
545
+ // table is deliberately not passed. The viewer-gated partition below does take it.
546
+ const bound = new Set(experienceHandles(exp).map((h) => h.handle));
547
+ for (const handle of [...this.scopedParams.keys()])
548
+ if (!bound.has(handle)) this.scopedParams.delete(handle);
549
+ const { jobs, findings, missing } = this.plan(exp);
550
+ for (const m of missing)
551
+ this.opts.store.setStatus(m.handle, { state: "error", finding: m.finding });
552
+ // Refused handles: their status says so (and nothing else), and no job exists for them.
553
+ const { allowed, restricted, advisory } = partitionByViewer(
554
+ experienceHandles(exp, this.policy.declaredToolClasses, this.policy.undeclared),
555
+ this.viewer
556
+ );
557
+ this.bound = new Set(bound);
558
+ this.restrictedHandles = new Set(restricted.map((r) => r.handle));
559
+ this.streamable = new Set(
560
+ allowed
561
+ .map((a) => a.handle)
562
+ .filter(
563
+ (h) =>
564
+ // A media- or chat-bound handle is never streamable: a text chunk must not overwrite a MediaRef or a transcript,
565
+ // and with no `outputKinds` the kinds known here are the node-type media ones only.
566
+ !(this.kinds.get(h) ?? []).some(isStrictValueKind) &&
567
+ (this.opts.outputKinds === undefined ||
568
+ (this.kinds.get(h) ?? []).includes("StreamingText"))
569
+ )
570
+ );
571
+ for (const r of restricted) {
572
+ if (isReservedHandle(r.handle)) continue;
573
+ // NOTHING is written for it: no value and no status, because a store projected to a client
574
+ // would reveal the handle's name (and that it is restricted) through its status key.
575
+ this.opts.store.remove(r.handle);
576
+ }
577
+ this.sched.schedule(jobs);
578
+ const mediaAdvice: Finding[] =
579
+ this.opts.outputKinds === undefined && hasMediaTypeNodes(exp)
580
+ ? [
581
+ {
582
+ code: "media-output-kinds-missing",
583
+ severity: "warn",
584
+ message:
585
+ "the experience has media nodes and the controller has no outputKinds: media handles are contract-checked by node type, but pass outputKinds so kinds resolve through the catalogue",
586
+ },
587
+ ]
588
+ : [];
589
+ const refsAdvice: Finding[] =
590
+ this.opts.contentRefArgs === "none" && hasMediaTypeNodes(exp)
591
+ ? [
592
+ {
593
+ code: "media-content-refs-unguarded",
594
+ severity: "warn",
595
+ message:
596
+ 'the experience has media nodes and the controller runs with contentRefArgs: "none": a content-reference tool argument is not checked; derive the table with contentRefArgsFrom(capabilities)',
597
+ },
598
+ ]
599
+ : [];
600
+ return { findings: [...findings, ...advisory, ...mediaAdvice, ...refsAdvice] };
601
+ }
602
+
603
+ /** Pure: the jobs `exp` asks for right now, and the handles that cannot load (with why). */
604
+ private plan(exp: Experience): {
605
+ jobs: ProviderJob[];
606
+ findings: Finding[];
607
+ missing: Array<{ handle: string; finding: Finding }>;
608
+ } {
609
+ const jobs: ProviderJob[] = [];
610
+ const findings: Finding[] = [];
611
+ const missing: Array<{ handle: string; finding: Finding }> = [];
612
+ // A restricted handle is not a job: it never reaches the scheduler, the cache or a provider.
613
+ for (const use of partitionByViewer(
614
+ experienceHandles(exp, this.policy.declaredToolClasses, this.policy.undeclared),
615
+ this.viewer
616
+ ).allowed) {
617
+ if (isReservedHandle(use.handle)) {
618
+ findings.push({
619
+ code: "invalid-handle",
620
+ severity: "error",
621
+ message: `handle "${use.handle}" is reserved`,
622
+ });
623
+ continue;
624
+ }
625
+ const declared = Object.hasOwn(exp.providers, use.handle)
626
+ ? exp.providers[use.handle]
627
+ : undefined;
628
+ if (declared === undefined) {
629
+ const f: Finding = {
630
+ code: "unknown-handle",
631
+ severity: "error",
632
+ message: `nodes ${use.nodeIds.join(", ")} bind "${use.handle}", which has no provider`,
633
+ };
634
+ findings.push(f);
635
+ missing.push({ handle: use.handle, finding: f });
636
+ continue;
637
+ }
638
+ const kinds = this.kinds.get(use.handle) ?? [];
639
+ // U9: a handle feeding a media widget is ALWAYS contract-checked (the MediaRef contract), whatever
640
+ // the provider declared. A shape pin passes any value of the pinned shape — inline bytes or a
641
+ // locator included — and what is published here reaches the store, a seen log and a client.
642
+ const spec =
643
+ feedsMedia(kinds) && declared.outputCheck !== "contract"
644
+ ? { ...declared, outputCheck: "contract" as const }
645
+ : declared;
646
+ // Keyed on the params the provider can USE, never the raw bag: an unrelated param must
647
+ // neither re-run the load nor split its cache entry.
648
+ const params = relevantParams(spec, {
649
+ ...this.opts.defaultParams,
650
+ ...this.globalParams,
651
+ ...this.scopedParams.get(use.handle),
652
+ });
653
+ jobs.push({
654
+ handle: use.handle,
655
+ priority: use.priority,
656
+ spec,
657
+ params,
658
+ // Only a contract provider's checked value is answerable to a kind; the cache key and
659
+ // `executeProvider` ignore kinds for any other provider anyway.
660
+ kinds: spec.outputCheck === "contract" ? kinds : undefined,
661
+ experienceId: exp.experienceId,
662
+ versionHash: exp.versionHash,
663
+ fingerprint: `${paramsKey(params)}${KEY_SEP}${loadFingerprint(spec, kinds)}`,
664
+ });
665
+ }
666
+ return { jobs, findings, missing };
667
+ }
668
+
669
+ /** The content-reference guard over a whole job (asks the host's gate now); `undefined` when nothing is guarded. */
670
+ private refsVerdict(job: ProviderJob): ReturnType<typeof inspectContentRefs> | undefined {
671
+ const table = this.opts.contentRefArgs;
672
+ if (table === "none") return undefined;
673
+ return inspectContentRefs(
674
+ job.spec,
675
+ job.params,
676
+ contentRefEnv(table, job.spec, this.opts.acceptContentRefParam, {
677
+ principalId: this.opts.principal?.id,
678
+ principal: this.opts.principal?.chain,
679
+ })
680
+ );
681
+ }
682
+
683
+ private async run(job: ProviderJob, signal: AbortSignal, pass: Pass): Promise<ProviderResult> {
684
+ const outcome = await executeProvider(job.spec, {
685
+ invoker: pass.invoker,
686
+ signal,
687
+ now: pass.now,
688
+ params: job.params,
689
+ ...(job.kinds !== undefined && { kinds: job.kinds }),
690
+ ...(this.opts.outputKinds?.catalog.valueContracts !== undefined && {
691
+ contracts: this.opts.outputKinds.catalog.valueContracts,
692
+ }),
693
+ ...(this.opts.principal?.chain && { principal: this.opts.principal.chain }),
694
+ ...(this.opts.concurrency !== undefined && { concurrency: this.opts.concurrency }),
695
+ ...(this.opts.inputDrift !== undefined && { inputDrift: this.opts.inputDrift }),
696
+ ...(this.opts.argumentHook !== undefined && { argumentHook: this.opts.argumentHook }),
697
+ ...(this.opts.contentRefArgs !== "none" && { contentRefArgs: this.opts.contentRefArgs }),
698
+ ...(this.opts.principal?.id !== undefined && { principalId: this.opts.principal.id }),
699
+ ...(this.opts.acceptContentRefParam !== undefined && {
700
+ acceptContentRefParam: this.opts.acceptContentRefParam,
701
+ }),
702
+ ...(this.opts.reshapers !== undefined && { reshapers: this.opts.reshapers }),
703
+ ...(this.opts.providers !== undefined && { providers: this.opts.providers }),
704
+ ...(this.opts.captureSample !== undefined && { captureSample: this.opts.captureSample }),
705
+ });
706
+ return { ...outcomeResult(outcome), drift: outcome.drift ?? [], findings: outcome.findings };
707
+ }
708
+ }