@telorun/kernel 0.82.1 → 0.84.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 (43) hide show
  1. package/dist/controllers/module/import-controller.d.ts.map +1 -1
  2. package/dist/controllers/module/import-controller.js +237 -12
  3. package/dist/controllers/module/import-controller.js.map +1 -1
  4. package/dist/controllers/module/shared-libraries.d.ts +83 -0
  5. package/dist/controllers/module/shared-libraries.d.ts.map +1 -0
  6. package/dist/controllers/module/shared-libraries.js +110 -0
  7. package/dist/controllers/module/shared-libraries.js.map +1 -0
  8. package/dist/controllers/resource-definition/resource-definition-controller.d.ts +3 -0
  9. package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
  10. package/dist/controllers/resource-definition/resource-definition-controller.js +8 -1
  11. package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
  12. package/dist/controllers/resource-definition/resource-template-controller.d.ts.map +1 -1
  13. package/dist/controllers/resource-definition/resource-template-controller.js +104 -30
  14. package/dist/controllers/resource-definition/resource-template-controller.js.map +1 -1
  15. package/dist/evaluation-context.d.ts +82 -0
  16. package/dist/evaluation-context.d.ts.map +1 -1
  17. package/dist/evaluation-context.js +200 -5
  18. package/dist/evaluation-context.js.map +1 -1
  19. package/dist/instance-sensitive-paths.d.ts +61 -0
  20. package/dist/instance-sensitive-paths.d.ts.map +1 -0
  21. package/dist/instance-sensitive-paths.js +116 -0
  22. package/dist/instance-sensitive-paths.js.map +1 -0
  23. package/dist/invocation-contract-binding.d.ts +3 -0
  24. package/dist/invocation-contract-binding.d.ts.map +1 -1
  25. package/dist/invocation-contract-binding.js +9 -1
  26. package/dist/invocation-contract-binding.js.map +1 -1
  27. package/dist/kernel.d.ts.map +1 -1
  28. package/dist/kernel.js +25 -0
  29. package/dist/kernel.js.map +1 -1
  30. package/dist/module-context.d.ts +5 -0
  31. package/dist/module-context.d.ts.map +1 -1
  32. package/dist/module-context.js +5 -0
  33. package/dist/module-context.js.map +1 -1
  34. package/package.json +4 -4
  35. package/src/controllers/module/import-controller.ts +324 -13
  36. package/src/controllers/module/shared-libraries.ts +180 -0
  37. package/src/controllers/resource-definition/resource-definition-controller.ts +14 -0
  38. package/src/controllers/resource-definition/resource-template-controller.ts +129 -29
  39. package/src/evaluation-context.ts +216 -7
  40. package/src/instance-sensitive-paths.ts +123 -0
  41. package/src/invocation-contract-binding.ts +12 -0
  42. package/src/kernel.ts +29 -1
  43. package/src/module-context.ts +6 -0
