@telorun/kernel 0.67.0 → 0.69.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 (37) 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/kernel.d.ts.map +1 -1
  6. package/dist/kernel.js +19 -1
  7. package/dist/kernel.js.map +1 -1
  8. package/dist/manifest-schemas.d.ts.map +1 -1
  9. package/dist/manifest-schemas.js +2 -1
  10. package/dist/manifest-schemas.js.map +1 -1
  11. package/dist/observed-state.d.ts.map +1 -1
  12. package/dist/observed-state.js +2 -0
  13. package/dist/observed-state.js.map +1 -1
  14. package/dist/resource-context.d.ts +5 -18
  15. package/dist/resource-context.d.ts.map +1 -1
  16. package/dist/resource-context.js +15 -48
  17. package/dist/resource-context.js.map +1 -1
  18. package/dist/schema-compiled-values.d.ts.map +1 -1
  19. package/dist/schema-compiled-values.js +39 -4
  20. package/dist/schema-compiled-values.js.map +1 -1
  21. package/dist/schema-validator.d.ts +21 -1
  22. package/dist/schema-validator.d.ts.map +1 -1
  23. package/dist/schema-validator.js +103 -6
  24. package/dist/schema-validator.js.map +1 -1
  25. package/dist/type-field-schema.d.ts +21 -0
  26. package/dist/type-field-schema.d.ts.map +1 -0
  27. package/dist/type-field-schema.js +54 -0
  28. package/dist/type-field-schema.js.map +1 -0
  29. package/package.json +3 -3
  30. package/src/application-env.ts +93 -4
  31. package/src/kernel.ts +21 -0
  32. package/src/manifest-schemas.ts +2 -1
  33. package/src/observed-state.ts +2 -0
  34. package/src/resource-context.ts +15 -48
  35. package/src/schema-compiled-values.ts +36 -4
  36. package/src/schema-validator.ts +103 -5
  37. package/src/type-field-schema.ts +77 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/kernel",
3
- "version": "0.67.0",
3
+ "version": "0.69.0",
4
4
  "description": "Telo Runtime - A lightweight, polyglot execution host.",
