@telorun/kernel 0.24.1 → 0.25.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 (38) hide show
  1. package/dist/application-env.d.ts +34 -0
  2. package/dist/application-env.d.ts.map +1 -1
  3. package/dist/application-env.js +77 -0
  4. package/dist/application-env.js.map +1 -1
  5. package/dist/controller-loaders/npm-loader.d.ts +41 -12
  6. package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
  7. package/dist/controller-loaders/npm-loader.js +117 -51
  8. package/dist/controller-loaders/npm-loader.js.map +1 -1
  9. package/dist/controller-registry.d.ts +9 -0
  10. package/dist/controller-registry.d.ts.map +1 -1
  11. package/dist/controller-registry.js +18 -0
  12. package/dist/controller-registry.js.map +1 -1
  13. package/dist/controllers/module/import-controller.d.ts.map +1 -1
  14. package/dist/controllers/module/import-controller.js +50 -28
  15. package/dist/controllers/module/import-controller.js.map +1 -1
  16. package/dist/evaluation-context.d.ts.map +1 -1
  17. package/dist/evaluation-context.js +13 -5
  18. package/dist/evaluation-context.js.map +1 -1
  19. package/dist/kernel.d.ts +13 -2
  20. package/dist/kernel.d.ts.map +1 -1
  21. package/dist/kernel.js +41 -2
  22. package/dist/kernel.js.map +1 -1
  23. package/dist/module-context.d.ts +56 -7
  24. package/dist/module-context.d.ts.map +1 -1
  25. package/dist/module-context.js +121 -10
  26. package/dist/module-context.js.map +1 -1
  27. package/dist/schema-validator.d.ts.map +1 -1
  28. package/dist/schema-validator.js +39 -3
  29. package/dist/schema-validator.js.map +1 -1
  30. package/package.json +2 -2
  31. package/src/application-env.ts +81 -0
  32. package/src/controller-loaders/npm-loader.ts +124 -58
  33. package/src/controller-registry.ts +18 -0
  34. package/src/controllers/module/import-controller.ts +58 -28
  35. package/src/evaluation-context.ts +15 -5
  36. package/src/kernel.ts +52 -2
  37. package/src/module-context.ts +153 -11
  38. package/src/schema-validator.ts +41 -3
@@ -1,4 +1,4 @@
1
- import { AnalysisRegistry, DiagnosticSeverity, StaticAnalyzer } from "@telorun/analyzer";
1
+ import { AnalysisRegistry, DiagnosticSeverity, parseExportEntry, StaticAnalyzer } from "@telorun/analyzer";
2
2
  import type { ResourceInstance } from "@telorun/sdk";
3
3
  import { RuntimeError } from "@telorun/sdk";
4
4
  import type { BuiltinControllerContext } from "../../internal-context.js";
