@telorun/kernel 0.66.0 → 0.68.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 (66) hide show
  1. package/dist/application-env.d.ts +25 -0
  2. package/dist/application-env.d.ts.map +1 -1
  3. package/dist/application-env.js +79 -3
  4. package/dist/application-env.js.map +1 -1
  5. package/dist/controller-loader.d.ts +7 -5
  6. package/dist/controller-loader.d.ts.map +1 -1
  7. package/dist/controller-loader.js +7 -5
  8. package/dist/controller-loader.js.map +1 -1
  9. package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
  10. package/dist/controllers/resource-definition/resource-definition-controller.js +29 -8
  11. package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
  12. package/dist/evaluation-context.d.ts +6 -0
  13. package/dist/evaluation-context.d.ts.map +1 -1
  14. package/dist/evaluation-context.js +20 -8
  15. package/dist/evaluation-context.js.map +1 -1
  16. package/dist/kernel.d.ts.map +1 -1
  17. package/dist/kernel.js +32 -3
  18. package/dist/kernel.js.map +1 -1
  19. package/dist/manifest-schemas.d.ts +1 -1
  20. package/dist/manifest-schemas.d.ts.map +1 -1
  21. package/dist/manifest-schemas.js +6 -0
  22. package/dist/manifest-schemas.js.map +1 -1
  23. package/dist/module-context.d.ts.map +1 -1
  24. package/dist/module-context.js +3 -4
  25. package/dist/module-context.js.map +1 -1
  26. package/dist/resource-context.d.ts +21 -20
  27. package/dist/resource-context.d.ts.map +1 -1
  28. package/dist/resource-context.js +71 -51
  29. package/dist/resource-context.js.map +1 -1
  30. package/dist/resource-handle.d.ts +11 -0
  31. package/dist/resource-handle.d.ts.map +1 -0
  32. package/dist/resource-handle.js +32 -0
  33. package/dist/resource-handle.js.map +1 -0
  34. package/dist/runtime-seam.d.ts.map +1 -1
  35. package/dist/runtime-seam.js +8 -2
  36. package/dist/runtime-seam.js.map +1 -1
  37. package/dist/schema-compiled-values.d.ts.map +1 -1
  38. package/dist/schema-compiled-values.js +39 -4
  39. package/dist/schema-compiled-values.js.map +1 -1
  40. package/dist/schema-validator.d.ts +21 -1
  41. package/dist/schema-validator.d.ts.map +1 -1
  42. package/dist/schema-validator.js +88 -6
  43. package/dist/schema-validator.js.map +1 -1
  44. package/dist/type-field-schema.d.ts +21 -0
  45. package/dist/type-field-schema.d.ts.map +1 -0
  46. package/dist/type-field-schema.js +54 -0
  47. package/dist/type-field-schema.js.map +1 -0
  48. package/dist/zone-context.d.ts +94 -0
  49. package/dist/zone-context.d.ts.map +1 -0
  50. package/dist/zone-context.js +272 -0
  51. package/dist/zone-context.js.map +1 -0
  52. package/package.json +4 -4
  53. package/src/application-env.ts +93 -4
  54. package/src/controller-loader.ts +7 -5
  55. package/src/controllers/resource-definition/resource-definition-controller.ts +31 -13
  56. package/src/evaluation-context.ts +21 -7
  57. package/src/kernel.ts +38 -1
  58. package/src/manifest-schemas.ts +6 -0
  59. package/src/module-context.ts +3 -4
  60. package/src/resource-context.ts +111 -50
  61. package/src/resource-handle.ts +35 -0
  62. package/src/runtime-seam.ts +9 -1
  63. package/src/schema-compiled-values.ts +36 -4
  64. package/src/schema-validator.ts +87 -5
  65. package/src/type-field-schema.ts +77 -0
  66. package/src/zone-context.ts +337 -0
package/src/kernel.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  AnalysisRegistry,
3
3
  buildEvalPaths,
4
+ collectZoneModuleDocuments,
4
5
  flattenForAnalyzer,
5
6
  flattenLoadedModule,
6
7
  isModuleKind,
@@ -45,6 +46,7 @@ import { formatSpanCounter } from "./logging/span-id.js";
45
46
  import { ambientInvokeContext } from "./evaluation-context.js";
46
47
  import { ModuleContext } from "./module-context.js";
47
48
  import { ResourceContextImpl } from "./resource-context.js";
49
+ import { mintResourceHandle } from "./resource-handle.js";
48
50
  import { nodeCelHandlers } from "./cel-handlers.js";