5
5
  "keywords": [
6
6
  "telo",
@@ -61,7 +61,7 @@
61
61
  "dependencies": {
62
62
  "@marcbachmann/cel-js": "^7.6.1",
63
63
  "@sinclair/typebox": "^0.34.48",
64
- "@telorun/analyzer": "0.54.0",
64
+ "@telorun/analyzer": "0.56.0",
65
65
  "@telorun/glob": "0.2.0",
66
66
  "@telorun/templating": "0.12.0",
67
67
  "ajv": "^8.17.1",
@@ -75,7 +75,7 @@
75
75
  "@types/tar-stream": "^3.1.3",
76
76
  "typescript": "^5.0.0",
77
77
  "vitest": "^2.1.8",
78
- "@telorun/sdk": "0.67.0"
78
+ "@telorun/sdk": "0.68.0"
79
79
  },
80
80
  "optionalDependencies": {
81
81
  "esbuild": "^0.25.12"
@@ -1,7 +1,19 @@
1
- import { type DefResolver, effectiveAuthorSchema, residualEntrySchema } from "@telorun/analyzer";
2
- import type { ResourceDefinition } from "@telorun/sdk";
1
+ import {
2
+ type DefResolver,
3
+ effectiveAuthorSchema,
4
+ residualEntrySchema,
5
+ withStreamPropertiesSkipped,
6
+ } from "@telorun/analyzer";
7
+ import type {
8
+ ResourceContext,
9
+ ResourceDefinition,
10
+ ResourceManifest,
11
+ TypeRule,
12
+ } from "@telorun/sdk";
3
13
  import { RuntimeError } from "@telorun/sdk";
14
+ import { create as createJsonSchemaType } from "./controllers/type/json-schema-controller.js";
4
15
  import { SchemaValidator } from "./schema-validator.js";
16
+ import { resolveTypeFieldSchema } from "./type-field-schema.js";
5
17
 
6
18
  type EntryType = "string" | "integer" | "number" | "boolean" | "object" | "array";
7
19
 
@@ -144,6 +156,63 @@ export function precompileApplicationEnvSchemas(
144
156
  }
145
157
  }
146
158
 
159
+ /**
160
+ * Register every `Telo.JsonSchema` resource's resolved schema into `validator`,
161
+ * ahead of the contract warm below.
162
+ *
163
+ * A contract declared as `{kind: Telo.JsonSchema, schema: {$ref: "telo:m/X"}}`
164
+ * — what `oauth-client` and `vector-store` write — is compiled at runtime from
165
+ * the schema registered under that id, reached by following the alias. With no
166
+ * types registered the warm follows it to the `$ref` wrapper itself, AJV refuses
167
+ * the unresolvable reference, and the entry is silently not baked while the
168
+ * runtime goes on compiling something else: a guaranteed miss for exactly the
169
+ * modules that declare their shapes once and reference them.
170
+ *
171
+ * Runs the REAL type controller rather than re-deriving registration here. The
172
+ * three names a type registers under, the canonical `telo:` id and the `extends`
173
+ * merge are its rules; a second implementation would drift into baking schemas
174
+ * under keys the runtime never asks for — the failure this whole pass exists to
175
+ * remove. The loop mirrors the kernel's multi-pass init (`create` returns null
176
+ * while a parent type is unregistered) and stops as soon as a pass registers
177
+ * nothing new, so an unresolvable parent ends it instead of spinning.
178
+ *
179
+ * The deprecated `Type.JsonSchema` is not warmed: it is a module kind with its
180
+ * own controller, and reaching for this one would be assuming the two stayed
181
+ * identical.
182
+ */
183
+ export async function precompileTypeSchemas(
184
+ manifests: Array<Record<string, any>>,
185
+ validator: SchemaValidator,
186
+ ): Promise<void> {
187
+ const ctx = {
188
+ lookupSchema: (name: string) => validator.getSchema(name),
189
+ registerSchema: (name: string, schema: object) => validator.addSchema(name, schema),
190
+ registerTypeRules: (name: string, rules: TypeRule[]) => validator.addTypeRules(name, rules),
191
+ } as unknown as ResourceContext;
192
+
193
+ let pending = manifests.filter(
194
+ (m) =>
195
+ m?.kind === "Telo.JsonSchema" &&
196
+ m.schema &&
197
+ typeof m.metadata?.name === "string" &&
198
+ typeof m.metadata?.module === "string",
199
+ );
200
+ while (pending.length > 0) {
201
+ const deferred: Array<Record<string, any>> = [];
202
+ for (const m of pending) {
203
+ try {
204
+ if ((await createJsonSchemaType(m as unknown as ResourceManifest, ctx)) === null) {
205
+ deferred.push(m);
206
+ }
207
+ } catch {
208
+ // A broken type surfaces through analysis / runtime, not the warm pass.
209
+ }
210
+ }
211
+ if (deferred.length === pending.length) break;
212
+ pending = deferred;
213
+ }
214
+ }
215
+
147
216
  /**
148
217
  * Build-time cache warm for resource-config validators. The runtime
149
218
  * `_createInstance` compiles the declaring `Telo.Definition`'s `schema` to
@@ -188,11 +257,31 @@ export function precompileDefinitionSchemas(
188
257
  // Broken schemas are reported by analysis / runtime, not the warm pass.
189
258
  }
190
259
  };
260
+ const lookup = (name: string) => validator.getSchema(name);
261
+ // A contract validator is compiled from the RESOLVED schema with its
262
+ // `x-telo-stream` properties stripped, never from the declaration — see
263
+ // `resolveBoundContract`. Baking the declaration instead bakes a validator for
264
+ // `{kind, schema}`, which no dispatch asks for. A declaration that needs the
265
+ // runtime type registry (a bare name, a `{kind, name}` ref) resolves to
266
+ // nothing here — named types register when their resources initialize, long
267
+ // after the warm — so it is skipped rather than baked wrong.
268
+ const compileContract = (declared: unknown): void => {
269
+ if (declared === undefined || declared === null) return;
270
+ try {
271
+ const schema = resolveTypeFieldSchema(declared, lookup);
272
+ if (!schema) return;
273
+ compile(withStreamPropertiesSkipped(schema, (ref) => lookup(ref) as any));
274
+ } catch {
275
+ // An unresolvable contract is a dispatch-time error, not a warm failure.
276
+ }
277
+ };
191
278
  for (const m of manifests) {
279
+ // A per-instance contract overrides the kind's, so a resource declaring its
280
+ // own narrowing is what the runtime compiles for that instance.
281
+ compileContract(m?.inputType);
282
+ compileContract(m?.outputType);
192
283
  if (m?.kind !== "Telo.Definition") continue;
193
284
  compile(m.schema);
194
- compile(m.inputType);
195
- compile(m.outputType);
196
285
  if (resolverFor && m.extends) {
197
286
  // Mirrors the runtime stamp in `resource-definition-controller`; sharing
198
287
  // `effectiveAuthorSchema` is what keeps the two keys identical.
package/src/kernel.ts CHANGED
@@ -72,6 +72,7 @@ import {
72
72
  collectDeclaredEnvKeys,
73
73
  precompileApplicationEnvSchemas,
74
74
  precompileDefinitionSchemas,
75
+ precompileTypeSchemas,
75
76
  resolveApplicationEnv,
76
77
  } from "./application-env.js";
77
78
  import { policyFingerprint } from "./runtime-registry.js";
@@ -571,6 +572,26 @@ export class Kernel implements IKernel {
571
572
  // same content-addressed `__validators/` cache the runtime reads. The
572
573
  // resolver lets it also bake each `extends` child's inheritance-resolved
573
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);
574
595
  precompileDefinitionSchemas(staticManifests, this.sharedSchemaValidator, (def) =>
575
596
  this.registry.resolverForDefinition(def),
576
597
  );
@@ -2,7 +2,7 @@ import AjvModule from "ajv";
2
2
  import addFormats from "ajv-formats";
3
3
  // One definition of the `status:` block's shape, shared with `telo check` — the
4
4
  // `required:` restriction is reported by the analyzer, which can name the fix.
5
- import { OBSERVED_STATE_SCHEMA } from "@telorun/analyzer";
5
+ import { binaryKeyword, OBSERVED_STATE_SCHEMA } from "@telorun/analyzer";
6
6
  const Ajv = AjvModule.default ?? AjvModule;
7
7
 
8
8
  // Re-export the shared ResourceRef fragment from the templating package
@@ -177,6 +177,7 @@ export const ResourceAbstractSchema = {
177
177
  };
178
178
 
179
179
  const ajv = new Ajv({ allErrors: true, strict: false });
180
+ ajv.addKeyword(binaryKeyword());
180
181
  addFormats.default(ajv);
181
182
 
182
183
  // Lazy-compile validator: the AJV codegen cost (≈10–15 ms for these
@@ -1,3 +1,4 @@
1
+ import { binaryKeyword } from "@telorun/analyzer";
1
2
  import AjvModule from "ajv";
2
3
  import { detachSnapshotValue, OBSERVED_STATE_KEY, RuntimeError } from "@telorun/sdk";
3
4
 
@@ -57,6 +58,7 @@ function mark(target: Record<string, unknown>, info: ObservedStateInfo): void {
57
58
  }
58
59
 
59
60
  const ajv = new Ajv({ allErrors: true, strict: false });
61
+ ajv.addKeyword(binaryKeyword());
60
62
  // Compiling a status schema costs ~ms and a resource may report repeatedly, so
61
63
  // keep the validator keyed on the schema object it came from. The kind's folded
62
64
  // `status:` is stamped once at registration, so this hits.
@@ -28,6 +28,7 @@ import {
28
28
  type TypeRule,
29
29
  type ZoneEntry,
30
30
  } from "@telorun/sdk";
31
+ import { binaryKeyword } from "@telorun/analyzer";
31
32
  import { isRefSentinel } from "@telorun/templating";
32
33
  import { ZoneContext } from "./zone-context.js";
33
34
  import * as path from "path";
@@ -43,6 +44,7 @@ interface KernelModuleContext {
43
44
  getLoggingConfig?(): ScopeConfig | undefined;
44
45
  }
45
46
  import { stripCompiledValues } from "./schema-compiled-values.js";
47
+ import { resolveTypeFieldSchema } from "./type-field-schema.js";
46
48
  import AjvModule from "ajv";
47
49
  import addFormats from "ajv-formats";
48
50
  import { Kernel } from "./kernel.js";
@@ -179,7 +181,12 @@ export class ResourceContextImpl implements ResourceContext {
179
181
  if (!schema) {
180
182
  return new NoopValidator();
181
183
  }
182
- return this.validator.compile(schema);
184
+ // Never persisted: the schema is author data in a RESOURCE field, which the
185
+ // build-time warm does not walk, so a disk entry could only ever miss and
186
+ // be rewritten on every boot. Compiling through the kernel's validator is
187
+ // still what keeps one engine in the process — its formats, its `x-telo-*`
188
+ // keywords, its non-strict mode.
189
+ return this.validator.compile(schema, { persist: false });
183
190
  }
184
191
 
185
192
  registerSchema(name: string, schema: object): void {
@@ -216,54 +223,13 @@ export class ResourceContextImpl implements ResourceContext {
216
223
  * anyway, for the decisions a validator cannot answer: which properties carry
217
224
  * `x-telo-stream` and must be exempt from the walk, and which paths a
218
225
  * `default:` can be written to. Returns undefined when the reference resolves
219
- * to nothing. */
220
- resolveTypeSchema(typeRef: unknown): Record<string, any> | undefined {
221
- return this.followTypeAlias(this.readTypeSchema(typeRef), new Set());
222
- }
223
-
224
- private readTypeSchema(typeRef: unknown): Record<string, any> | undefined {
225
- if (!typeRef) return undefined;
226
- if (typeof typeRef === "string") return this.validator.getSchema(typeRef) as any;
227
- if (typeof typeRef !== "object") return undefined;
228
- const ref = typeRef as Record<string, any>;
229
- if (ref.schema && typeof ref.schema === "object") return ref.schema;
230
- if (typeof ref.name === "string") return this.validator.getSchema(ref.name) as any;
231
- if (ref.type || ref.properties || ref.$ref) return ref;
232
- return undefined;
233
- }
234
-
235
- /**
236
- * Follow a schema that is nothing but a `$ref` to a registered type, so the
237
- * schema-level questions (which properties are streams, which paths carry a
238
- * default) are asked of the real shape rather than of an alias.
239
- *
240
- * Only the whole-document alias form is followed, and only to READ it — the
241
- * schema handed to AJV keeps its `$ref`s intact, because AJV resolves them
242
- * itself against the registered ids and each type stays its own document with
243
- * its own `$defs`. Inlining instead would move a `$ref: "#/$defs/X"` out of the
244
- * document that defines `$defs.X`.
226
+ * to nothing.
245
227
  *
246
- * `seen` guards a cycle two mutually-referencing types would otherwise spin on.
247
- * A `$ref` alongside other keywords is left alone: that is a composition, not
248
- * an alias.
249
- */
250
- private followTypeAlias(
251
- schema: Record<string, any> | undefined,
252
- seen: Set<string>,
253
- ): Record<string, any> | undefined {
254
- let current = schema;
255
- while (
256
- current &&
257
- typeof current.$ref === "string" &&
258
- Object.keys(current).length === 1 &&
259
- !seen.has(current.$ref)
260
- ) {
261
- seen.add(current.$ref);
262
- const target = this.validator.getSchema(current.$ref) as Record<string, any> | undefined;
263
- if (!target) return current;
264
- current = target;
265
- }
266
- return current;
228
+ * Shared with the build-time validator warm through
229
+ * {@link resolveTypeFieldSchema} the warm must land on the same schema
230
+ * object the runtime compiles, or its baked entry is one nothing asks for. */
231
+ resolveTypeSchema(typeRef: unknown): Record<string, any> | undefined {
232
+ return resolveTypeFieldSchema(typeRef, (name) => this.validator.getSchema(name));
267
233
  }
268
234
 
269
235
  /** Compile `schema` but compose the CEL `rules:` registered under `name`.
@@ -336,6 +302,7 @@ export class ResourceContextImpl implements ResourceContext {
336
302
  for (const kw of ["x-telo-ref", "x-telo-scope", "x-telo-context", "x-telo-schema-from"]) {
337
303
  ajv.addKeyword(kw);
338
304
  }
305
+ ajv.addKeyword(binaryKeyword());
339
306
  const validate = ajv.compile(
340
307
  "type" in schema && typeof schema.type === "string"
341
308
  ? schema
@@ -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);
@@ -7,6 +7,7 @@ import { createHash } from "node:crypto";
7
7
  import * as fs from "node:fs";
8
8
  import { createRequire } from "node:module";
9
9
  import * as path from "node:path";
10
+ import { binaryKeyword, X_TELO_BINARY } from "@telorun/analyzer";
10
11
  import { formatAjvErrors } from "./manifest-schemas.js";
11
12
 
12
13
  /** Render a value for an error message without ever throwing.
@@ -146,6 +147,74 @@ function collapseSentinelsToSource(value: unknown): unknown {
146
147
  return value;
147
148
  }
148
149
 
150
+ /** Schema keywords whose VALUE is a map keyed by author-chosen names rather
151
+ * than by keyword. A name may legitimately be `x-telo-…`, so the strip below
152
+ * must not treat a key in one of these maps as an annotation. */
153
+ const NAME_KEYED_SCHEMA_KEYWORDS = new Set([
154
+ "properties",
155
+ "patternProperties",
156
+ "dependentSchemas",
157
+ "dependentRequired",
158
+ "$defs",
159
+ "definitions",
160
+ ]);
161
+
162
+ /** Schema keywords whose value is DATA, not a subschema. The strip must not
163
+ * descend into them at all: an `x-telo-…` key inside a `const` / `default` /
164
+ * `enum` member is part of the value being matched or filled, so removing it
165
+ * would change what the validator accepts and what it writes — and would make
166
+ * two schemas that differ only there hash alike. */
167
+ const DATA_VALUE_KEYWORDS = new Set(["const", "default", "enum", "examples"]);
168
+
169
+ /** Annotations that DO emit validation code, and so must survive the strip and stay
170
+ * in the cache key. `x-telo-binary` is the first: bytes have no JSON Schema type,
171
+ * so the keyword is the only thing standing between a byte slot and "accepts any
172
+ * object". Stripping it would silently reduce the slot to an empty schema — the
173
+ * precise regression the annotation was introduced to close — and, because the key
174
+ * is meant to describe the compiled validator, a keyword that changes the validator
175
+ * belongs in it. */
176
+ const VALIDATING_ANNOTATIONS = new Set([X_TELO_BINARY]);
177
+
178
+ /** Deep-clone `schema` without its `x-telo-*` annotations — applied, like
179
+ * {@link collapseSentinelsToSource}, before both AJV compilation and cache
180
+ * hashing.
181
+ *
182
+ * Almost every `x-telo-*` keyword is analyzer/editor metadata: AJV runs `strict:
183
+ * false` and registers the known ones as no-op keywords, so they emit no
184
+ * validation code. {@link VALIDATING_ANNOTATIONS} is the exception and is kept —
185
+ * see the note there. Leaving the rest in the hashed form makes the
186
+ * cache key sensitive to differences that cannot change what the validator
187
+ * does — and one such difference is real and systematic. The analyzer rewrites
188
+ * `x-telo-ref.kind` to its canonical `<module>.<Kind>` in the declaring scope
189
+ * (`resolveSchemaRefKinds`), and `telo install`'s warm pass bakes THAT view;
190
+ * the kernel's controller registry never runs the rewrite, so at runtime the
191
+ * same kind's schema still reads `Self.Connection`. Two keys, one validator:
192
+ * every kind whose schema declares an alias-qualified ref missed the baked
193
+ * cache on every boot and tried to rewrite it — the EACCES noise on a
194
+ * read-only image.
195
+ *
196
+ * Stripping is what makes the key describe the compiled validator and nothing
197
+ * else, so the two views converge without either side having to agree on an
198
+ * annotation's spelling. `normalizeRefSlots` runs FIRST and is unaffected: it
199
+ * reads `x-telo-ref` to drop a legacy scalar `type` at a ref slot, which does
200
+ * change validation, and it has already done so by the time this runs. */
201
+ function stripTeloAnnotations(value: unknown, nameKeyed = false): unknown {
202
+ // An array's items are schema nodes (`allOf`, tuple `items`), never names.
203
+ if (Array.isArray(value)) return value.map((item) => stripTeloAnnotations(item));
204
+ if (!value || typeof value !== "object") return value;
205
+ const out: Record<string, unknown> = {};
206
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
207
+ if (!nameKeyed && k.startsWith("x-telo-") && !VALIDATING_ANNOTATIONS.has(k)) continue;
208
+ // A data-bearing keyword's value is carried over verbatim; a name-keyed
209
+ // map's VALUES are schema nodes again, so only its keys are exempt.
210
+ out[k] =
211
+ !nameKeyed && DATA_VALUE_KEYWORDS.has(k)
212
+ ? v
213
+ : stripTeloAnnotations(v, !nameKeyed && NAME_KEYED_SCHEMA_KEYWORDS.has(k));
214
+ }
215
+ return out;
216
+ }
217
+
149
218
  export class SchemaValidator {
150
219
  private ajv: InstanceType<typeof Ajv>;
151
220
  private typeRules = new Map<string, TypeRule[]>();
@@ -162,6 +231,13 @@ export class SchemaValidator {
162
231
  * process — `compiledValidators` is keyed by object identity and would
163
232
  * miss those cases. */
164
233
  private hashCache = new Map<string, DataValidator>();
234
+ /** Hashes whose compile went through the disk layer. A `persist: false`
235
+ * compile populates `hashCache` too — a repeat within the process should
236
+ * still collapse — but must not be mistaken for a baked entry: returning it
237
+ * to a persisting caller would suppress that caller's write permanently, so
238
+ * content shared with a warmable schema would never reach the cache. Such a
239
+ * caller falls through and compiles again, this time with the disk layer. */
240
+ private persistedHashes = new Set<string>();
165
241
  /** Where cache-failure diagnostics go. Injected rather than reached for
166
242
  * globally: this class is constructed outside the kernel's stdio scope, and
167
243
  * §13.1 forbids the kernel writing to `process.stderr` directly. Defaults to
@@ -200,6 +276,11 @@ export class SchemaValidator {
200
276
  ]) {
201
277
  this.ajv.addKeyword(kw);
202
278
  }
279
+ // Not a no-op like the rest: bytes have no JSON Schema type, so this keyword IS
280
+ // the check. Defined as codegen in the analyzer so it inlines into the
281
+ // standalone validators compiled and cached below, rather than needing the
282
+ // implementation present at load.
283
+ this.ajv.addKeyword(binaryKeyword());
203
284
  // Register the shared manifest root so module schemas can
204
285
  // `$ref: "telo://manifest#/$defs/ResourceRef"` without each manifest
205
286
  // bundling its own copy. Mirrors the analyzer's createAjv().
@@ -239,7 +320,18 @@ export class SchemaValidator {
239
320
  this.cacheWritable = opts?.write ?? true;
240
321
  }
241
322
 
242
- compile(schema: any): DataValidator {
323
+ /** Compile `schema` to a validator, reusing the in-memory and on-disk caches.
324
+ *
325
+ * `persist: false` keeps the compile in memory only — no disk read, no disk
326
+ * write. It is for a schema the build-time warm cannot see: an author-written
327
+ * JSON Schema sitting in a RESOURCE field (`ctx.createSchemaValidator`),
328
+ * rather than a kind's config schema or an invocation contract. Those are
329
+ * baked by `precompileDefinitionSchemas`; a resource-field schema never was,
330
+ * so persisting it only ever produced a miss-then-write on every boot — the
331
+ * EACCES noise on a read-only image. Declining to own what it cannot warm is
332
+ * the cache being honest, not a capability given up: the in-memory layers
333
+ * still collapse a repeat compile within the process. */
334
+ compile(schema: any, options?: { persist?: boolean }): DataValidator {
243
335
  if (schema && typeof schema === "object") {
244
336
  const cached = this.compiledValidators.get(schema as object);
245
337
  if (cached) return cached;
@@ -284,7 +376,7 @@ export class SchemaValidator {
284
376
  // precompiled (runtime) views of one schema land on the same cache key. The
285
377
  // hashed and the compiled schema are this same canonical form. See
286
378
  // `collapseSentinelsToSource`.
287
- const sanitized = collapseSentinelsToSource(injected);
379
+ const sanitized = collapseSentinelsToSource(stripTeloAnnotations(injected));
288
380
 
289
381
  const hash = createHash("sha256")
290
382
  .update(
@@ -295,15 +387,17 @@ export class SchemaValidator {
295
387
  )
296
388
  .digest("hex")
297
389
  .slice(0, 32);
390
+ const persist = options?.persist ?? true;
298
391
  const cachedByHash = this.hashCache.get(hash);
299
- if (cachedByHash) {
392
+ if (cachedByHash && (!persist || this.persistedHashes.has(hash))) {
300
393
  if (schema && typeof schema === "object") {
301
394
  this.compiledValidators.set(schema as object, cachedByHash);
302
395
  }
303
396
  return cachedByHash;
304
397
  }
305
398
 
306
- const validate = this.compileAjvOrLoadCached(sanitized, hash);
399
+ const validate = this.compileAjvOrLoadCached(sanitized, hash, persist);
400
+ if (persist) this.persistedHashes.add(hash);
307
401
 
308
402
  const validator = {
309
403
  validate: (data: any) => {
@@ -342,8 +436,12 @@ export class SchemaValidator {
342
436
  private compileAjvOrLoadCached(
343
437
  schema: any,
344
438
  hash: string,
439
+ persist: boolean,
345
440
  ): ValidateFunction {
346
- const cacheDir = this.cacheDir;
441
+ // `persist: false` drops the whole disk layer — the read too, not just the
442
+ // write. Nothing bakes these entries, so a lookup is an ENOENT probe whose
443
+ // only possible hit is one this process wrote on an earlier run.
444
+ const cacheDir = persist ? this.cacheDir : undefined;
347
445
  if (cacheDir) {
348
446
  const cachePath = path.join(cacheDir, `${hash}.cjs`);
349
447
  try {
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Resolve a type field (`inputType` / `outputType`, or any `telo#Type` slot) to
3
+ * the JSON Schema behind it.
4
+ *
5
+ * Extracted from `ResourceContextImpl` so the build-time validator warm
6
+ * (`precompileDefinitionSchemas`) resolves a contract through the SAME code the
7
+ * runtime binding does. It used to compile the raw declaration instead —
8
+ * `{kind: Telo.JsonSchema, schema: {...}}`, which is not a JSON Schema at all,
9
+ * so `SchemaValidator.compile` read it as a property map and baked a validator
10
+ * for `{kind, schema}` that no dispatch would ever ask for. One resolver, one
11
+ * cache key.
12
+ *
13
+ * `getSchema` is the registry lookup — `SchemaValidator.getSchema` at both call
14
+ * sites. At warm time named types are not registered yet, so a bare-name
15
+ * declaration resolves to `undefined` and the caller simply skips it.
16
+ */
17
+ export type SchemaLookup = (name: string) => object | undefined;
18
+
19
+ /** The four declaration forms: a registered type's name, a `{kind, name}` ref
20
+ * object, an inline `{kind, schema}` type resource, and a raw JSON Schema. */
21
+ function readTypeSchema(
22
+ typeRef: unknown,
23
+ getSchema: SchemaLookup,
24
+ ): Record<string, any> | undefined {
25
+ if (!typeRef) return undefined;
26
+ if (typeof typeRef === "string") return getSchema(typeRef) as Record<string, any> | undefined;
27
+ if (typeof typeRef !== "object") return undefined;
28
+ const ref = typeRef as Record<string, any>;
29
+ if (ref.schema && typeof ref.schema === "object") return ref.schema;
30
+ if (typeof ref.name === "string") return getSchema(ref.name) as Record<string, any> | undefined;
31
+ if (ref.type || ref.properties || ref.$ref) return ref;
32
+ return undefined;
33
+ }
34
+
35
+ /**
36
+ * Follow a schema that is nothing but a `$ref` to a registered type, so the
37
+ * schema-level questions (which properties are streams, which paths carry a
38
+ * default) are asked of the real shape rather than of an alias.
39
+ *
40
+ * Only the whole-document alias form is followed, and only to READ it — the
41
+ * schema handed to AJV keeps its `$ref`s intact, because AJV resolves them
42
+ * itself against the registered ids and each type stays its own document with
43
+ * its own `$defs`. Inlining instead would move a `$ref: "#/$defs/X"` out of the
44
+ * document that defines `$defs.X`.
45
+ *
46
+ * `seen` guards a cycle two mutually-referencing types would otherwise spin on.
47
+ * A `$ref` alongside other keywords is left alone: that is a composition, not
48
+ * an alias.
49
+ */
50
+ function followTypeAlias(
51
+ schema: Record<string, any> | undefined,
52
+ getSchema: SchemaLookup,
53
+ ): Record<string, any> | undefined {
54
+ const seen = new Set<string>();
55
+ let current = schema;
56
+ while (
57
+ current &&
58
+ typeof current.$ref === "string" &&
59
+ Object.keys(current).length === 1 &&
60
+ !seen.has(current.$ref)
61
+ ) {
62
+ seen.add(current.$ref);
63
+ const target = getSchema(current.$ref) as Record<string, any> | undefined;
64
+ if (!target) return current;
65
+ current = target;
66
+ }
67
+ return current;
68
+ }
69
+
70
+ /** The JSON Schema a type field names, or `undefined` when it resolves to
71
+ * nothing (an unregistered name, a declaration in none of the four forms). */
72
+ export function resolveTypeFieldSchema(
73
+ typeRef: unknown,
74
+ getSchema: SchemaLookup,
75
+ ): Record<string, any> | undefined {
76
+ return followTypeAlias(readTypeSchema(typeRef, getSchema), getSchema);
77
+ }