@@ -0,0 +1,180 @@
1
+ import type { EvaluationContext as IEvaluationContext, ResourceInstance } from "@telorun/sdk";
2
+ import { RuntimeError } from "@telorun/sdk";
3
+ import type { ModuleContext } from "../../module-context.js";
4
+ import type { ParsedExportEntry } from "@telorun/analyzer";
5
+
6
+ /**
7
+ * LIBRARY SINGLETONS — one instantiation of a `lifecycle: shared` library per
8
+ * application, borrowed by every import that names it.
9
+ *
10
+ * An import declaration otherwise builds its own child scope with its own
11
+ * instances, so two libraries importing a third get two of everything in it.
12
+ * That is right for a library whose instances are the importer's — a client
13
+ * configured per consumer — and wrong for one that owns a resource the
14
+ * application has exactly one of. `lifecycle: shared` names the second case, and
15
+ * it is what lets a set of libraries share a dependency without linearizing them
16
+ * into a chain that re-exports the union of everything beneath it.
17
+ *
18
+ * **The root owns it; every import borrows it** — the same rule an injected
19
+ * resource follows. The child context is spawned under the ROOT rather than
20
+ * under whichever import happened to reach it first, because otherwise tearing
21
+ * that importer down would close a library two others still hold, and which
22
+ * importer that is depends on init order. It is torn down after every other root
23
+ * child (`TEARDOWN_LAST` on the context), so a borrower's own inverses still
24
+ * find it alive.
25
+ *
26
+ * **Registered only when shared**, which is what makes a registry HIT the answer
27
+ * to "is this library shared" — a second import of one costs no fetch, no parse
28
+ * and no analysis pass at all.
29
+ */
30
+ export interface SharedLibrary {
31
+ /** The resolved module URL — the identity two imports must agree on. Carries
32
+ * any `#sha256-` pin, so two imports of the same source at different
33
+ * integrity are different libraries, which is the truth. */
34
+ readonly url: string;
35
+ readonly module: string;
36
+ /** The alias of the import that instantiated it, named in a conflict. */
37
+ readonly owner: string;
38
+ readonly context: ModuleContext;
39
+ readonly child: IEvaluationContext;
40
+ readonly variables: Record<string, unknown>;
41
+ readonly secrets: Record<string, unknown>;
42
+ /** The instances supplied for the library's declared `resources:` inputs,
43
+ * compared by IDENTITY: two imports handing down different instances of the
44
+ * same kind is exactly the split a singleton exists to prevent. */
45
+ readonly resources: ReadonlyMap<string, ResourceInstance>;
46
+ readonly declaredVariables: Record<string, any>;
47
+ readonly declaredSecrets: Record<string, any>;
48
+ readonly exportEntries: readonly ParsedExportEntry[];
49
+ readonly kindEntries: readonly ParsedExportEntry[];
50
+ readonly exportedResourceNames: readonly string[];
51
+ readonly exportedKindSuffixes: readonly string[] | undefined;
52
+ /** Build the library's resources and export tables. Carried on the ENTRY
53
+ * rather than in one import's closure because the import that REGISTERS a
54
+ * singleton is not necessarily the one whose `init()` runs first. */
55
+ readonly build: () => Promise<void>;
56
+ /** Memoized initialization. Whichever import's `init()` runs first starts it
57
+ * and every other awaits the same promise, so a borrower can never proceed
58
+ * against a library whose resources have not been built — an ordering the
59
+ * multi-pass loop does not otherwise guarantee. */
60
+ initialized?: Promise<void>;
61
+ }
62
+
63
+ /** Per-kernel, hung off the ROOT context rather than held in module scope, so
64
+ * two in-process kernels never share a library instance. */
65
+ const registries = new WeakMap<object, Map<string, SharedLibrary>>();
66
+
67
+ /** The root of a context's lifecycle tree — the kernel's own root context. */
68
+ export function rootContextOf(ctx: IEvaluationContext): IEvaluationContext {
69
+ let node: IEvaluationContext = ctx;
70
+ while (node.parent) node = node.parent;
71
+ return node;
72
+ }
73
+
74
+ /** The shared-library registry for the kernel `ctx` belongs to. */
75
+ export function sharedLibraries(ctx: IEvaluationContext): Map<string, SharedLibrary> {
76
+ const root = rootContextOf(ctx) as unknown as object;
77
+ let registry = registries.get(root);
78
+ if (!registry) registries.set(root, (registry = new Map()));
79
+ return registry;
80
+ }
81
+
82
+ /**
83
+ * Refuse a second import that would instantiate the library differently.
84
+ *
85
+ * A singleton has one configuration, so the only sound reading of two imports
86
+ * supplying different values is that one of them is wrong — and which one cannot
87
+ * be decided here. Resolved by init order it would be whichever import was
88
+ * created first, silently, which is the failure mode `lifecycle: shared` is
89
+ * supposed to remove rather than relocate.
90
+ *
91
+ * A secret's VALUE is never printed: the key is what the author has to look at.
92
+ */
93
+ export function assertSharedInputsAgree(
94
+ entry: SharedLibrary,
95
+ alias: string,
96
+ variables: Record<string, unknown>,
97
+ secrets: Record<string, unknown>,
98
+ resources: ReadonlyMap<string, { instance: ResourceInstance }>,
99
+ ): void {
100
+ const conflict = (block: string, key: string, detail?: string): never => {
101
+ throw new RuntimeError(
102
+ "ERR_SHARED_LIBRARY_CONFLICT",
103
+ `Import '${alias}' and import '${entry.owner}' both reach module '${entry.module}', which ` +
104
+ `declares 'lifecycle: shared' — one instantiation for the whole application — but they ` +
105
+ `supply different values for ${block}.${key}${detail ? ` (${detail})` : ""}. Make the two ` +
106
+ `imports agree, or make the library 'lifecycle: isolated'.`,
107
+ );
108
+ };
109
+
110
+ for (const key of union(Object.keys(entry.variables), Object.keys(variables))) {
111
+ if (!sameValue(entry.variables[key], variables[key])) {
112
+ conflict("variables", key, `'${render(entry.variables[key])}' vs '${render(variables[key])}'`);
113
+ }
114
+ }
115
+ // Values withheld deliberately — a diagnostic must not become a way to read a
116
+ // secret out of a running process.
117
+ for (const key of union(Object.keys(entry.secrets), Object.keys(secrets))) {
118
+ if (!sameValue(entry.secrets[key], secrets[key])) conflict("secrets", key);
119
+ }
120
+ for (const key of union([...entry.resources.keys()], [...resources.keys()])) {
121
+ if (entry.resources.get(key) !== resources.get(key)?.instance) {
122
+ conflict("resources", key, "different instances");
123
+ }
124
+ }
125
+ }
126
+
127
+ /** Reject a per-import override a singleton has no room for. The analyzer
128
+ * reports the same thing as `SHARED_LIBRARY_OVERRIDE`; this is the runtime
129
+ * half, for a library reached through a programmatic load that never passed
130
+ * `telo check`. */
131
+ export function assertNoSharedOverride(resource: any, alias: string, module: string): void {
132
+ for (const field of ["logging", "runtime"] as const) {
133
+ if (resource[field] === undefined) continue;
134
+ throw new RuntimeError(
135
+ "ERR_SHARED_LIBRARY_OVERRIDE",
136
+ `Import '${alias}' declares '${field}:', but module '${module}' is 'lifecycle: shared' — ` +
137
+ `one instantiation for the whole application, so a per-import override cannot apply to ` +
138
+ `it. Remove it, or make the library 'lifecycle: isolated'.`,
139
+ );
140
+ }
141
+ }
142
+
143
+ function union(a: readonly string[], b: readonly string[]): string[] {
144
+ return [...new Set([...a, ...b])];
145
+ }
146
+
147
+ /**
148
+ * Structural equality over the JSON-shaped values a config input carries.
149
+ * `undefined` and an absent key are the same absence.
150
+ *
151
+ * Key ORDER is not part of a value: two imports writing the same object variable
152
+ * with its keys in a different YAML order are supplying the same thing, and a
153
+ * conflict here is a hard boot failure telling the author the two imports
154
+ * disagree — a false positive is both expensive and unexplainable.
155
+ */
156
+ function sameValue(a: unknown, b: unknown): boolean {
157
+ if (a === b) return true;
158
+ if (a === undefined || b === undefined) return false;
159
+ if (a === null || b === null) return false;
160
+ if (Array.isArray(a) || Array.isArray(b)) {
161
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
162
+ return a.every((item, i) => sameValue(item, b[i]));
163
+ }
164
+ if (typeof a === "object" && typeof b === "object") {
165
+ const left = a as Record<string, unknown>;
166
+ const right = b as Record<string, unknown>;
167
+ const keys = union(Object.keys(left), Object.keys(right));
168
+ return keys.every((key) => sameValue(left[key], right[key]));
169
+ }
170
+ return false;
171
+ }
172
+
173
+ function render(value: unknown): string {
174
+ if (typeof value === "string") return value;
175
+ try {
176
+ return JSON.stringify(value) ?? String(value);
177
+ } catch {
178
+ return String(value);
179
+ }
180
+ }
@@ -9,6 +9,7 @@ import { RuntimeError } from "@telorun/sdk";
9
9
  import {
10
10
  controllerBearingAncestor,
11
11
  effectiveAuthorSchema,
12
+ publishedOwnFields,
12
13
  effectiveStatusSchema,
13
14
  hasOwnControllerOrTemplate,
14
15
  inheritedCapability,
@@ -30,6 +31,9 @@ type ResourceDefinitionResource = RuntimeResource & {
30
31
  };
31
32
  schema: Record<string, any>;
32
33
  status?: Record<string, any>;
34
+ /** Derived stamp: the fields a merge-form inheriting child publishes over the
35
+ * parent instance's reading (see `publishedOwnFields`). */
36
+ publishedOwnFields?: string[];
33
37
  capability?: string;
34
38
  extends?: string;
35
39
  base?: Record<string, any>;
@@ -77,6 +81,16 @@ class ResourceDefinition implements ResourceInstance {
77
81
  `Telo.Definition '${this.resource.metadata.name}': 'extends' target '${this.resource.extends}' is not loaded yet.`,
78
82
  );
79
83
  }
