@telorun/kernel 0.24.2 → 0.26.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 (68) hide show
  1. package/dist/application-env.d.ts +18 -0
  2. package/dist/application-env.d.ts.map +1 -1
  3. package/dist/application-env.js +36 -0
  4. package/dist/application-env.js.map +1 -1
  5. package/dist/controller-loader.d.ts +4 -0
  6. package/dist/controller-loader.d.ts.map +1 -1
  7. package/dist/controller-loader.js +4 -1
  8. package/dist/controller-loader.js.map +1 -1
  9. package/dist/controller-loaders/npm-loader.d.ts +51 -12
  10. package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
  11. package/dist/controller-loaders/npm-loader.js +119 -52
  12. package/dist/controller-loaders/npm-loader.js.map +1 -1
  13. package/dist/controller-registry.d.ts +9 -0
  14. package/dist/controller-registry.d.ts.map +1 -1
  15. package/dist/controller-registry.js +18 -0
  16. package/dist/controller-registry.js.map +1 -1
  17. package/dist/controllers/module/import-controller.d.ts.map +1 -1
  18. package/dist/controllers/module/import-controller.js +50 -28
  19. package/dist/controllers/module/import-controller.js.map +1 -1
  20. package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
  21. package/dist/controllers/resource-definition/resource-definition-controller.js +1 -0
  22. package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
  23. package/dist/evaluation-context.d.ts.map +1 -1
  24. package/dist/evaluation-context.js +13 -5
  25. package/dist/evaluation-context.js.map +1 -1
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/kernel.d.ts +8 -0
  31. package/dist/kernel.d.ts.map +1 -1
  32. package/dist/kernel.js +46 -15
  33. package/dist/kernel.js.map +1 -1
  34. package/dist/manifest-sources/analysis-stamp.d.ts +2 -2
  35. package/dist/manifest-sources/analysis-stamp.d.ts.map +1 -1
  36. package/dist/manifest-sources/analysis-stamp.js +9 -4
  37. package/dist/manifest-sources/analysis-stamp.js.map +1 -1
  38. package/dist/manifest-sources/local-manifest-cache-source.d.ts +13 -3
  39. package/dist/manifest-sources/local-manifest-cache-source.d.ts.map +1 -1
  40. package/dist/manifest-sources/local-manifest-cache-source.js +25 -6
  41. package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -1
  42. package/dist/module-context.d.ts +56 -7
  43. package/dist/module-context.d.ts.map +1 -1
  44. package/dist/module-context.js +121 -10
  45. package/dist/module-context.js.map +1 -1
  46. package/dist/resource-context.d.ts +1 -0
  47. package/dist/resource-context.d.ts.map +1 -1
  48. package/dist/resource-context.js +3 -0
  49. package/dist/resource-context.js.map +1 -1
  50. package/dist/schema-validator.d.ts +8 -1
  51. package/dist/schema-validator.d.ts.map +1 -1
  52. package/dist/schema-validator.js +47 -5
  53. package/dist/schema-validator.js.map +1 -1
  54. package/package.json +3 -3
  55. package/src/application-env.ts +37 -0
  56. package/src/controller-loader.ts +8 -1
  57. package/src/controller-loaders/npm-loader.ts +136 -59
  58. package/src/controller-registry.ts +18 -0
  59. package/src/controllers/module/import-controller.ts +58 -28
  60. package/src/controllers/resource-definition/resource-definition-controller.ts +1 -0
  61. package/src/evaluation-context.ts +15 -5
  62. package/src/index.ts +1 -0
  63. package/src/kernel.ts +65 -16
  64. package/src/manifest-sources/analysis-stamp.ts +9 -2
  65. package/src/manifest-sources/local-manifest-cache-source.ts +29 -4
  66. package/src/module-context.ts +153 -11
  67. package/src/resource-context.ts +4 -0
  68. package/src/schema-validator.ts +49 -5
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ export { LocalFileSource } from "./manifest-sources/local-file-source.js";
5
5
  export {
6
6
  LocalManifestCacheSource,
7
7
  cachePathForCanonical,
8
+ resolveCacheRoot,
8
9
  resolveEntryDir,
9
10
  writeManifestCache,
10
11
  } from "./manifest-sources/local-manifest-cache-source.js";
