@telorun/kernel 0.59.0 → 0.61.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 (120) hide show
  1. package/dist/bundle/files-integrity.d.ts +21 -12
  2. package/dist/bundle/files-integrity.d.ts.map +1 -1
  3. package/dist/bundle/files-integrity.js +27 -14
  4. package/dist/bundle/files-integrity.js.map +1 -1
  5. package/dist/bundle/module-artifact.d.ts +134 -0
  6. package/dist/bundle/module-artifact.d.ts.map +1 -0
  7. package/dist/bundle/module-artifact.js +300 -0
  8. package/dist/bundle/module-artifact.js.map +1 -0
  9. package/dist/bundle/module-manifest.d.ts +12 -4
  10. package/dist/bundle/module-manifest.d.ts.map +1 -1
  11. package/dist/bundle/module-manifest.js +8 -4
  12. package/dist/bundle/module-manifest.js.map +1 -1
  13. package/dist/controller-loader.d.ts +3 -2
  14. package/dist/controller-loader.d.ts.map +1 -1
  15. package/dist/controller-loader.js +8 -8
  16. package/dist/controller-loader.js.map +1 -1
  17. package/dist/controller-loaders/bundle-loader.d.ts +8 -6
  18. package/dist/controller-loaders/bundle-loader.d.ts.map +1 -1
  19. package/dist/controller-loaders/bundle-loader.js +52 -13
  20. package/dist/controller-loaders/bundle-loader.js.map +1 -1
  21. package/dist/controller-loaders/npm-loader.d.ts +0 -18
  22. package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
  23. package/dist/controller-loaders/npm-loader.js +3 -190
  24. package/dist/controller-loaders/npm-loader.js.map +1 -1
  25. package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
  26. package/dist/controllers/resource-definition/resource-definition-controller.js +6 -1
  27. package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
  28. package/dist/controllers/resource-definition/resource-inherited-controller.d.ts.map +1 -1
  29. package/dist/controllers/resource-definition/resource-inherited-controller.js +57 -10
  30. package/dist/controllers/resource-definition/resource-inherited-controller.js.map +1 -1
  31. package/dist/controllers/type/json-schema-controller.d.ts +8 -0
  32. package/dist/controllers/type/json-schema-controller.d.ts.map +1 -0
  33. package/dist/controllers/type/json-schema-controller.js +91 -0
  34. package/dist/controllers/type/json-schema-controller.js.map +1 -0
  35. package/dist/directory-lock.d.ts +27 -0
  36. package/dist/directory-lock.d.ts.map +1 -0
  37. package/dist/directory-lock.js +205 -0
  38. package/dist/directory-lock.js.map +1 -0
  39. package/dist/evaluation-context.d.ts +5 -0
  40. package/dist/evaluation-context.d.ts.map +1 -1
  41. package/dist/evaluation-context.js +63 -33
  42. package/dist/evaluation-context.js.map +1 -1
  43. package/dist/index.d.ts +5 -2
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +4 -1
  46. package/dist/index.js.map +1 -1
  47. package/dist/init-failure-diagnostics.d.ts +61 -0
  48. package/dist/init-failure-diagnostics.d.ts.map +1 -0
  49. package/dist/init-failure-diagnostics.js +141 -0
  50. package/dist/init-failure-diagnostics.js.map +1 -0
  51. package/dist/invocation-contract-binding.d.ts +105 -0
  52. package/dist/invocation-contract-binding.d.ts.map +1 -0
  53. package/dist/invocation-contract-binding.js +296 -0
  54. package/dist/invocation-contract-binding.js.map +1 -0
  55. package/dist/kernel.d.ts +49 -0
  56. package/dist/kernel.d.ts.map +1 -1
  57. package/dist/kernel.js +117 -6
  58. package/dist/kernel.js.map +1 -1
  59. package/dist/manifest-sources/local-manifest-cache-source.d.ts +1 -1
  60. package/dist/manifest-sources/local-manifest-cache-source.d.ts.map +1 -1
  61. package/dist/manifest-sources/local-manifest-cache-source.js +1 -1
  62. package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -1
  63. package/dist/module-context.d.ts.map +1 -1
  64. package/dist/module-context.js +21 -0
  65. package/dist/module-context.js.map +1 -1
  66. package/dist/resource-context.d.ts +61 -0
  67. package/dist/resource-context.d.ts.map +1 -1
  68. package/dist/resource-context.js +131 -0
  69. package/dist/resource-context.js.map +1 -1
  70. package/dist/schema-compiled-values.d.ts +9 -1
  71. package/dist/schema-compiled-values.d.ts.map +1 -1
  72. package/dist/schema-compiled-values.js +55 -16
  73. package/dist/schema-compiled-values.js.map +1 -1
  74. package/dist/schema-validator.d.ts.map +1 -1
  75. package/dist/schema-validator.js +15 -1
  76. package/dist/schema-validator.js.map +1 -1
  77. package/dist/transports/oci/oci-client.d.ts +28 -1
  78. package/dist/transports/oci/oci-client.d.ts.map +1 -1
  79. package/dist/transports/oci/oci-client.js +24 -1
  80. package/dist/transports/oci/oci-client.js.map +1 -1
  81. package/dist/transports/oci/oci-transport.d.ts +20 -14
  82. package/dist/transports/oci/oci-transport.d.ts.map +1 -1
  83. package/dist/transports/oci/oci-transport.js +105 -46
  84. package/dist/transports/oci/oci-transport.js.map +1 -1
  85. package/dist/transports/registry-transport.d.ts +7 -2
  86. package/dist/transports/registry-transport.d.ts.map +1 -1
  87. package/dist/transports/registry-transport.js +8 -33
  88. package/dist/transports/registry-transport.js.map +1 -1
  89. package/dist/transports/transport-registry.d.ts +5 -4
  90. package/dist/transports/transport-registry.d.ts.map +1 -1
  91. package/dist/transports/transport-registry.js +4 -4
  92. package/dist/transports/transport-registry.js.map +1 -1
  93. package/dist/transports/transport.d.ts +27 -19
  94. package/dist/transports/transport.d.ts.map +1 -1
  95. package/package.json +3 -3
  96. package/src/bundle/files-integrity.ts +34 -14
  97. package/src/bundle/module-artifact.ts +375 -0
  98. package/src/bundle/module-manifest.ts +22 -6
  99. package/src/controller-loader.ts +9 -4
  100. package/src/controller-loaders/bundle-loader.ts +57 -11
  101. package/src/controller-loaders/npm-loader.ts +3 -209
  102. package/src/controllers/resource-definition/resource-definition-controller.ts +19 -0
  103. package/src/controllers/resource-definition/resource-inherited-controller.ts +69 -10
  104. package/src/controllers/type/json-schema-controller.ts +114 -0
  105. package/src/directory-lock.ts +225 -0
  106. package/src/evaluation-context.ts +81 -32
  107. package/src/index.ts +11 -2
  108. package/src/init-failure-diagnostics.ts +169 -0
  109. package/src/invocation-contract-binding.ts +392 -0
  110. package/src/kernel.ts +149 -6
  111. package/src/manifest-sources/local-manifest-cache-source.ts +1 -1
  112. package/src/module-context.ts +27 -0
  113. package/src/resource-context.ts +138 -0
  114. package/src/schema-compiled-values.ts +55 -15
  115. package/src/schema-validator.ts +15 -1
  116. package/src/transports/oci/oci-client.ts +32 -1
  117. package/src/transports/oci/oci-transport.ts +122 -51
  118. package/src/transports/registry-transport.ts +10 -38
  119. package/src/transports/transport-registry.ts +5 -5
  120. package/src/transports/transport.ts +32 -19