84
+ // Which of the child's fields publish over the parent's reading. Stamped
85
+ // here for the reason `status:` is: an `extends` alias belongs to the file
86
+ // that declared it, so the set must be derived in the DEFINING scope — a
87
+ // consumer importing only the backend has no alias for the parent's
88
+ // library. Derived before the schema stamp only because it reads the same
89
+ // resolver, not because it reads the pre-stamp value.
90
+ this.resource.publishedOwnFields = publishedOwnFields(
91
+ this.resource as ResourceDefinitionManifest,
92
+ resolveDef,
93
+ );
80
94
  this.resource.schema = effectiveAuthorSchema(
81
95
  this.resource as ResourceDefinitionManifest,
82
96
  resolveDef,
@@ -5,28 +5,65 @@ import type {
5
5
  ResourceContext,
6
6
  ResourceInstance,
7
7
  } from "@telorun/sdk";
8
- import { isCompiledValue } from "@telorun/sdk";
8
+ import {
9
+ celEvalSites,
10
+ effectiveAuthorSchema,
11
+ evalPathCovers,
12
+ mergeCelEvalSites,
13
+ NO_CEL_EVAL_SITES,
14
+ pathMatchesScope,
15
+ type CelEvalSites,
16
+ } from "@telorun/analyzer";
17
+ import { isCompiledValue, type ResourceDefinition } from "@telorun/sdk";
9
18
  import { isRefSentinel } from "@telorun/templating";
10
19
  import { celSelfView } from "../../evaluation-context.js";
11
20
 
12
- /** CEL variables that are only bound at call time (request handling, step
13
- * chaining, error branches) never at a template's init(). A persistent
14
- * child's body is expanded against `self` only, so a `${{ }}` node referencing
15
- * any of these must survive untouched for the consuming controller (e.g. an
16
- * Http.Api evaluating route CEL per request) to evaluate later. A node mixing
17
- * `self` with a deferred variable in one expression is unsupported by design —
18
- * keep `self`-derived literals and request-derived values in separate fields
19
- * (e.g. a `self`-built SQL string vs. request-built `bindings`). */
20
- const DEFERRED_VARS = new Set(["request", "result", "steps", "error"]);
21
- const DEFERRED_RE = new RegExp(`(?<![.\\w])(?:${[...DEFERRED_VARS].join("|")})(?![\\w])`);
21
+ /**
22
+ * WHICH NODES OF A TEMPLATE BODY SURVIVE init() UNEXPANDED.
23
+ *
24
+ * A `resources:` entry is a DECLARATION of another kind, and that kind decides
25
+ * when each of its fields is evaluated: an `x-telo-eval: runtime` field, or any
26
+ * field under a CEL-bearing region (an `x-telo-context`, an error branch, a step
27
+ * body), is evaluated by the child's OWN controller against a scope only it can
28
+ * build. Those nodes must reach the child compiled.
29
+ *
30
+ * Read off the nested kind's schema through the containment matcher both halves
31
+ * already share (`celEvalSites` / `evalPathCovers`), never off a list of
32
+ * variable names. The name list — `request`, `result`, `steps`, `error` — was
33
+ * the reason a body could not read the call's own arguments (`inputs`) or an
34
+ * iteration's element (`item`): those two names were simply absent from it, so
35
+ * such a node was expanded at init() against a scope where they do not exist and
36
+ * failed with `Unknown variable: inputs`. A list has to be extended for every
37
+ * name any kind ever binds; the annotations already say where evaluation
38
+ * happens.
39
+ */
40
+ /** True when a node at `path` inside a body is evaluated by the child's own
41
+ * controller rather than at the template's init(). `runtime` paths are
42
+ * property-only and match by containment; a region scope is a JSONPath
43
+ * (`$.routes[*].returns`) whose wildcards a plain prefix test cannot resolve. */
44
+ function isDeferredPath(sites: CelEvalSites, path: string): boolean {
45
+ return (
46
+ sites.runtime.some((p) => evalPathCovers(p, path)) ||
47
+ sites.regions.some((scope) => pathMatchesScope(path, scope))
48
+ );
49
+ }
22
50
 
23
- /** True when an expression reads a call-time-only variable. Prefers the AST-
24
- * derived root identifiers stamped on the CompiledValue at compile time (exact
25
- * ignores string literals and substrings); falls back to a source-text scan for
26
- * values produced by engines that surface no AST. */
27
- function referencesDeferred(value: CompiledValue): boolean {
28
- if (value.refs) return value.refs.some((r) => DEFERRED_VARS.has(r));
29
- return typeof value.source === "string" && DEFERRED_RE.test(value.source);
51
+ /**
52
+ * True when an expression reads anything other than `self`. A deferred node that
53
+ * names only `self` is still resolved at `init()`: `self` is fixed for the life
54
+ * of the instance, so expanding it there costs nothing and keeps a `self`-built
55
+ * literal (a SQL string, a route path) a literal.
56
+ *
57
+ * Read off the AST-derived roots the compile step stamps, never off the source
58
+ * text. A regex over the source both over- and under-matches — it fires on a
59
+ * word inside a string literal and misses a bare `${{ item }}` with no member
60
+ * access — and there is nothing to fall back FOR: every built-in engine carries
61
+ * `refs` through. An engine that surfaces none is treated as reading nothing but
62
+ * `self`, which resolves it at `init()` and fails loudly there rather than
63
+ * silently deferring a node the child cannot evaluate.
64
+ */
65
+ function referencesBeyondSelf(value: CompiledValue): boolean {
66
+ return (value.refs ?? []).some((r) => r !== "self");
30
67
  }
31
68
 
32
69
  /** Matches a CEL source that is exactly a `self.<path>` member access (capturing
@@ -75,8 +112,18 @@ export function createTemplateController(definition: {
75
112
  // `inputs:` sibling (same factoring as Run.Sequence steps), never in the
76
113
  // target's resource body — the body is `self`-only so every child can be
77
114
  // created once at init and reused across calls.
78
- const targetName = (field: string | { kind?: string; name: string } | undefined): string | null => {
115
+ const targetName = (
116
+ field: string | { kind?: string; name: string } | undefined,
117
+ ): string | null => {
79
118
  if (field == null) return null;
119
+ // `invoke: !ref body` names a sibling `resources:` entry — the same
120
+ // spelling every other reference uses. Phase 2.5 does not descend into a
121
+ // `Telo.Definition`, so the sentinel arrives raw; `Self.` is the
122
+ // explicit self-qualifier and names the same local entry.
123
+ if (isRefSentinel(field)) {
124
+ const source = field.source;
125
+ return source.startsWith("Self.") ? source.slice("Self.".length) : source;
126
+ }
80
127
  const nameTemplate =
81
128
  typeof field === "object" && !isCompiledValue(field) ? field.name : field;
82
129
  return nameTemplate
@@ -146,16 +193,16 @@ export function createTemplateController(definition: {
146
193
  }
147
194
 
148
195
  // Expand a persistent child's body against `self`. Self-only CEL resolves
149
- // to literals now; CEL bound only at call time (DEFERRED_VARS) passes
150
- // through compiled for the child's own controller. `!ref` sentinels are
196
+ // to literals now; a node the NESTED KIND evaluates later (see
197
+ // `deferredPaths`) passes through compiled for the child's own controller. `!ref` sentinels are
151
198
  // rewritten to the `{kind, name, alias?}` injection shape here — Phase 2.5
152
199
  // (`resolveRefSentinels`) does not descend into template bodies, so the
153
200
  // child context's Phase 5 injection would otherwise see an unrecognized
154
201
  // sentinel and leave the slot unresolved. Kind is left empty: injection
155
202
  // dispatches by name and recovers the kind from the resolved instance.
156
- const expandSelf = (value: any): any => {
203
+ const expandSelf = (value: any, path: string, deferred: CelEvalSites): any => {
157
204
  if (isCompiledValue(value)) {
158
- if (referencesDeferred(value)) return value;
205
+ if (isDeferredPath(deferred, path) && referencesBeyondSelf(value)) return value;
159
206
  // A pure `self.<path>` access (e.g. a `connection: !ref` passed down) is
160
207
  // resolved by navigating the resource directly. Going through CEL would
161
208
  // re-emit the value through CEL's output type-checker, which rejects live
@@ -163,10 +210,11 @@ export function createTemplateController(definition: {
163
210
  // a consumer wired in could never reach a child's slot. Complex self
164
211
  // expressions (string building) still evaluate via CEL, where they yield
165
212
  // CEL-safe scalars.
166
- const path = typeof value.source === "string" ? value.source.trim().match(SELF_PATH) : null;
167
- if (path) {
213
+ const selfPath =
214
+ typeof value.source === "string" ? value.source.trim().match(SELF_PATH) : null;
215
+ if (selfPath) {
168
216
  let cur: any = getSelf();
169
- for (const key of path[1].split(".").slice(1)) cur = cur?.[key];
217
+ for (const key of selfPath[1]!.split(".").slice(1)) cur = cur?.[key];
170
218
  return cur;
171
219
  }
172
220
  // CEL cannot read a member off a live instance, and a ref slot holds
@@ -188,15 +236,54 @@ export function createTemplateController(definition: {
188
236
  const name = alias === "Self" ? source.slice(dot + 1) : source;
189
237
  return { kind: siblingKinds.get(name) ?? "", name };
190
238
  }
191
- if (Array.isArray(value)) return value.map(expandSelf);
239
+ if (Array.isArray(value)) {
240
+ return value.map((item, i) => expandSelf(item, `${path}[${i}]`, deferred));
241
+ }
192
242
  if (value !== null && typeof value === "object") {
193
243
  const out: Record<string, unknown> = {};
194
- for (const [k, v] of Object.entries(value)) out[k] = expandSelf(v);
244
+ for (const [k, v] of Object.entries(value)) {
245
+ out[k] = expandSelf(v, path ? `${path}.${k}` : k, deferred);
246
+ }
195
247
  return out;
196
248
  }
197
249
  return value;
198
250
  };
199
251
 
252
+ /**
253
+ * The nested kind's own CEL evaluation sites, resolved once per body. The
254
+ * kind is written in the DEFINING library's alias scope, so it is
255
+ * resolved there — the consumer has never heard of the alias.
256
+ *
257
+ * The INHERITANCE-RESOLVED schema, merged with the capability abstract's,
258
+ * exactly as the analyzer half reads it (`effectiveSchemaOf` in
259
+ * `cel-scope.ts`). A nested kind that inherits a CEL region from an
260
+ * `extends` parent — or gets one implicitly from `Telo.Provider` — would
261
+ * otherwise be covered on the static side and not here, which is the two
262
+ * halves disagreeing about when a node is evaluated.
263
+ */
264
+ const deferredPathsFor = (kind: unknown): CelEvalSites => {
265
+ if (typeof kind !== "string") return NO_CEL_EVAL_SITES;
266
+ const cached = deferredByKind.get(kind);
267
+ if (cached) return cached;
268
+ const resolveDef = (k: string): ResourceDefinition | undefined =>
269
+ definingContext.getDefinition?.(definingContext.kindResolver?.(k) ?? k) ??
270
+ definingContext.getDefinition?.(k);
271
+ const def = resolveDef(kind);
272
+ const capability = def?.capability;
273
+ const sites = def
274
+ ? mergeCelEvalSites(
275
+ celEvalSites(effectiveAuthorSchema(def, resolveDef) as Record<string, any>),
276
+ celEvalSites(
277
+ (capability ? resolveDef(capability)?.schema : undefined) as
278
+ | Record<string, any>
279
+ | undefined,
280
+ ),
281
+ )
282
+ : NO_CEL_EVAL_SITES;
283
+ deferredByKind.set(kind, sites);
284
+ return sites;
285
+ };
286
+
200
287
  // init() may run more than once: when a child's local ref names a sibling
201
288
  // not yet initialized, child init defers with ERR_LOCAL_REF_PENDING and the
202
289
  // outer multi-pass loop retries this resource. Registration must happen
@@ -204,6 +291,10 @@ export function createTemplateController(definition: {
204
291
  // children are skipped, still-pending ones advance).
205
292
  let registered = false;
206
293
 
294
+ /** Memo per nested kind — a body is expanded once, but a template kind is
295
+ * instantiated many times across an application. */
296
+ const deferredByKind = new Map<string, CelEvalSites>();
297
+
207
298
  return {
208
299
  // The template's own resources are its allocation, and tearing the child
209
300
  // context down is the inverse. `init()` still resumes rather than
@@ -212,8 +303,17 @@ export function createTemplateController(definition: {
212
303
  init: (templateCtx) =>
213
304
  templateCtx.effect("template resources", async () => {
214
305
  if (!registered) {
306
+ // `self` is in scope for the whole body, not only for what init()
307
+ // resolves: a node the nested kind evaluates later (a step's
308
+ // `inputs`, a route's `returns`) may read `self` beside the
309
+ // call-time names its own controller binds. Bound as the
310
+ // published-reading view, so `self.<ref>.<field>` answers exactly
311
+ // as `resources.<name>.<field>` does.
312
+ childContext.bindContextValue?.("self", celSelfView(getSelf()));
215
313
  for (const template of definition.resources ?? []) {
216
- childContext.registerManifest(expandSelf(template));
314
+ childContext.registerManifest(
315
+ expandSelf(template, "", deferredPathsFor(template?.kind)),
316
+ );
217
317
  }
218
318
  registered = true;
219
319
  }