package/src/kernel.ts CHANGED
@@ -40,8 +40,14 @@ import {
40
40
  readAnalysisStamp,
41
41
  writeAnalysisStamp,
42
42
  } from "./manifest-sources/analysis-stamp.js";
43
- import { resolveEntryDir } from "./manifest-sources/local-manifest-cache-source.js";
44
- import { precompileApplicationEnvSchemas, resolveApplicationEnv } from "./application-env.js";
43
+ import {
44
+ resolveCacheRoot,
45
+ } from "./manifest-sources/local-manifest-cache-source.js";
46
+ import {
47
+ precompileApplicationEnvSchemas,
48
+ precompileDefinitionSchemas,
49
+ resolveApplicationEnv,
50
+ } from "./application-env.js";
45
51
  import { policyFingerprint } from "./runtime-registry.js";
46
52
  import { SchemaValidator } from "./schema-validator.js";
47
53
 
@@ -165,6 +171,9 @@ export class Kernel implements IKernel {
165
171
  private rootContext!: ModuleContext;
166
172
  private staticManifests: ResourceManifest[] = [];
167
173
  private _entryUrl?: string;
174
+ /** The `.telo` cache root for this load, resolved once in `load()` and
175
+ * threaded to the validator, analysis stamp, and npm install root. */
176
+ private _cacheRoot?: string | null;
168
177
  private _loadedGraph?: LoadedGraph;
169
178
  // Lifecycle state — guards boot/runTargets/teardown/invoke transitions.
170
179
  // teardown() is the only idempotent method; everything else throws on misuse.
@@ -315,18 +324,32 @@ export class Kernel implements IKernel {
315
324
  * session rootfs — hits the stamp and skips the validation walk entirely
316
325
  * instead of failing to write the caches on every boot.
317
326
  */
318
- async load(url: string, options?: { analyzeOnly?: boolean }): Promise<void> {
327
+ async load(
328
+ url: string,
329
+ options?: { analyzeOnly?: boolean; cacheDir?: string | null; writeCache?: boolean },
330
+ ): Promise<void> {
319
331
  const sourceUrl = await this.loader.resolveEntryPoint(url);
320
332
  this._entryUrl = sourceUrl;
321
- // Point the shared schema validator at the entry-anchored cache so
322
- // compiled AJV validators are persisted (standalone CJS) under
323
- // `<entry-dir>/.telo/manifests/__validators/`. Memory- or HTTP-rooted
324
- // entries skip the cache; their schema compiles stay in-process only.
325
- const validatorCacheDir = resolveEntryDir(sourceUrl);
333
+ // Resolve the `.telo` cache root ONCE and thread it to every consumer
334
+ // (validators, analysis stamp, npm install root) — no consumer re-derives
335
+ // it or reads the env independently. A caller (the CLI) may pass `cacheDir`
336
+ // so the env is read exactly once per invocation; otherwise resolve here.
337
+ const cacheRoot =
338
+ options?.cacheDir !== undefined ? options.cacheDir : resolveCacheRoot(sourceUrl);
339
+ this._cacheRoot = cacheRoot;
340
+ const manifestsDir = cacheRoot ? `${cacheRoot}/manifests` : undefined;
341
+ // `writeCache: false` (`telo run --no-cache-write`) keeps the cache
342
+ // READ-only: compiled validators and the analysis stamp are still loaded
343
+ // from disk, but never written back — so an ephemeral, read-only session
344
+ // rootfs validates in-memory without touching the baked cache.
345
+ const writeCache = options?.writeCache !== false;
346
+ // Point the shared schema validator at the cache so compiled AJV validators
347
+ // are loaded (and, when writable, persisted) under
348
+ // `<cache-root>/manifests/__validators/`. Memory-/HTTP-rooted entries skip
349
+ // the cache; their schema compiles stay in-process only.
326
350
  this.sharedSchemaValidator.setCacheDir(
327
- validatorCacheDir
328
- ? `${validatorCacheDir}/.telo/manifests/__validators`
329
- : undefined,
351
+ manifestsDir ? `${manifestsDir}/__validators` : undefined,
352
+ { write: writeCache },
330
353
  );
331
354
  this.rootContext = new ModuleContext(
332
355
  sourceUrl,
@@ -389,9 +412,10 @@ export class Kernel implements IKernel {
389
412
  // and inline-resource normalisation still runs — only the diagnostic
390
413
  // passes are elided. Memory- / HTTP-rooted entries have no
391
414
  // local stamp store and always re-validate.
392
- const entryDir = resolveEntryDir(sourceUrl);
393
415
  const analysisSignature = computeAnalysisSignature(analysisGraph);
394
- const stamp = entryDir ? await readAnalysisStamp(entryDir) : undefined;
416
+ const stamp = manifestsDir
417
+ ? await readAnalysisStamp("", manifestsDir)
418
+ : undefined;
395
419
  const skipValidation = stamp?.signature === analysisSignature;
396
420
  const errors = this.analyzer.analyzeErrors(
397
421
  staticManifests,
@@ -412,13 +436,13 @@ export class Kernel implements IKernel {
412
436
  })),
413
437
  );
414
438
  }
415
- if (entryDir && !skipValidation) {
439
+ if (manifestsDir && writeCache && !skipValidation) {
416
440
  // Best-effort: stamp the verdict so subsequent loads hit the fast
417
441
  // path. A read-only filesystem (baked Docker image) reports the
418
442
  // failure on stderr and keeps running — the lookup above will
419
- // simply miss next time.
443
+ // simply miss next time. Skipped under `--no-cache-write`.
420
444
  try {
421
- await writeAnalysisStamp(entryDir, analysisSignature);
445
+ await writeAnalysisStamp("", analysisSignature, manifestsDir);
422
446
  } catch (err) {
423
447
  this.stderr.write(
424
448
  `[telo:kernel] analysis stamp write failed: ${err instanceof Error ? err.message : String(err)}\n`,
@@ -441,6 +465,25 @@ export class Kernel implements IKernel {
441
465
  this.sharedSchemaValidator,
442
466
  );
443
467
  }
468
+ // Bake the per-kind resource-config validators too. `_createInstance`
469
+ // compiles `controller.schema` (which falls back to the definition's own
470
+ // `schema`) for every resource at runtime — work this analyze-only pass
471
+ // otherwise skips because it stops before instantiation, leaving the
472
+ // runtime to recompile and fail to persist them on a read-only image.
473
+ // Pre-compiling every `Telo.Definition` schema here writes them into the
474
+ // same content-addressed `__validators/` cache the runtime reads.
475
+ precompileDefinitionSchemas(staticManifests, this.sharedSchemaValidator);
476
+ // Framework/builtin controller schemas (`Telo.Import`, `Telo.Definition`,
477
+ // the module controller, …) aren't in the static manifests but are
478
+ // validated per-resource at runtime just the same. They're registered by
479
+ // `loadBuiltinDefinitions` above, so bake them from the registry.
480
+ precompileDefinitionSchemas(
481
+ this.controllers.getControllerSchemas().map((schema) => ({
482
+ kind: "Telo.Definition",
483
+ schema,
484
+ })),
485
+ this.sharedSchemaValidator,
486
+ );
444
487
  return;
445
488
  }
446
489
 
@@ -721,6 +764,12 @@ export class Kernel implements IKernel {
721
764
  return this._entryUrl;
722
765
  }
723
766
 
767
+ /** The npm install root for this load (`<cache-root>/npm`), threaded to the
768
+ * controller loader so it doesn't re-derive it from the entry URL. */
769
+ getInstallRoot(): string | undefined {
770
+ return this._cacheRoot ? `${this._cacheRoot}/npm` : undefined;
771
+ }
772
+
724
773
  /** Authored `kind` of a declared resource by name, from the static manifest
725
774
  * set. Init-order-independent (unlike `resourceInstances`), so a controller
726
775
  * resolving a `!ref <name>` sentinel before the target initializes can still
@@ -136,9 +136,13 @@ export function computeAnalysisSignature(graph: LoadedGraph): string {
136
136
  * reading a newer stamp (or vice versa) discard rather than misparse. */
137
137
  export async function readAnalysisStamp(
138
138
  entryDir: string,
139
+ manifestsDir?: string,
139
140
  ): Promise<AnalysisStamp | undefined> {
141
+ const stampPath = manifestsDir
142
+ ? path.join(manifestsDir, ".validated.json")
143
+ : path.join(entryDir, ANALYSIS_STAMP_FILE);
140
144
  try {
141
- const text = await fs.readFile(path.join(entryDir, ANALYSIS_STAMP_FILE), "utf-8");
145
+ const text = await fs.readFile(stampPath, "utf-8");
142
146
  const parsed = JSON.parse(text) as Partial<AnalysisStamp>;
143
147
  if (
144
148
  parsed?.version === ANALYSIS_STAMP_FORMAT_VERSION &&
@@ -158,12 +162,15 @@ export async function readAnalysisStamp(
158
162
  export async function writeAnalysisStamp(
159
163
  entryDir: string,
160
164
  signature: string,
165
+ manifestsDir?: string,
161
166
  ): Promise<void> {
162
167
  const stamp: AnalysisStamp = {
163
168
  version: ANALYSIS_STAMP_FORMAT_VERSION,
164
169
  signature,
165
170
  };
166
- const target = path.join(entryDir, ANALYSIS_STAMP_FILE);
171
+ const target = manifestsDir
172
+ ? path.join(manifestsDir, ".validated.json")
173
+ : path.join(entryDir, ANALYSIS_STAMP_FILE);
167
174
  await fs.mkdir(path.dirname(target), { recursive: true });
168
175
  await fs.writeFile(target, JSON.stringify(stamp), "utf-8");
169
176
  }
@@ -133,8 +133,15 @@ export class LocalManifestCacheSource implements ManifestSource {
133
133
  private readonly cacheRoot: string;
134
134
  private readonly registryUrl: string;
135
135
 
136
- constructor(entryDir: string, registryUrl: string = DEFAULT_REGISTRY_URL) {
137
- this.cacheRoot = path.join(entryDir, CACHE_SUBDIR);
136
+ constructor(
137
+ entryDir: string,
138
+ registryUrl: string = DEFAULT_REGISTRY_URL,
139
+ manifestsDir?: string,
140
+ ) {
141
+ // `manifestsDir` is the resolved manifest-cache directory threaded from a
142
+ // single `resolveCacheRoot` (honours `TELO_CACHE_DIR`); when absent we fall
143
+ // back to the entry-anchored default so library/test callers are unchanged.
144
+ this.cacheRoot = manifestsDir ?? path.join(entryDir, CACHE_SUBDIR);
138
145
  this.registryUrl = registryUrl;
139
146
  }
140
147
 
@@ -189,8 +196,9 @@ export function cachePathForCanonical(
189
196
  canonicalSource: string,
190
197
  entryDir: string,
191
198
  registryUrl: string,
199
+ manifestsDir?: string,
192
200
  ): string | null {
193
- const cacheRoot = path.join(entryDir, CACHE_SUBDIR);
201
+ const cacheRoot = manifestsDir ?? path.join(entryDir, CACHE_SUBDIR);
194
202
  return cachePathForUrl(canonicalSource, cacheRoot, registryUrl);
195
203
  }
196
204
 
@@ -210,6 +218,7 @@ export async function writeManifestCache(
210
218
  graph: LoadedGraph,
211
219
  entryDir: string,
212
220
  registryUrl: string = DEFAULT_REGISTRY_URL,
221
+ manifestsDir?: string,
213
222
  ): Promise<string[]> {
214
223
  const written: string[] = [];
215
224
  const seen = new Set<string>();
@@ -220,7 +229,7 @@ export async function writeManifestCache(
220
229
  if (seen.has(file.source)) continue;
221
230
  seen.add(file.source);
222
231
 
223
- const target = cachePathForCanonical(file.source, entryDir, registryUrl);
232
+ const target = cachePathForCanonical(file.source, entryDir, registryUrl, manifestsDir);
224
233
  if (!target) continue;
225
234
 
226
235
  await fs.mkdir(path.dirname(target), { recursive: true });
@@ -254,3 +263,19 @@ export function resolveEntryDir(entryPath: string): string | null {
254
263
  return path.dirname(absolute);
255
264
  }
256
265
  }
266
+
267
+ /** The single `.telo` cache root for an entry, resolved once and threaded to
268
+ * every consumer (manifest cache, compiled validators, analysis stamp, npm
269
+ * install root) so none of them re-derive it or read the env independently.
270
+ *
271
+ * `TELO_CACHE_DIR` (the relocated root a prebuilt image bakes its deps into)
272
+ * wins; otherwise the root sits beside the entry at `<entry-dir>/.telo`.
273
+ * Returns `null` for http(s) entries with no local anchor (disk cache skipped).
274
+ * Consumers append the conventional subdirs: `manifests/`, `manifests/__validators/`,
275
+ * `npm/`. */
276
+ export function resolveCacheRoot(entryPath: string): string | null {
277
+ const override = process.env.TELO_CACHE_DIR;
278
+ if (override && override.trim()) return path.resolve(override.trim());
279
+ const entryDir = resolveEntryDir(entryPath);
280
+ return entryDir ? path.join(entryDir, ".telo") : null;
281
+ }
@@ -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,
@@ -344,6 +344,10 @@ export class ResourceContextImpl implements ResourceContext {
344
344
  return this.kernel.getEntryUrl();
345
345
  }
346
346
 
347
+ getInstallRoot(): string | undefined {
348
+ return this.kernel.getInstallRoot();
349
+ }
350
+
347
351
  on(event: string, handler: (payload?: any) => void | Promise<void>): void {
348
352
  this.kernel.on(event, handler);
349
353
  }
@@ -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,12 +78,50 @@ 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[]>();
84
117
  private rawSchemas = new Map<string, object>();
85
118
  private compiledValidators = new WeakMap<object, DataValidator>();
86
119
  private cacheDir: string | undefined;
120
+ /** When false, the disk cache is read-only: compiled validators are still
121
+ * loaded from `cacheDir` but never written back. `telo run --no-cache-write`
122
+ * sets this so an ephemeral, read-only session rootfs validates in-memory
123
+ * without touching (or failing to write) the baked cache. */
124
+ private cacheWritable = true;
87
125
  /** Tracks (schema-hash → in-memory compiled validator) so two distinct
88
126
  * but content-equal schema objects share one compile across the kernel
89
127
  * process — `compiledValidators` is keyed by object identity and would
@@ -150,8 +188,9 @@ export class SchemaValidator {
150
188
  * kernel anchors this under `<entry-dir>/.telo/manifests/__validators/`
151
189
  * so it lives next to the manifest cache and rides along in
152
190
  * `COPY --from=build /srv /srv` Docker images. */
153
- setCacheDir(dir: string | undefined): void {
191
+ setCacheDir(dir: string | undefined, opts?: { write?: boolean }): void {
154
192
  this.cacheDir = dir;
193
+ this.cacheWritable = opts?.write ?? true;
155
194
  }
156
195
 
157
196
  compile(schema: any): DataValidator {
@@ -194,7 +233,12 @@ export class SchemaValidator {
194
233
  const injected = normalizeRefSlots(withImplicit) as typeof withImplicit;
195
234
 
196
235
  const hash = createHash("sha256")
197
- .update(JSON.stringify({ runtime: VALIDATOR_RUNTIME_TAG, schema: injected }))
236
+ .update(
237
+ JSON.stringify({
238
+ runtime: VALIDATOR_RUNTIME_TAG,
239
+ schema: normalizeSentinelsForHash(injected),
240
+ }),
241
+ )
198
242
  .digest("hex")
199
243
  .slice(0, 32);
200
244
  const cachedByHash = this.hashCache.get(hash);
@@ -277,7 +321,7 @@ export class SchemaValidator {
277
321
  }
278
322
 
279
323
  const validate = this.ajv.compile(schema) as ValidateFunction;
280
- if (cacheDir) {
324
+ if (cacheDir && this.cacheWritable) {
281
325
  try {
282
326
  const body = standaloneCode(this.ajv, validate);
283
327
  const integrity = createHash("sha256").update(body).digest("hex");