@@ -0,0 +1,392 @@
1
+ import {
2
+ type ContractDirection,
3
+ defaultBearingPaths,
4
+ effectiveContractField,
5
+ type DefResolver,
6
+ withStreamPropertiesSkipped,
7
+ } from "@telorun/analyzer";
8
+ import type { ResourceDefinition, ResourceInstance, ResourceManifest } from "@telorun/sdk";
9
+ import {
10
+ ERR_CONTRACT_UNRESOLVABLE,
11
+ ERR_INPUT_INVALID,
12
+ ERR_OUTPUT_INVALID,
13
+ InvokeError,
14
+ } from "@telorun/sdk";
15
+
16
+ /**
17
+ * Binds a resource's resolved invocation contract to its dispatch entry points,
18
+ * at the moment the kernel produces the instance.
19
+ *
20
+ * A contract is only a guarantee if it cannot be dispatched around, and most
21
+ * consumers never reach the kernel's dispatch chokepoint: Phase-5 injection puts
22
+ * the live instance straight into a consumer's config object, so `Ai.Agent`
23
+ * reads `this.resource.model` and calls `model.invoke(...)` in hand. Enforcing at
24
+ * a handoff would mean enforcing at every handoff — Phase-5 injection,
25
+ * `ctx.resolveRef`, scope-handle resolution, the template controller's direct
26
+ * dispatch, `ctx.invoke`'s target lookup — and one forgotten site silently
27
+ * reopens the hole.
28
+ *
29
+ * So the kernel binds instead, at `_createInstance`: its single production site.
30
+ * Every consumer, on every path, then holds an instance whose dispatch already
31
+ * enforces. Binding rather than wrapping is also what keeps the rest a
32
+ * non-problem — there is one object, so controller-specific members are
33
+ * genuinely its own, the prototype chain (and `instanceof` across the SDK realm
34
+ * boundary) is untouched, and `stripCompiledValues` / `detachSnapshotValue` see
35
+ * exactly the object they always saw. It is the same in-place technique the
36
+ * kernel already uses to fold detached-task draining into `teardown()` and
37
+ * runtime CEL expansion into `invoke()`.
38
+ *
39
+ * Consequence, stated rather than left to be discovered: the bound `invoke`
40
+ * shadows the controller's, so a controller calling `this.invoke()` internally
41
+ * goes through its own contract.
42
+ */
43
+
44
+ /** Compiles a JSON Schema to a validator. The kernel's `SchemaValidator` runs
45
+ * with `useDefaults`, so validating also fills declared defaults. */
46
+ export interface ContractValidatorFactory {
47
+ (typeRef: unknown): { validate(value: unknown): void };
48
+ /** Resolves a type field to its JSON Schema, for the schema-level decisions
49
+ * (stream skipping, default paths) a compiled validator can't answer. */
50
+ schemaOf(typeRef: unknown): Record<string, any> | undefined;
51
+ /** Resolves a `$ref` to the registered schema it names, so the schema walks
52
+ * can see through the reference form the compiled validator keeps intact. */
53
+ resolveRef(ref: string): Record<string, any> | undefined;
54
+ /** Compiles an adjusted schema while keeping the CEL `rules:` registered under
55
+ * a named type — the one path that survives stripping a stream property from
56
+ * a named contract without abandoning its invariants. */
57
+ withRules(name: string | undefined, schema: Record<string, any>): { validate(value: unknown): void };
58
+ }
59
+
60
+ export interface BoundContract {
61
+ direction: ContractDirection;
62
+ validate(value: unknown): void;
63
+ /** Paths a default can be written to — how far the caller's value must be
64
+ * copied before validation runs. Empty when the contract declares none. */
65
+ defaultPaths(): string[][];
66
+ }
67
+
68
+ const CONTRACT_ERROR: Record<ContractDirection, string> = {
69
+ inputType: ERR_INPUT_INVALID,
70
+ outputType: ERR_OUTPUT_INVALID,
71
+ };
72
+
73
+ /**
74
+ * Resolve one direction of a resource's contract to a bound validator.
75
+ *
76
+ * The declaration is layered instance-manifest → nearest along `extends` (see
77
+ * `effectiveContractField` — nearest wins, contracts never merge), then compiled.
78
+ *
79
+ * A NAMED reference (`inputType: RequestShape`, or a `!ref` to a type) is
80
+ * compiled by name so it keeps the CEL `rules:` registered alongside it, which a
81
+ * plain schema copy would drop. Everything else is compiled from the resolved
82
+ * schema — inline `{kind, schema}` and raw forms carry no rules, and resolving
83
+ * first is what lets a `{ $ref: "telo://Self/X" }` contract compile at all
84
+ * (`resolveTypeSchema` follows it through the kernel's registry, which AJV
85
+ * cannot).
86
+ *
87
+ * The resolved schema is stripped of `x-telo-stream` properties first: a live
88
+ * `Stream` in a declared slot is not data to be traversed, the same defect as
89
+ * `stripCompiledValues` walking a live instance in a ref slot. Streams travel in
90
+ * both directions (`Codec.Encoder` marks `input` on its `inputType` and requires
91
+ * it), so the skip is not one-directional.
92
+ *
93
+ * WHICH declaration applies is decided here, at create time — that is a fact
94
+ * about the manifest. COMPILING it is deferred to first dispatch and memoized: a
95
+ * contract may reference a named `telo#Type` whose `Type.JsonSchema` resource
96
+ * initializes later in the same multi-pass loop, and resolving it eagerly would
97
+ * make every contract-declaring kind depend on type-registration order. Nothing
98
+ * can dispatch before the loop finishes, so first-use is always late enough.
99
+ */
100
+ /** True when a type field names a registered `telo#Type` — a bare name, or the
101
+ * `{kind, name}` object a `!ref` normalizes to. Only these carry CEL `rules:`,
102
+ * so only these are worth compiling by name rather than from their schema. */
103
+ function isNamedTypeReference(declared: unknown): boolean {
104
+ if (typeof declared === "string") return true;
105
+ if (!declared || typeof declared !== "object") return false;
106
+ const ref = declared as Record<string, unknown>;
107
+ return typeof ref.name === "string" && !(ref.schema && typeof ref.schema === "object");
108
+ }
109
+
110
+ export function resolveBoundContract(
111
+ direction: ContractDirection,
112
+ manifest: ResourceManifest,
113
+ definition: ResourceDefinition | undefined,
114
+ resolveDef: DefResolver,
115
+ factory: ContractValidatorFactory,
116
+ ): BoundContract | undefined {
117
+ const own = (manifest as unknown as Record<string, unknown>)[direction];
118
+ const declared =
119
+ own !== undefined && own !== null
120
+ ? own
121
+ : effectiveContractField(definition, resolveDef, direction);
122
+ if (declared === undefined || declared === null) return undefined;
123
+
124
+ let compiled: { validate(value: unknown): void } | undefined;
125
+ let paths: string[][] | undefined;
126
+
127
+ const resolve = (): { validate(value: unknown): void } => {
128
+ if (compiled !== undefined) return compiled;
129
+ const schema = factory.schemaOf(declared);
130
+ if (!schema) {
131
+ // A declared contract that resolves to nothing is a manifest fault — a
132
+ // named type that never registered — and it MUST NOT degrade to
133
+ // "unvalidated". Silently disabling enforcement is the failure mode
134
+ // nobody notices: every later call passes because nothing is checking.
135
+ throw new InvokeError(
136
+ ERR_CONTRACT_UNRESOLVABLE,
137
+ `declared \`${direction}\` could not be resolved to a schema: ${describeDeclaration(declared)}. ` +
138
+ `The type is not registered, so the contract cannot be enforced.`,
139
+ );
140
+ }
141
+ const stripped = withStreamPropertiesSkipped(schema, factory.resolveRef);
142
+ paths = defaultBearingPaths(stripped, factory.resolveRef);
143
+ // Compile by NAME whenever the declaration is one, so the type's CEL
144
+ // `rules:` are composed in — including when a stream had to be stripped, in
145
+ // which case the stream-bearing properties are dropped from the schema the
146
+ // named validator sees rather than the reference being abandoned.
147
+ compiled = !isNamedTypeReference(declared)
148
+ ? factory(stripped)
149
+ : stripped === schema
150
+ ? factory(declared)
151
+ : factory.withRules(nameOf(declared), stripped);
152
+ return compiled;
153
+ };
154
+
155
+ return {
156
+ direction,
157
+ validate: (value: unknown) => resolve().validate(value),
158
+ defaultPaths: () => {
159
+ resolve();
160
+ return paths ?? [];
161
+ },
162
+ };
163
+ }
164
+
165
+ const nameOf = (declared: unknown): string | undefined =>
166
+ typeof declared === "string"
167
+ ? declared
168
+ : ((declared as Record<string, unknown> | null)?.name as string | undefined);
169
+
170
+ const describeDeclaration = (declared: unknown): string =>
171
+ typeof declared === "string" ? `'${declared}'` : JSON.stringify(declared);
172
+
173
+ /**
174
+ * A copy of `value` deep along exactly the paths a default can be written to and
175
+ * shared everywhere else.
176
+ *
177
+ * A flat shallow copy would not do: AJV's `useDefaults` writes at every level it
178
+ * finds a default, so a nested default would mutate the structure the caller
179
+ * still holds. Bounded by the schema's defaults rather than by the size of the
180
+ * payload — a contract declaring no defaults copies one level and nothing more.
181
+ */
182
+ export function copyForDefaults(value: unknown, paths: readonly string[][]): unknown {
183
+ if (!value || typeof value !== "object") return value;
184
+ let out = shallowCopy(value);
185
+ for (const path of paths) {
186
+ // The leaf is what gets written; every CONTAINER above it is what must not
187
+ // be shared. An `[]` segment fans out: the default lands in each element, so
188
+ // the array and every element on the path have to be copied too — bailing
189
+ // there would leave `rows[0]` shared and let a fill mutate the caller's data.
190
+ out = copyAlong(out, path.slice(0, -1));
191
+ }
192
+ return out;
193
+ }
194
+
195
+ const shallowCopy = (value: object): any =>
196
+ Array.isArray(value) ? [...value] : { ...(value as Record<string, unknown>) };
197
+
198
+ function copyAlong(node: unknown, segments: readonly string[]): unknown {
199
+ if (!node || typeof node !== "object") return node;
200
+ if (segments.length === 0) return node;
201
+ const [head, ...rest] = segments;
202
+
203
+ if (head === "[]") {
204
+ if (!Array.isArray(node)) return node;
205
+ return node.map((item) =>
206
+ item && typeof item === "object" ? copyAlong(shallowCopy(item), rest) : item,
207
+ );
208
+ }
209
+
210
+ const container = node as Record<string, unknown>;
211
+ const child = container[head!];
212
+ if (!child || typeof child !== "object") return node;
213
+ container[head!] = copyAlong(shallowCopy(child), rest);
214
+ return node;
215
+ }
216
+
217
+ /**
218
+ * Raise a contract violation as a structured {@link InvokeError}.
219
+ *
220
+ * Structured rather than a plain error because the run engine assigns
221
+ * `INTERNAL_ERROR` to anything that is not an `InvokeError` — a contract
222
+ * violation would then reach a `catch` block indistinguishable from a crash, and
223
+ * an author could neither match it nor rethrow it faithfully. These are ambient
224
+ * kernel codes: catchable by name, and never counted against a kind's own
225
+ * `throws:` union.
226
+ *
227
+ * The message names the target, the direction, and the offending detail, because
228
+ * a caller several steps away otherwise cannot tell which boundary rejected the
229
+ * value or which side supplied it.
230
+ */
231
+ export function contractViolation(
232
+ direction: ContractDirection,
233
+ describeTarget: () => string,
234
+ cause: unknown,
235
+ ): Error {
236
+ // A type's CEL `rules:` raise the author's OWN code — that is the whole point
237
+ // of declaring one, and `modules/type` documents rule codes as catchable.
238
+ // Only a structural schema failure is the ambient contract violation.
239
+ //
240
+ // Re-raised as a STRUCTURED error carrying that code: the rule itself throws a
241
+ // `RuntimeError`, which has a code but not the marker a catch block matches
242
+ // on, so it used to reach `catch` as the generic plain-failure code — the
243
+ // documented behaviour never actually worked. Wrapping preserves the code and
244
+ // makes it match.
245
+ const ruleCode = ruleViolationCode(cause);
246
+ if (ruleCode) {
247
+ return new InvokeError(ruleCode, (cause as Error).message, undefined, { cause });
248
+ }
249
+
250
+ const side = direction === "inputType" ? "inputs" : "result";
251
+ const detail = cause instanceof Error ? cause.message : String(cause);
252
+ return new InvokeError(
253
+ CONTRACT_ERROR[direction],
254
+ `${describeTarget()}: ${side} do not satisfy the declared ${direction}: ${detail}`,
255
+ undefined,
256
+ { cause },
257
+ );
258
+ }
259
+
260
+ /** Codes the validator itself raises for a STRUCTURAL failure. Anything else
261
+ * carrying a code came from a declared rule and belongs to its author. */
262
+ const STRUCTURAL_VALIDATION_CODES = new Set([
263
+ "ERR_RESOURCE_SCHEMA_VALIDATION_FAILED",
264
+ "ERR_TYPE_NOT_FOUND",
265
+ ]);
266
+
267
+ function ruleViolationCode(cause: unknown): string | undefined {
268
+ const code = (cause as { code?: unknown } | null)?.code;
269
+ if (typeof code !== "string" || code.length === 0) return undefined;
270
+ return STRUCTURAL_VALIDATION_CODES.has(code) ? undefined : code;
271
+ }
272
+
273
+ export interface ContractBinding {
274
+ input?: BoundContract;
275
+ output?: BoundContract;
276
+ /** Names the resource in a violation message — the target, so a caller several
277
+ * steps away can tell which boundary rejected the value. */
278
+ describeTarget(): string;
279
+ }
280
+
281
+ /**
282
+ * Bind `invoke()` and `provide()` in place.
283
+ *
284
+ * `provide()` takes no caller arguments, so it has no input side, but it returns
285
+ * a value against a declared `outputType` and that result is validated exactly as
286
+ * an invocable's is — same path, same stream skip, same ambient error code.
287
+ * `run()` is bound to nothing: parameterless and void, there is nothing to fill
288
+ * defaults into and no result to validate, so it is guarded statically instead.
289
+ */
290
+ export function bindContract(instance: ResourceInstance, binding: ContractBinding): void {
291
+ const { input, output, describeTarget } = binding;
292
+ if (!input && !output) return;
293
+
294
+ if (typeof instance.invoke === "function") {
295
+ const original = instance.invoke.bind(instance) as (
296
+ inputs: any,
297
+ ...rest: unknown[]
298
+ ) => Promise<unknown>;
299
+ // EVERY argument is forwarded, not just `inputs`. `invoke(inputs, ctx)`
300
+ // carries the InvokeContext — cancellation, tracing — as its second
301
+ // parameter, and a wrapper that takes only `inputs` silently drops it: a
302
+ // detached body would then never see its cancellation token and a lease
303
+ // holding across it would never be released. The contract only concerns the
304
+ // first argument; the rest belong to the caller and the callee.
305
+ instance.invoke = async (inputs: any, ...rest: unknown[]) => {
306
+ let effective = inputs;
307
+ if (input) {
308
+ effective = copyForDefaults(inputs, input.defaultPaths());
309
+ // Validate a BIGINT-NORMALIZED view, not the values themselves. CEL
310
+ // evaluates an integer literal to a BigInt, which a JSON Schema
311
+ // validator does not recognise as `integer` — so every computed integer
312
+ // reaching a declared integer input would be rejected for a reason the
313
+ // author cannot act on. The dispatched values keep their BigInts, since
314
+ // a controller may need the full 64-bit range.
315
+ const view = withBigIntsAsNumbers(effective);
316
+ try {
317
+ input.validate(view);
318
+ } catch (error) {
319
+ throw contractViolation("inputType", describeTarget, error);
320
+ }
321
+ // Defaults are additive, so anything the validator filled into the view
322
+ // is a key the caller omitted — copy exactly those back.
323
+ effective = mergeFilledDefaults(effective, view);
324
+ }
325
+ const result = await original(effective, ...rest);
326
+ if (output) {
327
+ try {
328
+ output.validate(withBigIntsAsNumbers(result));
329
+ } catch (error) {
330
+ throw contractViolation("outputType", describeTarget, error);
331
+ }
332
+ }
333
+ return result;
334
+ };
335
+ }
336
+
337
+ if (output && typeof instance.provide === "function") {
338
+ const original = instance.provide.bind(instance) as (...args: unknown[]) => Promise<unknown>;
339
+ instance.provide = async (...args: unknown[]) => {
340
+ const result = await original(...args);
341
+ try {
342
+ output.validate(withBigIntsAsNumbers(result));
343
+ } catch (error) {
344
+ throw contractViolation("outputType", describeTarget, error);
345
+ }
346
+ return result;
347
+ };
348
+ }
349
+ }
350
+
351
+ /** A structural copy with every BigInt rendered as a Number, for validation
352
+ * only. A value beyond the safe-integer range loses precision in the VIEW,
353
+ * which can only affect a bound check at the extremes; the dispatched value is
354
+ * untouched. Non-plain objects (a live `Stream`, a resource instance) pass
355
+ * through by reference — they are not data to be walked. */
356
+ export function withBigIntsAsNumbers(value: unknown): unknown {
357
+ if (typeof value === "bigint") return Number(value);
358
+ if (Array.isArray(value)) {
359
+ let changed = false;
360
+ const items = value.map((item) => {
361
+ const next = withBigIntsAsNumbers(item);
362
+ if (next !== item) changed = true;
363
+ return next;
364
+ });
365
+ return changed ? items : value;
366
+ }
367
+ if (!value || typeof value !== "object") return value;
368
+ if (Object.getPrototypeOf(value) !== Object.prototype) return value;
369
+ let changed = false;
370
+ const out: Record<string, unknown> = {};
371
+ for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
372
+ const next = withBigIntsAsNumbers(item);
373
+ if (next !== item) changed = true;
374
+ out[key] = next;
375
+ }
376
+ return changed ? out : value;
377
+ }
378
+
379
+ /** Copy keys the validator's default-fill added to `view` back onto `target`.
380
+ * Only ADDITIONS are taken: a key already present came from the caller and its
381
+ * original (possibly BigInt) value is the one to dispatch. */
382
+ function mergeFilledDefaults(target: unknown, view: unknown): unknown {
383
+ if (target === view) return target;
384
+ if (!target || typeof target !== "object" || Array.isArray(target)) return target;
385
+ if (!view || typeof view !== "object" || Array.isArray(view)) return target;
386
+ const out = target as Record<string, unknown>;
387
+ for (const [key, filled] of Object.entries(view as Record<string, unknown>)) {
388
+ if (!(key in out)) out[key] = filled;
389
+ else out[key] = mergeFilledDefaults(out[key], filled);
390
+ }
391
+ return out;
392
+ }
package/src/kernel.ts CHANGED
@@ -6,9 +6,15 @@ import {
6
6
  isModuleKind,
7
7
  Loader,
8
8
  StaticAnalyzer,
9
+ type DefResolver,
9
10
  type LoadedGraph,
10
11
  type ManifestSource,
11
12
  } from "@telorun/analyzer";