49
51
  import { parseRef, seedInvokeSource } from "./invoke-dispatch.js";
50
52
  import { stripCompiledValues } from "./schema-compiled-values.js";
@@ -70,6 +72,7 @@ import {
70
72
  collectDeclaredEnvKeys,
71
73
  precompileApplicationEnvSchemas,
72
74
  precompileDefinitionSchemas,
75
+ precompileTypeSchemas,
73
76
  resolveApplicationEnv,
74
77
  } from "./application-env.js";
75
78
  import { policyFingerprint } from "./runtime-registry.js";
@@ -520,7 +523,10 @@ export class Kernel implements IKernel {
520
523
  const skipValidation = stamp?.signature === analysisSignature;
521
524
  const errors = this.analyzer.analyzeErrors(
522
525
  staticManifests,
523
- { skipValidation },
526
+ // Imported libraries' full documents, for the zone stage's per-library
527
+ // export derivation: `flattenForAnalyzer` forwards only each library's
528
+ // export surface, never its internal dispatch chain.
529
+ { skipValidation, moduleDocuments: collectZoneModuleDocuments(analysisGraph) },
524
530
  this.registry,
525
531
  );
526
532
  if (errors.length > 0) {
@@ -566,6 +572,26 @@ export class Kernel implements IKernel {
566
572
  // same content-addressed `__validators/` cache the runtime reads. The
567
573
  // resolver lets it also bake each `extends` child's inheritance-resolved
568
574
  // schema — the form the runtime actually validates against.
575
+ //
576
+ // Named types first: a contract that is a `$ref` to one resolves through
577
+ // the schema registry, which at runtime is populated by the type
578
+ // resources' own init. Without them a `$ref` contract bakes nothing.
579
+ //
580
+ // Fed from the GRAPH, not from `staticManifests`: flatten forwards every
581
+ // module's definitions but only the ENTRY's resource instances, and a
582
+ // named type is a resource instance — so a library that declares its
583
+ // shapes once and `$ref`s them (`oauth-client`, `vector-store`) has no
584
+ // type doc in the flattened view at all.
585
+ const graphDocs = [...analysisGraph.modules.values()].flatMap((mod) =>
586
+ flattenLoadedModule(mod),
587
+ );
588
+ // Canonicalize `telo://Self/<type>` to the id the type registers under.
589
+ // `analyze()` did this to its own view; these projections are separate
590
+ // objects, and an un-canonicalized `$ref` resolves to nothing here while
591
+ // the runtime resolves it fine — a guaranteed miss on exactly the
592
+ // contracts that reference a named shape.
593
+ this.registry.resolveSchemaTypeRefs([...graphDocs, ...staticManifests]);
594
+ await precompileTypeSchemas(graphDocs, this.sharedSchemaValidator);
569
595
  precompileDefinitionSchemas(staticManifests, this.sharedSchemaValidator, (def) =>
570
596
  this.registry.resolverForDefinition(def),
571
597
  );
@@ -1286,6 +1312,17 @@ export class Kernel implements IKernel {
1286
1312
  const instance = await controller.create(processedResource, ctx);
1287
1313
  if (!instance) return null;
1288
1314
 
1315
+ // Mint the instance's identity here, at the single instance-production site,
1316
+ // so an instance is never observable without a handle — the same argument
1317
+ // that put contract binding here. First mint wins: a `base:` child IS the
1318
+ // parent instance returned verbatim and must not be re-identified.
1319
+ const handle = mintResourceHandle(
1320
+ instance,
1321
+ resolvedKind,
1322
+ (processedResource.metadata?.name as string | undefined) ?? "<unnamed>",
1323
+ );
1324
+ (ctx as ResourceContextImpl).bindResourceIdentity(handle, resolvedKind, processedResource);
1325
+
1289
1326
  // Bind the resolved invocation contract to the instance, here at the kernel's
1290
1327
  // single instance-production site — so every consumer holds an already
1291
1328
  // enforcing instance, including the majority that read a Phase-5-injected ref
@@ -96,6 +96,12 @@ const KNOWN_CAPABILITIES = [
96
96
  // path, and dispatch emits trace events, so routing logs through it would
97
97
  // generate telemetry from inside the telemetry path. See kernel/specs/logging.md §10.
98
98
  "Telo.Sink",
99
+ // `Telo.Executable` is deliberately declarable NOWHERE: it is the slot-
100
+ // constraint parent of Invocable and Runnable ("control can be transferred to
101
+ // this"), naming no lifecycle role. Listing it here keeps the open third-party
102
+ // fallback branch below from accepting it — and since no branch above admits
103
+ // it either, `capability: Telo.Executable` fails validation outright.
104
+ "Telo.Executable",
99
105
  ] as const;
100
106
 
101
107
  /** Rule 8: `throws:` is only meaningful on Telo.Invocable or Telo.Runnable.
@@ -1,4 +1,4 @@
1
- import { executeInvokeStep, getRefIdentity, RuntimeError } from "@telorun/sdk";
1
+ import { deriveContext, executeInvokeStep, getRefIdentity, RuntimeError } from "@telorun/sdk";
2
2
  import type { ScopeConfig } from "./logging/scope-config.js";
3
3
  import type {
4
4
  BootTarget,
@@ -585,12 +585,11 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
585
585
  );
586
586
  const targetCtx: InvokeContext | undefined =
587
587
  tracing && ctx
588
- ? {
589
- cancellation: ctx.cancellation,
588
+ ? deriveContext(ctx, {
590
589
  invocationId: appSpanId,
591
590
  parentInvocationId: undefined,
592
591
  traceId: appTraceId,
593
- }
592
+ })
594
593
  : ctx;
595
594
 
596
595
  const steps: Record<string, unknown> = {};
@@ -1,15 +1,20 @@
1
1
  import {
2
+ InvokeError,
2
3
  NoopValidator,
3
4
  ResourceContext,
4
5
  ResourceInstance,
5
6
  ResourceManifest,
6
7
  RuntimeError,
7
8
  RuntimeResource,
9
+ UNCANCELLABLE_CONTEXT,
8
10
  createCancellationSource,
11
+ deriveContext,
12
+ getRefIdentity,
9
13
  resolveRefInstance,
10
14
  type CancellationSource,
11
15
  type ControllerPolicy,
12
16
  type EvaluationContext as IEvaluationContext,
17
+ type InvokeByNameOptions,
13
18
  type InvokeContext,
14
19
  type LoadOptions,
15
20
  type ModuleContext,
@@ -17,10 +22,14 @@ import {
17
22
  type OpenSpan,
18
23
  type OpenSpanOptions,
19
24
  type ParsedArgs,
25
+ type ResourceDefinition,
26
+ type ResourceHandle,
20
27
  type RuntimeSeam,
21
28
  type TypeRule,
29
+ type ZoneEntry,
22
30
  } from "@telorun/sdk";
23
31
  import { isRefSentinel } from "@telorun/templating";
32
+ import { ZoneContext } from "./zone-context.js";
24
33
  import * as path from "path";
25
34
  import { pathToFileURL } from "url";
26
35
  import type { ModuleArtifact } from "./bundle/module-artifact.js";
@@ -34,6 +43,7 @@ interface KernelModuleContext {
34
43
  getLoggingConfig?(): ScopeConfig | undefined;
35
44
  }
36
45
  import { stripCompiledValues } from "./schema-compiled-values.js";
46
+ import { resolveTypeFieldSchema } from "./type-field-schema.js";
37
47
  import AjvModule from "ajv";
38
48
  import addFormats from "ajv-formats";
39
49
  import { Kernel } from "./kernel.js";
@@ -170,7 +180,12 @@ export class ResourceContextImpl implements ResourceContext {
170
180
  if (!schema) {
171
181
  return new NoopValidator();
172
182
  }
173
- return this.validator.compile(schema);
183
+ // Never persisted: the schema is author data in a RESOURCE field, which the
184
+ // build-time warm does not walk, so a disk entry could only ever miss and
185
+ // be rewritten on every boot. Compiling through the kernel's validator is
186
+ // still what keeps one engine in the process — its formats, its `x-telo-*`
187
+ // keywords, its non-strict mode.
188
+ return this.validator.compile(schema, { persist: false });
174
189
  }
175
190
 
176
191
  registerSchema(name: string, schema: object): void {
@@ -207,54 +222,13 @@ export class ResourceContextImpl implements ResourceContext {
207
222
  * anyway, for the decisions a validator cannot answer: which properties carry
208
223
  * `x-telo-stream` and must be exempt from the walk, and which paths a
209
224
  * `default:` can be written to. Returns undefined when the reference resolves
210
- * to nothing. */
211
- resolveTypeSchema(typeRef: unknown): Record<string, any> | undefined {
212
- return this.followTypeAlias(this.readTypeSchema(typeRef), new Set());
213
- }
214
-
215
- private readTypeSchema(typeRef: unknown): Record<string, any> | undefined {
216
- if (!typeRef) return undefined;
217
- if (typeof typeRef === "string") return this.validator.getSchema(typeRef) as any;
218
- if (typeof typeRef !== "object") return undefined;
219
- const ref = typeRef as Record<string, any>;
220
- if (ref.schema && typeof ref.schema === "object") return ref.schema;
221
- if (typeof ref.name === "string") return this.validator.getSchema(ref.name) as any;
222
- if (ref.type || ref.properties || ref.$ref) return ref;
223
- return undefined;
224
- }
225
-
226
- /**
227
- * Follow a schema that is nothing but a `$ref` to a registered type, so the
228
- * schema-level questions (which properties are streams, which paths carry a
229
- * default) are asked of the real shape rather than of an alias.
230
- *
231
- * Only the whole-document alias form is followed, and only to READ it — the
232
- * schema handed to AJV keeps its `$ref`s intact, because AJV resolves them
233
- * itself against the registered ids and each type stays its own document with
234
- * its own `$defs`. Inlining instead would move a `$ref: "#/$defs/X"` out of the
235
- * document that defines `$defs.X`.
225
+ * to nothing.
236
226
  *
237
- * `seen` guards a cycle two mutually-referencing types would otherwise spin on.
238
- * A `$ref` alongside other keywords is left alone: that is a composition, not
239
- * an alias.
240
- */
241
- private followTypeAlias(
242
- schema: Record<string, any> | undefined,
243
- seen: Set<string>,
244
- ): Record<string, any> | undefined {
245
- let current = schema;
246
- while (
247
- current &&
248
- typeof current.$ref === "string" &&
249
- Object.keys(current).length === 1 &&
250
- !seen.has(current.$ref)
251
- ) {
252
- seen.add(current.$ref);
253
- const target = this.validator.getSchema(current.$ref) as Record<string, any> | undefined;
254
- if (!target) return current;
255
- current = target;
256
- }
257
- return current;
227
+ * Shared with the build-time validator warm through
228
+ * {@link resolveTypeFieldSchema} the warm must land on the same schema
229
+ * object the runtime compiles, or its baked entry is one nothing asks for. */
230
+ resolveTypeSchema(typeRef: unknown): Record<string, any> | undefined {
231
+ return resolveTypeFieldSchema(typeRef, (name) => this.validator.getSchema(name));
258
232
  }
259
233
 
260
234
  /** Compile `schema` but compose the CEL `rules:` registered under `name`.
@@ -350,6 +324,88 @@ export class ResourceContextImpl implements ResourceContext {
350
324
  return createCancellationSource();
351
325
  }
352
326
 
327
+ // ── Execution zones (kernel/specs/execution-zones.md) ─────────────────────
328
+ //
329
+ // Identity is held here (it is the resource's, not the zone subsystem's);
330
+ // everything else delegates to `ZoneContext`, which owns the annotation
331
+ // resolution, correlation walk and stack matching — and memoizes them, since
332
+ // this sits on the dispatch path.
333
+
334
+ #self: ResourceHandle | undefined;
335
+ #zones: ZoneContext | undefined;
336
+
337
+ /** Kernel-internal: stamped at `create()`, the moment the instance exists. */
338
+ bindResourceIdentity(
339
+ handle: ResourceHandle,
340
+ resolvedKind: string,
341
+ manifest: Record<string, unknown>,
342
+ ): void {
343
+ this.#self = handle;
344
+ this.#zones = new ZoneContext({
345
+ resourceName: (this.metadata?.name as string) ?? "<unnamed>",
346
+ resolvedKind,
347
+ self: handle,
348
+ // The SAME object Phase-5 injection later mutates, so a correlation
349
+ // pointer read at invoke time sees live instances in ref slots.
350
+ manifest,
351
+ resolveDefinition: (kind) => this.kernel.getAnalysisRegistry().resolveDefinition(kind),
352
+ resolveDefinitionIn: (kind, module) =>
353
+ this.kernel.getAnalysisRegistry().resolveDefinitionIn(kind, module),
354
+ resolveLocalInstance: (name) => this.resolveLocalInstance(name),
355
+ resolveLocalManifest: (name) =>
356
+ this.contextForName(name).resourceInstances.get(name)?.resource as
357
+ | Record<string, unknown>
358
+ | undefined,
359
+ });
360
+ }
361
+
362
+ get self(): ResourceHandle {
363
+ if (!this.#self) {
364
+ throw new RuntimeError(
365
+ "ERR_RESOURCE_IDENTITY_UNBOUND",
366
+ `[${this.metadata.name}] ctx.self is unavailable inside create() — the handle is minted when create() returns`,
367
+ );
368
+ }
369
+ return this.#self;
370
+ }
371
+
372
+ /** The zone subsystem, available once `create()` has returned. */
373
+ private zoneContext(): ZoneContext {
374
+ if (!this.#zones) {
375
+ throw new RuntimeError(
376
+ "ERR_RESOURCE_IDENTITY_UNBOUND",
377
+ `[${this.metadata.name}] zones are unavailable inside create() — the handle is minted when create() returns`,
378
+ );
379
+ }
380
+ return this.#zones;
381
+ }
382
+
383
+ withZone<T>(
384
+ slot: string,
385
+ fn: (ctx: InvokeContext, entry: ZoneEntry) => Promise<T>,
386
+ base?: InvokeContext,
387
+ ): Promise<T> {
388
+ return this.zoneContext().withZone(slot, fn, base);
389
+ }
390
+
391
+ requireZone(field: string, ctx?: InvokeContext): ZoneEntry {
392
+ return this.zoneContext().requireZone(field, ctx);
393
+ }
394
+
395
+ findZone(field: string, ctx?: InvokeContext): ZoneEntry | undefined {
396
+ return this.zoneContext().findZone(field, ctx);
397
+ }
398
+
399
+ zonesFor(instance: ResourceInstance, ctx?: InvokeContext): readonly ZoneEntry[] {
400
+ return this.zoneContext().zonesFor(instance, ctx);
401
+ }
402
+
403
+ /** The root context for runtime-driven inbound work — inherits nothing from
404
+ * whatever ambient happens to be live at the registration site. */
405
+ rootContext(opts?: { cancellation?: CancellationSource }): InvokeContext {
406
+ return opts?.cancellation?.context ?? UNCANCELLABLE_CONTEXT;
407
+ }
408
+
353
409
  /** In-flight fire-and-forget tasks this resource spawned. Owned here, not by
354
410
  * the kernel: the resource drains them in its own teardown (see
355
411
  * `drainDetached`), so background work is bounded by the resource's lifetime. */
@@ -401,8 +457,13 @@ export class ResourceContextImpl implements ResourceContext {
401
457
  return this.owningContext.openSpan(base, opts);
402
458
  }
403
459
 
404
- invoke<TInputs>(kind: string, name: string, inputs: TInputs): Promise<any> {
405
- return this.contextForName(name).invoke(kind, name, inputs);
460
+ invoke<TInputs>(
461
+ kind: string,
462
+ name: string,
463
+ inputs: TInputs,
464
+ options?: InvokeByNameOptions,
465
+ ): Promise<any> {
466
+ return this.contextForName(name).invoke(kind, name, inputs, options?.ctx);
406
467
  }
407
468
 
408
469
  invokeResolved<TInputs>(
@@ -0,0 +1,35 @@
1
+ import type { ResourceHandle, ResourceInstanceId } from "@telorun/sdk";
2
+
3
+ /**
4
+ * Instance → handle, minted at `create()` — the kernel's single
5
+ * instance-production site, where the invocation contract already binds — so an
6
+ * instance is never observable without one. The reverse direction deliberately
7
+ * does not exist: nothing turns a handle back into someone else's live
8
+ * instance, which is what keeps the ambient zone stack from leaking instances
9
+ * across module boundaries.
10
+ */
11
+ const handles = new WeakMap<object, ResourceHandle>();
12
+
13
+ let counter = 0;
14
+
15
+ /**
16
+ * Mint (or return) the handle for a live instance. Idempotent, first mint wins
17
+ * — a `base:` child IS its parent's instance returned verbatim, so the nested
18
+ * parent create stamps first and the child create must not re-identify it; one
19
+ * live instance, one id, exactly like `stampRefIdentity`.
20
+ */
21
+ export function mintResourceHandle(instance: object, kind: string, name: string): ResourceHandle {
22
+ const existing = handles.get(instance);
23
+ if (existing) return existing;
24
+ const handle: ResourceHandle = Object.freeze({
25
+ id: `ri-${++counter}` as ResourceInstanceId,
26
+ ref: Object.freeze({ kind, name }),
27
+ });
28
+ handles.set(instance, handle);
29
+ return handle;
30
+ }
31
+
32
+ /** The handle minted for a live instance, if any. */
33
+ export function handleOfInstance(instance: object): ResourceHandle | undefined {
34
+ return handles.get(instance);
35
+ }
@@ -1,9 +1,11 @@
1
1
  import {
2
2
  Loader,
3
3
  StaticAnalyzer,
4
+ collectZoneModuleDocuments,
4
5
  flattenForAnalyzer,
5
6
  type AnalysisDiagnostic,
6
7
  type ManifestSource,
8
+ type ZoneModuleDocuments,
7
9
  } from "@telorun/analyzer";
8
10
  import {
9
11
  Stream,
@@ -211,6 +213,7 @@ export class KernelRuntimeSeam implements RuntimeSeam {
211
213
  // `analyze()`'s — carried out of the try so the checks below can see them.
212
214
  let parseDiagnostics: AnalysisDiagnostic[] = [];
213
215
  let versionDiagnostics: AnalysisDiagnostic[] = [];
216
+ let moduleDocuments: ZoneModuleDocuments[] = [];
214
217
  try {
215
218
  const graph = await loader.loadGraph(source, {
216
219
  desugarImports: options?.desugarImports ?? true,
@@ -219,6 +222,9 @@ export class KernelRuntimeSeam implements RuntimeSeam {
219
222
  parseDiagnostics = graph.parseDiagnostics;
220
223
  versionDiagnostics = graph.versionDiagnostics;
221
224
  manifests = flattenForAnalyzer(graph);
225
+ // The zone stage derives each imported library's export contracts from
226
+ // its own full documents, which the flattened list drops.
227
+ moduleDocuments = collectZoneModuleDocuments(graph);
222
228
  } catch (err) {
223
229
  // A graph that would not load is an answer, not a failure of the call —
224
230
  // "this manifest does not load, and here is the reason" is precisely what
@@ -243,7 +249,9 @@ export class KernelRuntimeSeam implements RuntimeSeam {
243
249
 
244
250
  // `analyze()` never sees version skew, so without merging these a major
245
251
  // mismatch — which `load()` refuses to boot on — would check clean.
246
- const diagnostics = new StaticAnalyzer({ celHandlers: nodeCelHandlers }).analyze(manifests);
252
+ const diagnostics = new StaticAnalyzer({ celHandlers: nodeCelHandlers }).analyze(manifests, {
253
+ moduleDocuments,
254
+ });
247
255
  return {
248
256
  diagnostics: [...versionDiagnostics, ...diagnostics].map(toCheckDiagnostic),
249
257
  };
@@ -61,6 +61,35 @@ function collectSchemaProperties(
61
61
  return props;
62
62
  }
63
63
 
64
+ /** True when a ref slot is holding CONFIG rather than a reference.
65
+ *
66
+ * A slot annotated `x-telo-ref` is normally handed back whole, but the
67
+ * annotation can sit on a node that is a reference AND a config carrier at
68
+ * once: `targets:` puts it on the array ITEM so a bare `!ref Foo` is accepted,
69
+ * while the same item may be a step object (`{ref, when}` /
70
+ * `{invoke, inputs, when}`) whose `when` is a CEL guard that must be stripped
71
+ * like any other. Told apart by what the value IS, three ways:
72
+ *
73
+ * - a reference carries a `kind` — `resolveRefSentinels` rewrites a `!ref` to
74
+ * `{kind, name, alias?}`, and the only other object a ref slot admits is an
75
+ * inline definition (`{kind, …config}`);
76
+ * - a live instance is either not a plain object, or exposes a method (a
77
+ * controller's `create()` may return an object literal — `Assert.Schema`
78
+ * does). Copying one is what the walk exists to avoid, and its graph is
79
+ * routinely cyclic;
80
+ * - what is left came from YAML, where a function cannot appear. */
81
+ function isConfigAtRefSlot(value: unknown): boolean {
82
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
83
+ const proto = Object.getPrototypeOf(value);
84
+ if (proto !== Object.prototype && proto !== null) return false;
85
+ const obj = value as Record<string, unknown>;
86
+ if ("kind" in obj) return false;
87
+ for (const member of Object.values(obj)) {
88
+ if (typeof member === "function") return false;
89
+ }
90
+ return true;
91
+ }
92
+
64
93
  /** Replaces CompiledValue wrappers with schema-appropriate placeholders for schema validation.
65
94
  * Template strings were compiled from YAML at load time; this restores a shape
66
95
  * that AJV can validate without evaluating expressions. When no schema is
@@ -88,10 +117,13 @@ export function stripCompiledValues(
88
117
  const resolved = resolveSchemaRef(nodeSchema, root);
89
118
 
90
119
  if (isCompiledValue(value)) return placeholderForSchema(resolved);
91
- // A slot the schema declares as a reference is never config: it holds a
92
- // `{kind, name}` ref or the live instance Phase 5 replaced it with, and the
93
- // schema declares no shape to validate against either way.
94
- if (resolved["x-telo-ref"] !== undefined) return value;
120
+ // A slot the schema declares as a reference is never config when it HOLDS a
121
+ // reference: a `{kind, name}` ref or the live instance Phase 5 replaced it
122
+ // with, and the schema declares no shape to validate against either way. A
123
+ // ref slot carrying config beside the ref keeps walking — bailing there left
124
+ // a boot target's `when: !cel` a CompiledValue for AJV to reject as
125
+ // "must be string", which is the whole gated-target form.
126
+ if (resolved["x-telo-ref"] !== undefined && !isConfigAtRefSlot(value)) return value;
95
127
 
96
128
  if (Array.isArray(value)) {
97
129
  const itemSchema = resolveSchemaRef((resolved.items ?? {}) as Record<string, unknown>, root);
@@ -146,6 +146,64 @@ function collapseSentinelsToSource(value: unknown): unknown {
146
146
  return value;
147
147
  }
148
148
 
149
+ /** Schema keywords whose VALUE is a map keyed by author-chosen names rather
150
+ * than by keyword. A name may legitimately be `x-telo-…`, so the strip below
151
+ * must not treat a key in one of these maps as an annotation. */
152
+ const NAME_KEYED_SCHEMA_KEYWORDS = new Set([
153
+ "properties",
154
+ "patternProperties",
155
+ "dependentSchemas",
156
+ "dependentRequired",
157
+ "$defs",
158
+ "definitions",
159
+ ]);
160
+
161
+ /** Schema keywords whose value is DATA, not a subschema. The strip must not
162
+ * descend into them at all: an `x-telo-…` key inside a `const` / `default` /
163
+ * `enum` member is part of the value being matched or filled, so removing it
164
+ * would change what the validator accepts and what it writes — and would make
165
+ * two schemas that differ only there hash alike. */
166
+ const DATA_VALUE_KEYWORDS = new Set(["const", "default", "enum", "examples"]);
167
+
168
+ /** Deep-clone `schema` without its `x-telo-*` annotations — applied, like
169
+ * {@link collapseSentinelsToSource}, before both AJV compilation and cache
170
+ * hashing.
171
+ *
172
+ * Every `x-telo-*` keyword is analyzer/editor metadata: AJV runs `strict:
173
+ * false` and registers the known ones as no-op keywords, so none of them emits
174
+ * a single line of validation code. Leaving them in the hashed form makes the
175
+ * cache key sensitive to differences that cannot change what the validator
176
+ * does — and one such difference is real and systematic. The analyzer rewrites
177
+ * `x-telo-ref.kind` to its canonical `<module>.<Kind>` in the declaring scope
178
+ * (`resolveSchemaRefKinds`), and `telo install`'s warm pass bakes THAT view;
179
+ * the kernel's controller registry never runs the rewrite, so at runtime the
180
+ * same kind's schema still reads `Self.Connection`. Two keys, one validator:
181
+ * every kind whose schema declares an alias-qualified ref missed the baked
182
+ * cache on every boot and tried to rewrite it — the EACCES noise on a
183
+ * read-only image.
184
+ *
185
+ * Stripping is what makes the key describe the compiled validator and nothing
186
+ * else, so the two views converge without either side having to agree on an
187
+ * annotation's spelling. `normalizeRefSlots` runs FIRST and is unaffected: it
188
+ * reads `x-telo-ref` to drop a legacy scalar `type` at a ref slot, which does
189
+ * change validation, and it has already done so by the time this runs. */
190
+ function stripTeloAnnotations(value: unknown, nameKeyed = false): unknown {
191
+ // An array's items are schema nodes (`allOf`, tuple `items`), never names.
192
+ if (Array.isArray(value)) return value.map((item) => stripTeloAnnotations(item));
193
+ if (!value || typeof value !== "object") return value;
194
+ const out: Record<string, unknown> = {};
195
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
196
+ if (!nameKeyed && k.startsWith("x-telo-")) continue;
197
+ // A data-bearing keyword's value is carried over verbatim; a name-keyed
198
+ // map's VALUES are schema nodes again, so only its keys are exempt.
199
+ out[k] =
200
+ !nameKeyed && DATA_VALUE_KEYWORDS.has(k)
201
+ ? v
202
+ : stripTeloAnnotations(v, !nameKeyed && NAME_KEYED_SCHEMA_KEYWORDS.has(k));
203
+ }
204
+ return out;
205
+ }
206
+
149
207
  export class SchemaValidator {
150
208
  private ajv: InstanceType<typeof Ajv>;
151
209
  private typeRules = new Map<string, TypeRule[]>();
@@ -162,6 +220,13 @@ export class SchemaValidator {
162
220
  * process — `compiledValidators` is keyed by object identity and would
163
221
  * miss those cases. */
164
222
  private hashCache = new Map<string, DataValidator>();
223
+ /** Hashes whose compile went through the disk layer. A `persist: false`
224
+ * compile populates `hashCache` too — a repeat within the process should
225
+ * still collapse — but must not be mistaken for a baked entry: returning it
226
+ * to a persisting caller would suppress that caller's write permanently, so
227
+ * content shared with a warmable schema would never reach the cache. Such a
228
+ * caller falls through and compiles again, this time with the disk layer. */
229
+ private persistedHashes = new Set<string>();
165
230
  /** Where cache-failure diagnostics go. Injected rather than reached for
166
231
  * globally: this class is constructed outside the kernel's stdio scope, and
167
232
  * §13.1 forbids the kernel writing to `process.stderr` directly. Defaults to
@@ -239,7 +304,18 @@ export class SchemaValidator {
239
304
  this.cacheWritable = opts?.write ?? true;
240
305
  }
241
306
 
242
- compile(schema: any): DataValidator {
307
+ /** Compile `schema` to a validator, reusing the in-memory and on-disk caches.
308
+ *
309
+ * `persist: false` keeps the compile in memory only — no disk read, no disk
310
+ * write. It is for a schema the build-time warm cannot see: an author-written
311
+ * JSON Schema sitting in a RESOURCE field (`ctx.createSchemaValidator`),
312
+ * rather than a kind's config schema or an invocation contract. Those are
313
+ * baked by `precompileDefinitionSchemas`; a resource-field schema never was,
314
+ * so persisting it only ever produced a miss-then-write on every boot — the
315
+ * EACCES noise on a read-only image. Declining to own what it cannot warm is
316
+ * the cache being honest, not a capability given up: the in-memory layers
317
+ * still collapse a repeat compile within the process. */
318
+ compile(schema: any, options?: { persist?: boolean }): DataValidator {
243
319
  if (schema && typeof schema === "object") {
244
320
  const cached = this.compiledValidators.get(schema as object);
245
321
  if (cached) return cached;
@@ -284,7 +360,7 @@ export class SchemaValidator {
284
360
  // precompiled (runtime) views of one schema land on the same cache key. The
285
361
  // hashed and the compiled schema are this same canonical form. See
286
362
  // `collapseSentinelsToSource`.
287
- const sanitized = collapseSentinelsToSource(injected);
363
+ const sanitized = collapseSentinelsToSource(stripTeloAnnotations(injected));
288
364
 
289
365
  const hash = createHash("sha256")
290
366
  .update(
@@ -295,15 +371,17 @@ export class SchemaValidator {
295
371
  )
296
372
  .digest("hex")
297
373
  .slice(0, 32);
374
+ const persist = options?.persist ?? true;
298
375
  const cachedByHash = this.hashCache.get(hash);
299
- if (cachedByHash) {
376
+ if (cachedByHash && (!persist || this.persistedHashes.has(hash))) {
300
377
  if (schema && typeof schema === "object") {
301
378
  this.compiledValidators.set(schema as object, cachedByHash);
302
379
  }
303
380
  return cachedByHash;
304
381
  }
305
382
 
306
- const validate = this.compileAjvOrLoadCached(sanitized, hash);
383
+ const validate = this.compileAjvOrLoadCached(sanitized, hash, persist);
384
+ if (persist) this.persistedHashes.add(hash);
307
385
 
308
386
  const validator = {
309
387
  validate: (data: any) => {
@@ -342,8 +420,12 @@ export class SchemaValidator {
342
420
  private compileAjvOrLoadCached(
343
421
  schema: any,
344
422
  hash: string,
423
+ persist: boolean,
345
424
  ): ValidateFunction {
346
- const cacheDir = this.cacheDir;
425
+ // `persist: false` drops the whole disk layer — the read too, not just the
426
+ // write. Nothing bakes these entries, so a lookup is an ENOENT probe whose
427
+ // only possible hit is one this process wrote on an earlier run.
428
+ const cacheDir = persist ? this.cacheDir : undefined;
347
429
  if (cacheDir) {
348
430
  const cachePath = path.join(cacheDir, `${hash}.cjs`);
349
431
  try {