@@ -108,12 +108,27 @@ export async function create(
108
108
  validateRequiredInputs(moduleManifest.variables ?? {}, resource.variables ?? {}, "variables");
109
109
  validateRequiredInputs(moduleManifest.secrets ?? {}, resource.secrets ?? {}, "secrets");
110
110
 
111
- // Create child context with the imported variables/secrets baked in, so that
112
- // ${{ variables.x }} / ${{ secrets.y }} templates resolve correctly at runtime.
111
+ // Evaluate the import's variables/secrets ONCE against the IMPORTER's config
112
+ // scope, instead of baking the raw compiled-value objects verbatim. Resolution
113
+ // is eager and per-hop: each importer resolves its child's inputs from its own
114
+ // already-settled config, so config flows app -> lib -> lib at any nesting depth
115
+ // and a leaf reads `variables.X` as an O(1) concrete lookup with no chain-walk.
116
+ // The single concrete result seeds both the child scope and the snapshot value-
117
+ // flow surface, so they can never diverge.
118
+ //
119
+ // NOTE: `expandValue` evaluates against the importer's FULL context (variables /
120
+ // secrets, plus env/resources for a root app). The config-only import contract —
121
+ // no resources/env/ports in import inputs — is enforced by the analyzer, not here;
122
+ // a `${{ resources.X }}` slipping past analysis (skipValidation / programmatic
123
+ // load) would evaluate at runtime rather than being rejected.
124
+ const importVariables =
125
+ (ctx.expandValue(resource.variables, {}) as Record<string, unknown>) ?? {};
126
+ const importSecrets =
127
+ (ctx.expandValue(resource.secrets, {}) as Record<string, unknown>) ?? {};
113
128
  const childCtx = new ModuleContext(
114
129
  ctx.moduleContext.source,
115
- (resource.variables as Record<string, unknown>) ?? {},
116
- (resource.secrets as Record<string, unknown>) ?? {},
130
+ importVariables,
131
+ importSecrets,
117
132
  {},
118
133
  [],
119
134
  ctx.moduleContext.createInstance,
@@ -157,8 +172,26 @@ export async function create(
157
172
  // Throws if resources.X is not yet populated — the kernel retry loop catches this and retries.
158
173
  // const evaluatedExports: any = child.expand(moduleManifest.exports ?? {});
159
174
 
160
- const exportedKinds: string[] = moduleManifest.exports?.kinds ?? [];
161
- const exportedResourceNames: string[] = moduleManifest.exports?.resources ?? [];
175
+ // `exports.kinds` entries are a bare kind name (locally defined) or `Alias.Kind` (a re-export
176
+ // of an imported library's kind). `parseExportEntry` (shared with the analyzer) yields
177
+ // `{name, alias?}` — `name` is the exported kind suffix, `alias` (when set) names this
178
+ // library's own import it re-exports from.
179
+ const kindEntries = ((moduleManifest.exports?.kinds ?? []) as string[]).map(parseExportEntry);
180
+ const exportedKindSuffixes = kindEntries.map((k) => k.name);
181
+ // `exports.resources` entries are a bare name (`Db`, a locally-owned export) or a dotted
182
+ // `Alias.Name` (re-export of an imported instance, under name `Name`) — same grammar as
183
+ // `exports.kinds`.
184
+ const exportEntries = ((moduleManifest.exports?.resources ?? []) as unknown[]).map((e) => {
185
+ if (typeof e !== "string") {
186
+ throw new RuntimeError(
187
+ "ERR_INVALID_EXPORT",
188
+ `Library '${targetModule}' exports.resources entries must be plain names ('Name' or ` +
189
+ `'Alias.Name'); the '!ref' tag is not allowed in exports.resources.`,
190
+ );
191
+ }
192
+ return parseExportEntry(e);
193
+ });
194
+ const exportedResourceNames = exportEntries.map((e) => e.name);
162
195
  for (const name of exportedResourceNames) {
163
196
  if (name === "variables" || name === "secrets") {
164
197
  throw new RuntimeError(
@@ -167,30 +200,22 @@ export async function create(
167
200
  );
168
201
  }
169
202
  }
170
- ctx.registerModuleImport(alias, targetModule, exportedKinds);
203
+ ctx.registerModuleImport(alias, targetModule, exportedKindSuffixes);
171
204
 
172
205
  // Publish the child's exported instances to the parent so cross-module `!ref Alias.name`
173
206
  // (Phase 5 injection / boot targets) and `${{ resources.Alias.name }}` (CEL value-flow)
174
- // resolve. The gate is `exports.resources`; the lookup is lazy instances exist after
175
- // this import's init() has run child.initializeResources().
207
+ // resolve. The gate is `exports.resources`; the child's terminal getter is read lazily
208
+ // it exists after this import's init() built the child's export table. Handing the parent
209
+ // the child's TERMINAL getter (not a wrapper) keeps resolution O(1) across re-export hops.
176
210
  (ctx.moduleContext as ModuleContext).registerImportedScope(
177
211
  alias,
178
212
  exportedResourceNames,
179
- (name) => {
180
- const entry = childCtx.resourceInstances.get(name);
181
- if (!entry) return undefined;
182
- // Canonicalize the authored kind to `<module>.<Kind>` so the cross-module ref shape and
183
- // event naming are scope-independent. Exported instances are authored `Self.<Kind>` —
184
- // the only case the std modules use, and the only one canonicalized here; it maps to the
185
- // owning module directly, matching the analyzer's rule in resolve-ref-sentinels.ts. A
186
- // re-exported foreign kind (authored via another import alias) is a future case with no
187
- // current consumer and is left verbatim.
188
- const rawKind = entry.resource.kind as string;
189
- const kind = rawKind.startsWith("Self.")
190
- ? `${targetModule}.${rawKind.slice("Self.".length)}`
191
- : rawKind;
192
- return { kind, instance: entry.instance };
193
- },
213
+ (name) => childCtx.getTerminalExport(name),
214
+ );
215
+ // Same for kinds: `kind: Alias.Kind` resolves through the child's exported-kind table,
216
+ // covering both locally-defined and transitively re-exported kinds in O(1).
217
+ (ctx.moduleContext as ModuleContext).registerImportedKindScope(alias, (suffix) =>
218
+ childCtx.getExportedKind(suffix),
194
219
  );
195
220
 
196
221
  // Return a ResourceInstance whose snapshot() surfaces the exported values under
@@ -201,19 +226,24 @@ export async function create(
201
226
  snapshot: async () => {
202
227
  const exported: Record<string, unknown> = {};
203
228
  for (const name of exportedResourceNames) {
204
- const inst = childCtx.resourceInstances.get(name)?.instance;
229
+ const inst = childCtx.getExported(name)?.instance;
205
230
  if (inst && typeof inst.snapshot === "function") {
206
231
  exported[name] = await Promise.resolve(inst.snapshot());
207
232
  }
208
233
  }
209
234
  return {
210
- variables: ctx.expandValue(resource.variables, {}) ?? {},
211
- secrets: ctx.expandValue(resource.secrets, {}) ?? {},
235
+ variables: importVariables,
236
+ secrets: importSecrets,
212
237
  ...exported,
213
238
  };
214
239
  },
215
240
  init: async () => {
216
241
  await child.initializeResources();
242
+ // Build this import's flattened export tables now that its own imports are
243
+ // registered (leaves-first), so a re-export (`!ref Alias.name` / `Alias.Kind`)
244
+ // copies the source import's terminal getter / canonical kind by reference —
245
+ // O(1) resolution at any depth.
246
+ childCtx.buildExportTable(exportEntries, kindEntries, targetModule);
217
247
  },
218
248
  teardown: async () => {
219
249
  await child.teardownResources();
@@ -501,10 +501,13 @@ export class EvaluationContext implements IEvaluationContext {
501
501
  // the resolved entry for event topics and error messages.
502
502
  const effectiveKind = kind || (entry.resource.kind as string);
503
503
 
504
- if (typeof entry.instance.invoke !== "function") {
504
+ if (
505
+ typeof entry.instance.invoke !== "function" &&
506
+ typeof entry.instance.run !== "function"
507
+ ) {
505
508
  throw new RuntimeError(
506
509
  "ERR_RESOURCE_NOT_INVOKABLE",
507
- `Resource ${effectiveKind}.${name} does not have an invoke method`,
510
+ `Resource ${effectiveKind}.${name} does not have an invoke or run method`,
508
511
  );
509
512
  }
510
513
 
@@ -524,10 +527,10 @@ export class EvaluationContext implements IEvaluationContext {
524
527
  inputs: TInputs,
525
528
  ctx?: InvokeContext,
526
529
  ): Promise<any> {
527
- if (typeof instance.invoke !== "function") {
530
+ if (typeof instance.invoke !== "function" && typeof instance.run !== "function") {
528
531
  throw new RuntimeError(
529
532
  "ERR_RESOURCE_NOT_INVOKABLE",
530
- `Resource ${kind}.${name} does not have an invoke method`,
533
+ `Resource ${kind}.${name} does not have an invoke or run method`,
531
534
  );
532
535
  }
533
536
  return this.runInvoke(kind, name, instance, inputs, ctx);
@@ -560,8 +563,15 @@ export class EvaluationContext implements IEvaluationContext {
560
563
  try {
561
564
  // Only (re)establish the ALS scope when the token differs from the ambient
562
565
  // one — nested invokes that inherited it skip the redundant `run`.
566
+ // A step / boot-target slot may reference a pure Runnable (the schema
567
+ // allows `telo#Runnable` alongside `telo#Invocable`); dispatch it via
568
+ // `run()` — side effects only, no outputs. Prefer `invoke()` when both
569
+ // exist so a dual-capability instance (e.g. Run.Sequence) keeps invoke
570
+ // semantics and returns its `steps`/`outputs`.
563
571
  const call = () =>
564
- (instance.invoke as (i: any, c?: InvokeContext) => any)(inputs as any, invokeCtx);
572
+ typeof instance.invoke === "function"
573
+ ? (instance.invoke as (i: any, c?: InvokeContext) => any)(inputs as any, invokeCtx)
574
+ : (instance.run as (c?: InvokeContext) => any)(invokeCtx);
565
575
  const outputs = await (invokeCtx === ambient
566
576
  ? call()
567
577
  : cancellationStore.run(invokeCtx, call));
package/src/kernel.ts CHANGED
@@ -41,7 +41,11 @@ import {
41
41
  writeAnalysisStamp,
42
42
  } from "./manifest-sources/analysis-stamp.js";
43
43
  import { resolveEntryDir } from "./manifest-sources/local-manifest-cache-source.js";
44
- import { resolveApplicationEnv } from "./application-env.js";
44
+ import {
45
+ precompileApplicationEnvSchemas,
46
+ precompileDefinitionSchemas,
47
+ resolveApplicationEnv,
48
+ } from "./application-env.js";
45
49
  import { policyFingerprint } from "./runtime-registry.js";
46
50
  import { SchemaValidator } from "./schema-validator.js";
47
51
 
@@ -305,8 +309,17 @@ export class Kernel implements IKernel {
305
309
  * Load a manifest by URL. The URL is dispatched through the registered
306
310
  * `ManifestSource` chain (file://, http://, pkg:, memory://, …); URL-shape
307
311
  * normalization is each source's responsibility.
312
+ *
313
+ * `analyzeOnly` runs the static-analysis pre-flight and persists its
314
+ * caches (the `.validated.json` analysis stamp and the compiled
315
+ * `__validators/` schema cache) but stops before module instantiation,
316
+ * target wiring, and application-env/secret resolution. Build steps
317
+ * (`telo install`) use it to bake those caches into a prebuilt image on a
318
+ * writable filesystem, so the runtime `load()` — which runs on a read-only
319
+ * session rootfs — hits the stamp and skips the validation walk entirely
320
+ * instead of failing to write the caches on every boot.
308
321
  */
309
- async load(url: string): Promise<void> {
322
+ async load(url: string, options?: { analyzeOnly?: boolean }): Promise<void> {
310
323
  const sourceUrl = await this.loader.resolveEntryPoint(url);
311
324
  this._entryUrl = sourceUrl;
312
325
  // Point the shared schema validator at the entry-anchored cache so
@@ -417,6 +430,43 @@ export class Kernel implements IKernel {
417
430
  }
418
431
  }
419
432
 
433
+ // Build-time warm pass: the analysis caches are now on disk. Also bake the
434
+ // application-env residual validators — the runtime `resolveApplicationEnv`
435
+ // (below) compiles these on EVERY boot regardless of the stamp, so without
436
+ // pre-compiling them here an app with `variables`/`secrets`/`ports` would
437
+ // still hit the read-only `__validators` write on a baked image. Then stop
438
+ // before module instantiation / target wiring / application-env value
439
+ // resolution — those need a running environment (e.g. session secrets) the
440
+ // build does not have, and the runtime `load()` performs them anyway.
441
+ if (options?.analyzeOnly) {
442
+ if (rootModuleDoc?.kind === "Telo.Application") {
443
+ precompileApplicationEnvSchemas(
444
+ rootModuleDoc as Record<string, any>,
445
+ this.sharedSchemaValidator,
446
+ );
447
+ }
448
+ // Bake the per-kind resource-config validators too. `_createInstance`
449
+ // compiles `controller.schema` (which falls back to the definition's own
450
+ // `schema`) for every resource at runtime — work this analyze-only pass
451
+ // otherwise skips because it stops before instantiation, leaving the
452
+ // runtime to recompile and fail to persist them on a read-only image.
453
+ // Pre-compiling every `Telo.Definition` schema here writes them into the
454
+ // same content-addressed `__validators/` cache the runtime reads.
455
+ precompileDefinitionSchemas(staticManifests, this.sharedSchemaValidator);
456
+ // Framework/builtin controller schemas (`Telo.Import`, `Telo.Definition`,
457
+ // the module controller, …) aren't in the static manifests but are
458
+ // validated per-resource at runtime just the same. They're registered by
459
+ // `loadBuiltinDefinitions` above, so bake them from the registry.
460
+ precompileDefinitionSchemas(
461
+ this.controllers.getControllerSchemas().map((schema) => ({
462
+ kind: "Telo.Definition",
463
+ schema,
464
+ })),
465
+ this.sharedSchemaValidator,
466
+ );
467
+ return;
468
+ }
469
+
420
470
  // Load runtime configuration — root module gets access to host env.
421
471
  // Imports are loaded separately via the import-controller; this load is
422
472
  // entry-only with compile-time CEL.
@@ -103,15 +103,41 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
103
103
  /** Maps import alias → its child context's exported instances. Registered by the
104
104
  * Telo.Import controller; read when resolving a cross-module `!ref Alias.name`
105
105
  * reference (Phase 5 injection and boot targets). `names` is the import's
106
- * `exports.resources` gate — only listed instances are reachable. */
106
+ * `exports.resources` gate — only listed instances are reachable. `terminal`
107
+ * returns the child's pre-flattened terminal getter for a name (a closure that
108
+ * points directly at the OWNING context's instance, however many re-export hops
109
+ * away), so resolution stays O(1) regardless of re-export depth. */
107
110
  private readonly importedScopes = new Map<
108
111
  string,
109
112
  {
110
113
  names: Set<string>;
111
- get: (name: string) => { kind: string; instance: ResourceInstance } | undefined;
114
+ terminal: (
115
+ name: string,
116
+ ) => (() => { kind: string; instance: ResourceInstance } | undefined) | undefined;
112
117
  }
113
118
  >();
114
119
 
120
+ /** This module's flattened export table: export name → terminal getter. A local
121
+ * export's getter reads this context's own `resourceInstances`; a re-export
122
+ * (`!ref Alias.name`) holds the SAME closure object as the owner's entry, copied
123
+ * by reference at build time so depth adds no resolution hops. Built once by the
124
+ * Telo.Import controller after this module's own imports have initialized. */
125
+ private readonly exportedGetters = new Map<
126
+ string,
127
+ () => { kind: string; instance: ResourceInstance } | undefined
128
+ >();
129
+
130
+ /** This module's flattened exported-KIND table: exported kind suffix → canonical
131
+ * `<owningModule>.<Kind>`. A local exported kind maps to `<thisModule>.<Kind>`; a
132
+ * re-export (`exports.kinds: [Alias.Kind]`) copies the source import's already-canonical
133
+ * string, so depth adds no resolution hops. Built by `buildExportTable`. */
134
+ private readonly exportedKinds = new Map<string, string>();
135
+
136
+ /** Maps import alias → a resolver into that import's exported-kind table. Registered by the
137
+ * Telo.Import controller; consulted by `resolveKind` so an imported library's kinds — local
138
+ * OR transitively re-exported — resolve to their true owning module in O(1). */
139
+ private readonly importedKindResolvers = new Map<string, (suffix: string) => string | undefined>();
140
+
115
141
  /**
116
142
  * Resolved controller-selection policy for this module's `Telo.Definition`s.
117
143
  * Stamped by the parent `Telo.Import` controller from the import's `runtime:`
@@ -202,24 +228,127 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
202
228
  }
203
229
 
204
230
  /** Register an import alias's exported instances for cross-module reference resolution.
205
- * `names` is the gate (the import's `exports.resources`); `get` reads the child context's
206
- * live instance + its canonical kind lazily they exist only after the import's `init()`
207
- * runs. */
231
+ * `names` is the gate (the import's `exports.resources`); `terminal` returns the child
232
+ * context's pre-flattened terminal getter for a name (existing only after the import's
233
+ * `init()` built the child's export table). */
208
234
  registerImportedScope(
209
235
  alias: string,
210
236
  names: string[],
211
- get: (name: string) => { kind: string; instance: ResourceInstance } | undefined,
237
+ terminal: (
238
+ name: string,
239
+ ) => (() => { kind: string; instance: ResourceInstance } | undefined) | undefined,
212
240
  ): void {
213
- this.importedScopes.set(alias, { names: new Set(names), get });
241
+ this.importedScopes.set(alias, { names: new Set(names), terminal });
242
+ }
243
+
244
+ /** This module's terminal getter for an exported `name`, or undefined. Read by a parent
245
+ * import building its own (re-)export table — copying this closure by reference flattens
246
+ * the chain so resolution never walks hops. */
247
+ getTerminalExport(
248
+ name: string,
249
+ ): (() => { kind: string; instance: ResourceInstance } | undefined) | undefined {
250
+ return this.exportedGetters.get(name);
251
+ }
252
+
253
+ /** O(1) resolution of an exported instance owned or re-exported by this module. */
254
+ getExported(name: string): { kind: string; instance: ResourceInstance } | undefined {
255
+ return this.exportedGetters.get(name)?.();
256
+ }
257
+
258
+ /** Register an import alias's exported-kind resolver (the child's `getExportedKind`). */
259
+ registerImportedKindScope(alias: string, resolve: (suffix: string) => string | undefined): void {
260
+ this.importedKindResolvers.set(alias, resolve);
261
+ }
262
+
263
+ /** This module's canonical kind for an exported suffix (local or re-exported), or undefined. */
264
+ getExportedKind(suffix: string): string | undefined {
265
+ return this.exportedKinds.get(suffix);
266
+ }
267
+
268
+ /** Build this module's flattened export tables from `exports.resources` / `exports.kinds`.
269
+ * A resource entry is a local export (no alias) or a re-export `Alias.Name` (alias set); a
270
+ * kind entry is a local kind (no alias) or a re-export `Alias.Kind` (alias set). A local
271
+ * export gets a fresh terminal getter / `<module>.<Kind>` canonical; a re-export copies the
272
+ * source import's terminal getter / already-canonical kind by reference, collapsing the chain
273
+ * to a single hop. Called once by the Telo.Import controller after this module's own imports
274
+ * have initialized (leaves-first), so re-export sources are already registered — an
275
+ * unresolvable re-export is therefore a permanent misconfiguration and throws here. */
276
+ buildExportTable(
277
+ entries: ReadonlyArray<{ name: string; alias?: string }>,
278
+ kindEntries: ReadonlyArray<{ name: string; alias?: string }>,
279
+ moduleName: string,
280
+ ): void {
281
+ for (const entry of entries) {
282
+ const name = entry.name;
283
+ if (entry.alias && entry.alias !== "Self") {
284
+ // A re-export resolves against this module's own imports, which have all initialized
285
+ // by now (leaves-first) — so an unresolved source is a permanent misconfiguration, not
286
+ // a transient miss. Surface it instead of silently dropping the export.
287
+ const scope = this.importedScopes.get(entry.alias);
288
+ if (!scope) {
289
+ throw new RuntimeError(
290
+ "ERR_INVALID_REEXPORT",
291
+ `Library '${moduleName}' re-exports '${entry.alias}.${name}' but declares no import ` +
292
+ `aliased '${entry.alias}'. Add it to this library's 'imports', or remove the ` +
293
+ `'exports.resources' entry.`,
294
+ );
295
+ }
296
+ const terminal = scope.terminal(name);
297
+ if (!terminal) {
298
+ throw new RuntimeError(
299
+ "ERR_INVALID_REEXPORT",
300
+ `Library '${moduleName}' re-exports '${entry.alias}.${name}', but the imported ` +
301
+ `library '${entry.alias}' exports no instance named '${name}'.`,
302
+ );
303
+ }
304
+ this.exportedGetters.set(name, terminal);
305
+ continue;
306
+ }
307
+ this.exportedGetters.set(name, () => {
308
+ const inst = this.resourceInstances.get(name);
309
+ if (!inst) return undefined;
310
+ // Canonicalize the authored kind to `<module>.<Kind>` so the cross-module ref shape
311
+ // and event naming are scope-independent (matches resolve-ref-sentinels.ts).
312
+ const rawKind = inst.resource.kind as string;
313
+ const kind = rawKind.startsWith("Self.")
314
+ ? `${moduleName}.${rawKind.slice("Self.".length)}`
315
+ : rawKind;
316
+ return { kind, instance: inst.instance };
317
+ });
318
+ }
319
+ for (const k of kindEntries) {
320
+ if (k.alias && k.alias !== "Self") {
321
+ const resolver = this.importedKindResolvers.get(k.alias);
322
+ if (!resolver) {
323
+ throw new RuntimeError(
324
+ "ERR_INVALID_REEXPORT",
325
+ `Library '${moduleName}' re-exports kind '${k.alias}.${k.name}' but declares no ` +
326
+ `import aliased '${k.alias}'. Add it to this library's 'imports', or remove the ` +
327
+ `'exports.kinds' entry.`,
328
+ );
329
+ }
330
+ const canonical = resolver(k.name);
331
+ if (!canonical) {
332
+ throw new RuntimeError(
333
+ "ERR_INVALID_REEXPORT",
334
+ `Library '${moduleName}' re-exports kind '${k.alias}.${k.name}', but the imported ` +
335
+ `library '${k.alias}' exports no kind named '${k.name}'.`,
336
+ );
337
+ }
338
+ this.exportedKinds.set(k.name, canonical);
339
+ continue;
340
+ }
341
+ this.exportedKinds.set(k.name, `${moduleName}.${k.name}`);
342
+ }
214
343
  }
215
344
 
216
345
  /** Resolve `Alias.name` to a live exported instance, gated by `exports.resources`.
217
346
  * Returns undefined when the alias is unknown, the name isn't exported, or the import
218
- * hasn't initialized its instances yet (the injection path defers and retries). */
347
+ * hasn't built its export table yet (the injection path defers and retries). O(1). */
219
348
  override resolveImportedInstance(alias: string, name: string): ResourceInstance | undefined {
220
349
  const scope = this.importedScopes.get(alias);
221
350
  if (!scope || !scope.names.has(name)) return undefined;
222
- return scope.get(name)?.instance;
351
+ return scope.terminal(name)?.()?.instance;
223
352
  }
224
353
 
225
354
  /** Like `resolveImportedInstance`, but returns the `{kind, name}` ref (canonical kind)
@@ -228,7 +357,7 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
228
357
  resolveImportedRef(alias: string, name: string): { kind: string; name: string } | undefined {
229
358
  const scope = this.importedScopes.get(alias);
230
359
  if (!scope || !scope.names.has(name)) return undefined;
231
- const entry = scope.get(name);
360
+ const entry = scope.terminal(name)?.();
232
361
  return entry ? { kind: entry.kind, name } : undefined;
233
362
  }
234
363
 
@@ -292,6 +421,14 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
292
421
  `Exported kinds: ${[...allowed].join(", ")}`,
293
422
  );
294
423
  }
424
+ // Re-export override: if this import's exported-kind table maps the suffix to a DIFFERENT
425
+ // owning module, the kind is transitively re-exported (`exports.kinds: [Alias.Kind]`) —
426
+ // resolve to its true owner. A local kind maps to `${realModule}.${suffix}` (no override),
427
+ // and a module without `exports.kinds` has an empty table (unrestricted, unchanged). Built
428
+ // deferred, so before the import inits this returns the un-overridden kind, whose controller
429
+ // miss makes the init loop retry until the table is ready.
430
+ const reExported = this.importedKindResolvers.get(prefix)?.(suffix);
431
+ if (reExported && reExported !== `${realModule}.${suffix}`) return reExported;
295
432
  return `${realModule}.${suffix}`;
296
433
  }
297
434
 
@@ -363,7 +500,12 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
363
500
  await this.run(target, ctx);
364
501
  continue;
365
502
  }
366
- if ("invoke" in target && target.invoke !== undefined) {
503
+ // A bare `!ref Alias.name` boot target is Phase-5-injected into the live
504
+ // instance, which for a Run.Sequence exposes both `run()` and `invoke()`.
505
+ // Guard against treating such an instance as an authored inline-invoke
506
+ // step — only a structural `{ invoke: <ref>, inputs }` spec (no `run`)
507
+ // belongs here; the live instance falls through to the runnable branch.
508
+ if (!isRunnableInstance(target) && "invoke" in target && target.invoke !== undefined) {
367
509
  const step: InvokeStep = {
368
510
  name: target.name ?? `Target${i}`,
369
511
  when: target.when,
@@ -1,5 +1,5 @@
1
1
  import { evaluate } from "@marcbachmann/cel-js";
2
- import { DataValidator, RuntimeError, TypeRule } from "@telorun/sdk";
2
+ import { DataValidator, isCompiledValue, RuntimeError, TypeRule } from "@telorun/sdk";
3
3
  import AjvModule, { type ValidateFunction } from "ajv";
4
4
  import standaloneCodeMod from "ajv/dist/standalone/index.js";
5
5
  import addFormats from "ajv-formats";
@@ -8,7 +8,7 @@ import * as fs from "node:fs";
8
8
  import { createRequire } from "node:module";
9
9
  import * as path from "node:path";
10
10
  import { formatAjvErrors } from "./manifest-schemas.js";
11
- import { ManifestRootSchema, normalizeRefSlots } from "@telorun/templating";
11
+ import { isTaggedSentinel, ManifestRootSchema, normalizeRefSlots } from "@telorun/templating";
12
12
 
13
13
  const Ajv = AjvModule.default ?? AjvModule;
14
14
  // AJV's standalone subpath is CJS — the default export shows up as either
@@ -78,6 +78,39 @@ function verifyAndExtractBody(text: string): string | null {
78
78
  return actual === match[1] ? body : null;
79
79
  }
80
80
 
81
+ /** Deep-clone `value` collapsing every CEL/template sentinel to its original
82
+ * `source` string, for use as the validator cache *key* only.
83
+ *
84
+ * The same `Telo.Definition` schema reaches `compile()` in two forms: the
85
+ * build-time validator warm feeds the raw analysis graph (parse-time
86
+ * `{__tagged, engine, source}` sentinels, or plain `${{ }}` strings for inline
87
+ * templates), while the runtime feeds the compile-loader output (`{__compiled,
88
+ * source, parts}`). A `!cel` / `!sql` tag embedded anywhere in a schema — most
89
+ * commonly inside `examples` / `description`, but the loader rewrites them at
90
+ * any position — therefore serialises differently between the two, so without
91
+ * normalisation the same kind hashes differently and the runtime misses the
92
+ * warmed cache (and fails to persist it read-only). Both sentinel shapes carry
93
+ * the same original `source`, so collapsing each to that string converges them.
94
+ *
95
+ * This rewrites only the hash key — AJV still compiles the full `injected`
96
+ * schema. Unlike dropping keys by name, it never removes a structural node, so
97
+ * a property literally named `description` / `examples` keeps its schema in the
98
+ * key and two genuinely different shapes never collide. */
99
+ function normalizeSentinelsForHash(value: unknown): unknown {
100
+ if (isCompiledValue(value) || isTaggedSentinel(value)) {
101
+ return typeof value.source === "string" ? value.source : { __teloSentinel: true };
102
+ }
103
+ if (Array.isArray(value)) return value.map(normalizeSentinelsForHash);
104
+ if (value && typeof value === "object") {
105
+ const out: Record<string, unknown> = {};
106
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
107
+ out[k] = normalizeSentinelsForHash(v);
108
+ }
109
+ return out;
110
+ }
111
+ return value;
112
+ }
113
+
81
114
  export class SchemaValidator {
82
115
  private ajv: InstanceType<typeof Ajv>;
83
116
  private typeRules = new Map<string, TypeRule[]>();
@@ -194,7 +227,12 @@ export class SchemaValidator {
194
227
  const injected = normalizeRefSlots(withImplicit) as typeof withImplicit;
195
228
 
196
229
  const hash = createHash("sha256")
197
- .update(JSON.stringify({ runtime: VALIDATOR_RUNTIME_TAG, schema: injected }))
230
+ .update(
231
+ JSON.stringify({
232
+ runtime: VALIDATOR_RUNTIME_TAG,
233
+ schema: normalizeSentinelsForHash(injected),
234
+ }),
235
+ )
198
236
  .digest("hex")
199
237
  .slice(0, 32);
200
238
  const cachedByHash = this.hashCache.get(hash);