13
+ import {
14
+ bindContract,
15
+ type ContractValidatorFactory,
16
+ resolveBoundContract,
17
+ } from "./invocation-contract-binding.js";
12
18
  import {
13
19
  ControllerContext,
14
20
  ControllerPolicy,
@@ -49,8 +55,16 @@ import {
49
55
  writeAnalysisStamp,
50
56
  } from "./manifest-sources/analysis-stamp.js";
51
57
  import {
58
+ cachePathForCanonical,
52
59
  resolveCacheRoot,
60
+ resolveEntryDir,
53
61
  } from "./manifest-sources/local-manifest-cache-source.js";
62
+ import { readOwnerManifest } from "./bundle/module-manifest.js";
63
+ import {
64
+ moduleArtifactFor,
65
+ moduleDirectoryFor,
66
+ type ModuleArtifact,
67
+ } from "./bundle/module-artifact.js";
54
68
  import { defaultTransportRegistry } from "./transports/transport-registry.js";
55
69
  import {
56
70
  collectDeclaredEnvKeys,
@@ -131,6 +145,9 @@ export class Kernel implements IKernel {
131
145
  /** The `.telo` cache root for this load, resolved once in `load()` and
132
146
  * threaded to the validator, analysis stamp, and npm install root. */
133
147
  private _cacheRoot?: string | null;
148
+ /** Per-module artifact handles, keyed by canonical manifest source. Rebuilt on
149
+ * every `load()`; empty for a graph of purely local / manifest-only modules. */
150
+ private readonly moduleArtifacts = new Map<string, ModuleArtifact>();
134
151
  private _loadedGraph?: LoadedGraph;
135
152
  // Lifecycle state — guards boot/runTargets/teardown/invoke transitions.
136
153
  // teardown() is the only idempotent method; everything else throws on misuse.
@@ -337,6 +354,13 @@ export class Kernel implements IKernel {
337
354
  "Telo.FileSink",
338
355
  await import("./controllers/logging/file-sink-controller.js"),
339
356
  );
357
+ // Data shapes are a kernel concern for the same reason: every kind with an
358
+ // invocation contract declares one, so `inputType:` must be writable without
359
+ // first importing a module.
360
+ this.controllers.registerController(
361
+ "Telo.JsonSchema",
362
+ await import("./controllers/type/json-schema-controller.js"),
363
+ );
340
364
  }
341
365
 
342
366
  /**
@@ -394,8 +418,8 @@ export class Kernel implements IKernel {
394
418
  await this.loadBuiltinDefinitions();
395
419
 
396
420
  // Phase 5: attach injection hook — fires between create() and init() for every resource
397
- this.rootContext.preInitHook = (resource, getInstance, isPending) =>
398
- this._injectDependencies(resource, getInstance, isPending);
421
+ this.rootContext.preInitHook = (resource, getInstance, isPending, owner) =>
422
+ this._injectDependencies(resource, getInstance, isPending, owner);
399
423
 
400
424
  // Expose definition lookup so invoke()/invokeResolved() can check thrown
401
425
  // InvokeError.code against the declared throw union (rule 9). Propagates
@@ -425,6 +449,7 @@ export class Kernel implements IKernel {
425
449
  );
426
450
  }
427
451
  this._loadedGraph = analysisGraph;
452
+ this.buildModuleArtifacts(analysisGraph, manifestsDir);
428
453
  // Version reconciliation: an incompatible major mismatch is fatal (the
429
454
  // hoist override would silently run the wrong major); a same-major hoist is
430
455
  // advisory — the override already redirects every importer to the winner.
@@ -918,6 +943,60 @@ export class Kernel implements IKernel {
918
943
  return this._cacheRoot ? `${this._cacheRoot}/npm` : undefined;
919
944
  }
920
945
 
946
+ /**
947
+ * Build one {@link ModuleArtifact} per loaded module that ships a payload.
948
+ *
949
+ * Here, and not inside a controller loader, because this is the only point
950
+ * where both halves are in hand: the **pinned** ref the importer wrote
951
+ * (`requestedUrl`, integrity fragment included) and the manifest text that has
952
+ * already been verified against it. A loader sees only the canonical base URI,
953
+ * which carries no `#sha256-`, so a loader that fetched for itself would have
954
+ * to re-read the index off the cache directory — verification downgraded from
955
+ * "anchored at the importer's pin" to "trust whatever is on disk".
956
+ *
957
+ * Keyed by canonical `source`, which is what a definition's `metadata.source`
958
+ * carries, so a controller resolution can find its module's artifact.
959
+ *
960
+ * The trigger is "this manifest carries a `layers:` index", never "its source
961
+ * maps to a cache path". Those differ the moment the cache is warm:
962
+ * `LocalManifestCacheSource` serves a hit as a `file://` URL into
963
+ * `.telo/manifests/`, which no transport claims, so deriving the directory from
964
+ * `source` yields nothing and every OCI module would silently lose its artifact
965
+ * on the second and every later run — taking lazy asset materialization with it.
966
+ * The **pinned** `requestedUrl` survives a cache hit, so placement is derived
967
+ * from that, with a local `source` resolving to its own directory.
968
+ */
969
+ private buildModuleArtifacts(graph: LoadedGraph, manifestsDir: string | undefined): void {
970
+ this.moduleArtifacts.clear();
971
+ const transports = defaultTransportRegistry(this.registryUrl);
972
+ const entryDir = this._entryUrl ? resolveEntryDir(this._entryUrl) ?? "" : "";
973
+ for (const [, module] of graph.modules) {
974
+ const file = module.owner;
975
+ if (this.moduleArtifacts.has(file.source)) continue;
976
+ const artifact = moduleArtifactFor({
977
+ pinnedRef: file.requestedUrl,
978
+ layers: readOwnerManifest(file.text).layers,
979
+ moduleDir: moduleDirectoryFor(
980
+ file.requestedUrl,
981
+ file.source,
982
+ entryDir,
983
+ this.registryUrl,
984
+ manifestsDir,
985
+ ),
986
+ transports,
987
+ log: this.logging.kernelLogger(),
988
+ });
989
+ if (artifact) this.moduleArtifacts.set(file.source, artifact);
990
+ }
991
+ }
992
+
993
+ /** The artifact of the module whose manifest resolved from `source`, or
994
+ * `undefined` for a module with no fetchable payload (already on disk,
995
+ * manifest-only, or not cacheable). */
996
+ getModuleArtifact(source: string | undefined): ModuleArtifact | undefined {
997
+ return source ? this.moduleArtifacts.get(source) : undefined;
998
+ }
999
+
921
1000
  /** Authored `kind` of a declared resource by name, from the static manifest
922
1001
  * set. Init-order-independent (unlike `resourceInstances`), so a controller
923
1002
  * resolving a `!ref <name>` sentinel before the target initializes can still
@@ -1198,6 +1277,19 @@ export class Kernel implements IKernel {
1198
1277
  const instance = await controller.create(processedResource, ctx);
1199
1278
  if (!instance) return null;
1200
1279
 
1280
+ // Bind the resolved invocation contract to the instance, here at the kernel's
1281
+ // single instance-production site — so every consumer holds an already
1282
+ // enforcing instance, including the majority that read a Phase-5-injected ref
1283
+ // straight off their own config and never reach a dispatch chokepoint.
1284
+ //
1285
+ // For a `base:` child this composes without a special case: the parent's
1286
+ // instance was produced by a nested `_createInstance` (so it is already bound
1287
+ // to the parent's contract), the inherited controller bound the `inputs:` /
1288
+ // `result:` mapping onto it, and this call binds the child's own contract
1289
+ // outermost. One dispatch then checks, in order: child inputs → mapping →
1290
+ // parent inputs → controller → parent result → mapping → child result.
1291
+ this.bindInvocationContract(instance, processedResource, resolvedKind, ctx);
1292
+
1201
1293
  // Fold the resource's fire-and-forget drain into its own teardown: tearing
1202
1294
  // the resource down drains the background tasks it spawned (the kernel just
1203
1295
  // calls teardown() — it tracks no tasks itself). A drain with no pending
@@ -1216,14 +1308,58 @@ export class Kernel implements IKernel {
1216
1308
  // init() on the wrapper would be invisible to the original invoke(), which still
1217
1309
  // runs with `this === instance`. Mutating in place also preserves the prototype
1218
1310
  // chain — class-declared methods remain reachable.
1311
+ // Every argument is forwarded: `invoke(inputs, ctx)` carries the
1312
+ // InvokeContext (cancellation, tracing) as its second parameter, and a
1313
+ // wrapper that declares only `inputs` silently drops it.
1219
1314
  const originalInvoke = instance.invoke!.bind(instance);
1220
- instance.invoke = async (inputs: any) => {
1315
+ instance.invoke = async (inputs: any, ...rest: unknown[]) => {
1221
1316
  const expanded = evalContext.expandPaths(inputs as Record<string, unknown>, runtime);
1222
- return originalInvoke(expanded);
1317
+ return (originalInvoke as (i: any, ...r: unknown[]) => Promise<unknown>)(expanded, ...rest);
1223
1318
  };
1224
1319
  return { instance, ctx, resource: processedResource };
1225
1320
  }
1226
1321
 
1322
+ /**
1323
+ * Resolve and bind both directions of a resource's invocation contract.
1324
+ *
1325
+ * The declaration is layered instance-manifest → nearest along `extends`;
1326
+ * contracts never merge (a call signature is not additive the way construction
1327
+ * config is), so a definition that declares one fully replaces its ancestor's.
1328
+ * Resolution runs in the scope that DECLARED each definition — an `extends`
1329
+ * alias is lexical, and a `telo#Type` reference goes through import aliases, so
1330
+ * a chain crossing module boundaries re-scopes at every hop.
1331
+ */
1332
+ private bindInvocationContract(
1333
+ instance: ResourceInstance,
1334
+ resource: ResourceManifest,
1335
+ resolvedKind: string,
1336
+ ctx: ResourceContext,
1337
+ ): void {
1338
+ const definition = this.controllers.getDefinition(resolvedKind);
1339
+ if (!definition) return;
1340
+
1341
+ const impl = ctx as ResourceContextImpl;
1342
+ const factory = Object.assign((typeRef: unknown) => impl.createTypeValidator(typeRef as any), {
1343
+ schemaOf: (typeRef: unknown) => impl.resolveTypeSchema(typeRef),
1344
+ resolveRef: (ref: string) => impl.lookupSchema(ref) as Record<string, any> | undefined,
1345
+ withRules: (name: string | undefined, schema: Record<string, any>) =>
1346
+ impl.createTypeValidatorWithRules(name, schema),
1347
+ }) as ContractValidatorFactory;
1348
+
1349
+ const resolveDef: DefResolver = (kind, from) =>
1350
+ this.registry.resolveDefinitionIn(kind, from?.metadata?.module);
1351
+
1352
+ const input = resolveBoundContract("inputType", resource, definition, resolveDef, factory);
1353
+ const output = resolveBoundContract("outputType", resource, definition, resolveDef, factory);
1354
+ if (!input && !output) return;
1355
+
1356
+ bindContract(instance, {
1357
+ input,
1358
+ output,
1359
+ describeTarget: () => `${resolvedKind}/${resource.metadata?.name ?? "<unnamed>"}`,
1360
+ });
1361
+ }
1362
+
1227
1363
  /**
1228
1364
  * Create phase for an inherited (concrete-`extends`) definition's parent: runs
1229
1365
  * the ordinary create pipeline (controller resolution + lazy load, schema
@@ -1248,11 +1384,18 @@ export class Kernel implements IKernel {
1248
1384
  * field map and replaces each {kind, name} reference value (outside scope visibility
1249
1385
  * paths) with the live ResourceInstance returned by getInstance(name). Fields within
1250
1386
  * scope paths are left as {kind, name} — the controller resolves them at runtime.
1387
+ *
1388
+ * `owner` is the context the resource belongs to, and the scope handle built for an
1389
+ * `x-telo-scope` field hangs off it rather than off the root: a `with:` block's inline
1390
+ * declarations name their kinds through the import aliases of the module that DECLARED
1391
+ * the resource, so a library's scoped `kind: OAuth.RedirectListener` resolves against
1392
+ * that library's imports — the root has never heard of the alias.
1251
1393
  */
1252
1394
  private _injectDependencies(
1253
1395
  resource: ResourceManifest,
1254
1396
  getInstance: (name: string, alias?: string) => ResourceInstance | undefined,
1255
- isPending?: (name: string) => boolean,
1397
+ isPending: ((name: string) => boolean) | undefined,
1398
+ owner: IEvaluationContext,
1256
1399
  ): void {
1257
1400
  this.registry.iterateFieldEntries(
1258
1401
  resource,
@@ -1283,7 +1426,7 @@ export class Kernel implements IKernel {
1283
1426
  );
1284
1427
  }
1285
1428
  }
1286
- (resource as Record<string, unknown>)[fieldPath] = this.rootContext.createScopeHandle(
1429
+ (resource as Record<string, unknown>)[fieldPath] = owner.createScopeHandle(
1287
1430
  val as ResourceManifest[],
1288
1431
  );
1289
1432
  }
@@ -133,7 +133,7 @@ export class LocalManifestCacheSource implements ManifestSource {
133
133
  export function cachePathForCanonical(
134
134
  canonicalSource: string,
135
135
  entryDir: string,
136
- registryUrl: string,
136
+ registryUrl: string | undefined = DEFAULT_REGISTRY_URL,
137
137
  manifestsDir?: string,
138
138
  ): string | null {
139
139
  const cacheRoot = manifestsDir ?? path.join(entryDir, CACHE_SUBDIR);
@@ -404,6 +404,33 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
404
404
  getInstance(name: string): unknown {
405
405
  const entry = this.resourceInstances.get(name);
406
406
  if (!entry) {
407
+ // A name this module DID declare but that has no instance is never an
408
+ // unknown name — reporting it as "not found ... available resources:
409
+ // <imports>" reads as a typo in a name declared right there. Which of the
410
+ // two real situations it is depends on whether this context is still
411
+ // initializing:
412
+ //
413
+ // - mid-init: a dependency-ordering deferral. Defer exactly as Phase-5
414
+ // injection does, so the multi-pass loop retries and the failure is
415
+ // attributed to the dependency.
416
+ // - after init: no later pass is coming (a resource registered into the
417
+ // module after the loop drained its queue stays pending forever), so
418
+ // promising one would send the developer after a retry that will never
419
+ // happen. Say what is actually true.
420
+ if (this.hasManifest(name)) {
421
+ if (this.state !== "Initialized") {
422
+ throw new RuntimeError(
423
+ "ERR_LOCAL_REF_PENDING",
424
+ `Local reference '${name}' is registered but not initialized yet (deferring to a later init pass)`,
425
+ );
426
+ }
427
+ throw new RuntimeError(
428
+ "ERR_RESOURCE_NOT_FOUND",
429
+ `Resource '${name}' is declared in this module but was never initialized, so it cannot be dispatched. ` +
430
+ `A resource registered after the module's init loop finished is never created — declare it at module scope, ` +
431
+ `or inside the '${name}'-owning scope's own resource list.`,
432
+ );
433
+ }
407
434
  throw new Error(
408
435
  `Resource '${name}' not found in module context. Available resources: ${[...this.resourceInstances.keys()].join(", ")}`,
409
436
  );