@telorun/kernel 0.77.0 → 0.79.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 (110) hide show
  1. package/dist/bundle/module-artifact.d.ts +2 -1
  2. package/dist/bundle/module-artifact.d.ts.map +1 -1
  3. package/dist/bundle/module-artifact.js +14 -10
  4. package/dist/bundle/module-artifact.js.map +1 -1
  5. package/dist/bundle/tar.d.ts +3 -0
  6. package/dist/bundle/tar.d.ts.map +1 -1
  7. package/dist/bundle/tar.js +27 -1
  8. package/dist/bundle/tar.js.map +1 -1
  9. package/dist/controller-loader.d.ts +62 -9
  10. package/dist/controller-loader.d.ts.map +1 -1
  11. package/dist/controller-loader.js +71 -63
  12. package/dist/controller-loader.js.map +1 -1
  13. package/dist/controller-loaders/bundle-loader.d.ts +2 -2
  14. package/dist/controller-loaders/bundle-loader.d.ts.map +1 -1
  15. package/dist/controller-loaders/bundle-loader.js +9 -3
  16. package/dist/controller-loaders/bundle-loader.js.map +1 -1
  17. package/dist/controller-loaders/napi-loader.d.ts +2 -1
  18. package/dist/controller-loaders/napi-loader.d.ts.map +1 -1
  19. package/dist/controller-loaders/napi-loader.js +4 -1
  20. package/dist/controller-loaders/napi-loader.js.map +1 -1
  21. package/dist/controller-loaders/npm-loader.d.ts +2 -1
  22. package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
  23. package/dist/controller-loaders/npm-loader.js +13 -7
  24. package/dist/controller-loaders/npm-loader.js.map +1 -1
  25. package/dist/controller-loaders/source-bundle-builder.d.ts +2 -1
  26. package/dist/controller-loaders/source-bundle-builder.d.ts.map +1 -1
  27. package/dist/controller-loaders/source-bundle-builder.js +4 -1
  28. package/dist/controller-loaders/source-bundle-builder.js.map +1 -1
  29. package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
  30. package/dist/controllers/resource-definition/resource-definition-controller.js +7 -3
  31. package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
  32. package/dist/controllers/resource-definition/resource-template-controller.d.ts.map +1 -1
  33. package/dist/controllers/resource-definition/resource-template-controller.js +12 -5
  34. package/dist/controllers/resource-definition/resource-template-controller.js.map +1 -1
  35. package/dist/dependency-injection.d.ts.map +1 -1
  36. package/dist/dependency-injection.js +7 -7
  37. package/dist/dependency-injection.js.map +1 -1
  38. package/dist/evaluation-context.d.ts +33 -13
  39. package/dist/evaluation-context.d.ts.map +1 -1
  40. package/dist/evaluation-context.js +145 -4
  41. package/dist/evaluation-context.js.map +1 -1
  42. package/dist/index.d.ts +1 -0
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +1 -0
  45. package/dist/index.js.map +1 -1
  46. package/dist/instance-declaration.d.ts +8 -0
  47. package/dist/instance-declaration.d.ts.map +1 -0
  48. package/dist/instance-declaration.js +34 -0
  49. package/dist/instance-declaration.js.map +1 -0
  50. package/dist/invocation-contract-binding.d.ts +15 -2
  51. package/dist/invocation-contract-binding.d.ts.map +1 -1
  52. package/dist/invocation-contract-binding.js +39 -4
  53. package/dist/invocation-contract-binding.js.map +1 -1
  54. package/dist/kernel.d.ts.map +1 -1
  55. package/dist/kernel.js +60 -3
  56. package/dist/kernel.js.map +1 -1
  57. package/dist/manifest-schemas.d.ts +1 -1
  58. package/dist/manifest-schemas.d.ts.map +1 -1
  59. package/dist/manifest-schemas.js +1 -1
  60. package/dist/manifest-schemas.js.map +1 -1
  61. package/dist/module-context.d.ts +5 -1
  62. package/dist/module-context.d.ts.map +1 -1
  63. package/dist/module-context.js +13 -1
  64. package/dist/module-context.js.map +1 -1
  65. package/dist/resource-context.d.ts +12 -1
  66. package/dist/resource-context.d.ts.map +1 -1
  67. package/dist/resource-context.js +15 -0
  68. package/dist/resource-context.js.map +1 -1
  69. package/dist/transports/oci/oci-client.d.ts +4 -0
  70. package/dist/transports/oci/oci-client.d.ts.map +1 -1
  71. package/dist/transports/oci/oci-client.js +7 -1
  72. package/dist/transports/oci/oci-client.js.map +1 -1
  73. package/dist/transports/oci/oci-transport.d.ts +8 -3
  74. package/dist/transports/oci/oci-transport.d.ts.map +1 -1
  75. package/dist/transports/oci/oci-transport.js +67 -21
  76. package/dist/transports/oci/oci-transport.js.map +1 -1
  77. package/dist/transports/registry-transport.d.ts +3 -2
  78. package/dist/transports/registry-transport.d.ts.map +1 -1
  79. package/dist/transports/registry-transport.js +10 -0
  80. package/dist/transports/registry-transport.js.map +1 -1
  81. package/dist/transports/transport.d.ts +25 -6
  82. package/dist/transports/transport.d.ts.map +1 -1
  83. package/dist/zone-context.d.ts +16 -1
  84. package/dist/zone-context.d.ts.map +1 -1
  85. package/dist/zone-context.js +61 -1
  86. package/dist/zone-context.js.map +1 -1
  87. package/package.json +3 -3
  88. package/src/bundle/module-artifact.ts +25 -8
  89. package/src/bundle/tar.ts +28 -1
  90. package/src/controller-loader.ts +114 -80
  91. package/src/controller-loaders/bundle-loader.ts +10 -2
  92. package/src/controller-loaders/napi-loader.ts +6 -0
  93. package/src/controller-loaders/npm-loader.ts +14 -4
  94. package/src/controller-loaders/source-bundle-builder.ts +6 -0
  95. package/src/controllers/resource-definition/resource-definition-controller.ts +7 -3
  96. package/src/controllers/resource-definition/resource-template-controller.ts +12 -5
  97. package/src/dependency-injection.ts +7 -7
  98. package/src/evaluation-context.ts +195 -15
  99. package/src/index.ts +6 -0
  100. package/src/instance-declaration.ts +36 -0
  101. package/src/invocation-contract-binding.ts +49 -1
  102. package/src/kernel.ts +77 -2
  103. package/src/manifest-schemas.ts +1 -0
  104. package/src/module-context.ts +24 -3
  105. package/src/resource-context.ts +18 -0
  106. package/src/transports/oci/oci-client.ts +8 -1
  107. package/src/transports/oci/oci-transport.ts +76 -22
  108. package/src/transports/registry-transport.ts +14 -0
  109. package/src/transports/transport.ts +26 -5
  110. package/src/zone-context.ts +70 -1
package/src/kernel.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  type ContractValidatorFactory,
18
18
  resolveBoundContract,
19
19
  } from "./invocation-contract-binding.js";
20
+ import { readProjectionRef, type ProjectionScope } from "@telorun/analyzer";
20
21
  import {
21
22
  ControllerContext,
22
23
  ControllerPolicy,
@@ -26,6 +27,7 @@ import {
26
27
  ResourceDefinition,
27
28
  ResourceInstance,
28
29
  ResourceManifest,
30
+ stampRefIdentity,
29
31
  RuntimeError,
30
32
  RuntimeEvent,
31
33
  type BootTarget,
@@ -49,6 +51,7 @@ import { ambientInvokeContext } from "./evaluation-context.js";
49
51
  import { ModuleContext } from "./module-context.js";
50
52
  import { ResourceContextImpl } from "./resource-context.js";
51
53
  import { mintResourceHandle } from "./resource-handle.js";
54
+ import { declarationOfInstance, recordInstanceDeclaration } from "./instance-declaration.js";
52
55
  import { nodeHostVersions } from "./host-versions.js";
53
56
  import { nodeCelHandlers } from "./cel-handlers.js";
54
57
  import { parseRef, seedInvokeSource } from "./invoke-dispatch.js";
@@ -1417,6 +1420,18 @@ export class Kernel implements IKernel {
1417
1420
  resolvedKind,
1418
1421
  (processedResource.metadata?.name as string | undefined) ?? "<unnamed>",
1419
1422
  );
1423
+ recordInstanceDeclaration(instance, processedResource);
1424
+ // Stamp the DECLARATION SITE here rather than at Phase-5 injection, because
1425
+ // this is the only point where the instance and the context that declared it
1426
+ // are both in hand: injection sees the consumer's context, not the target's,
1427
+ // so a module stamped there would be whoever referenced it. Without it a
1428
+ // durable step could not name its target anywhere the instance does not
1429
+ // exist — the whole reason `step()` takes an identity at all.
1430
+ const originPointer = originPointerOf(processedResource);
1431
+ stampRefIdentity(instance, resolvedKind, processedResource.metadata?.name ?? "<unnamed>", {
1432
+ ...(moduleCtx?.source ? { module: moduleCtx.source } : {}),
1433
+ ...(originPointer === undefined ? {} : { pointer: originPointer }),
1434
+ });
1420
1435
  (ctx as ResourceContextImpl).bindResourceIdentity(handle, resolvedKind, processedResource);
1421
1436
 
1422
1437
  // Bind the resolved invocation contract to the instance, here at the kernel's
@@ -1491,8 +1506,43 @@ export class Kernel implements IKernel {
1491
1506
  const resolveDef: DefResolver = (kind, from) =>
1492
1507
  this.registry.resolveDefinitionIn(kind, from?.metadata?.module);
1493
1508
 
1494
- const input = resolveBoundContract("inputType", resource, definition, resolveDef, factory);
1495
- const output = resolveBoundContract("outputType", resource, definition, resolveDef, factory);
1509
+ // A DECLARATION-derived slot is resolved against the context that OWNS this
1510
+ // resource, so a bare name means the same thing here as it does to `!ref`
1511
+ // and to CEL — scope-local first, enclosing module as the fallback — and an
1512
+ // alias routes into that import's exported instances.
1513
+ const projections: ProjectionScope = {
1514
+ resolveDefinition: (kind) =>
1515
+ this.controllers.getDefinition(kind) as unknown as Record<string, any> | undefined,
1516
+ // The slot holds the LIVE INSTANCE by now — Phase-5 injection runs before
1517
+ // create — so the declaration is recovered by instance identity. The ref
1518
+ // shape is still accepted, because a ref slot on a `with:`-scoped resource
1519
+ // is not an injection site and reaches the controller unresolved.
1520
+ resolveManifest: (value) => {
1521
+ const injected = declarationOfInstance(value);
1522
+ if (injected) return { manifest: injected as unknown as Record<string, any> };
1523
+ const ref = readProjectionRef(value);
1524
+ if (!ref) return undefined;
1525
+ const found = impl.resolveDeclaredManifest(ref.name, ref.alias);
1526
+ return found ? { manifest: found as unknown as Record<string, any> } : undefined;
1527
+ },
1528
+ };
1529
+
1530
+ const input = resolveBoundContract(
1531
+ "inputType",
1532
+ resource,
1533
+ definition,
1534
+ resolveDef,
1535
+ factory,
1536
+ projections,
1537
+ );
1538
+ const output = resolveBoundContract(
1539
+ "outputType",
1540
+ resource,
1541
+ definition,
1542
+ resolveDef,
1543
+ factory,
1544
+ projections,
1545
+ );
1496
1546
  if (!input && !output) return;
1497
1547
 
1498
1548
  bindContract(instance, {
@@ -1576,3 +1626,28 @@ export class Kernel implements IKernel {
1576
1626
  );
1577
1627
  }
1578
1628
  }
1629
+
1630
+ /**
1631
+ * The JSON pointer to an inline declaration, from the origin the loader stamped.
1632
+ *
1633
+ * `xTeloOrigin.pathFromParent` is a dotted path with `[N]` indices — the loader's
1634
+ * own spelling, which addresses the manifest tree and nothing else. A pointer is
1635
+ * what crosses a boundary, so the conversion happens here rather than being left
1636
+ * to each recipient to reinvent: segments are identifiers and indices, so the
1637
+ * rewrite is mechanical and total.
1638
+ */
1639
+ function originPointerOf(resource: ResourceManifest): string | undefined {
1640
+ const origin = resource.metadata?.xTeloOrigin as
1641
+ | { parentName?: unknown; pathFromParent?: unknown }
1642
+ | undefined;
1643
+ if (typeof origin?.parentName !== "string" || typeof origin.pathFromParent !== "string") {
1644
+ return undefined;
1645
+ }
1646
+ const pointer = origin.pathFromParent
1647
+ .replace(/\[(\d+)\]/g, ".$1")
1648
+ .split(".")
1649
+ .filter((segment) => segment.length > 0)
1650
+ .map((segment) => segment.replace(/~/g, "~0").replace(/\//g, "~1"))
1651
+ .join("/");
1652
+ return `${origin.parentName}#/${pointer}`;
1653
+ }
@@ -22,6 +22,7 @@ export {
22
22
  manifestFragmentRef,
23
23
  ResourceRefSchema,
24
24
  RetryPolicySchema,
25
+ StepSchema,
25
26
  withSchemaFragments,
26
27
  } from "@telorun/analyzer";
27
28
 
@@ -15,6 +15,7 @@ import type {
15
15
  InvokeStepContext,
16
16
  ModuleContext as IModuleContext,
17
17
  ResourceInstance,
18
+ ResourceManifest,
18
19
  } from "@telorun/sdk";
19
20
  import type { EmitEvent, InstanceFactory } from "@telorun/sdk";
20
21
  import { EvaluationContext } from "./evaluation-context.js";
@@ -106,7 +107,11 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
106
107
  names: Set<string>;
107
108
  terminal: (
108
109
  name: string,
109
- ) => (() => { kind: string; instance: ResourceInstance } | undefined) | undefined;
110
+ ) =>
111
+ | (() =>
112
+ | { kind: string; instance: ResourceInstance; manifest?: ResourceManifest }
113
+ | undefined)
114
+ | undefined;
110
115
  }
111
116
  >();
112
117
 
@@ -292,7 +297,11 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
292
297
  names: string[],
293
298
  terminal: (
294
299
  name: string,
295
- ) => (() => { kind: string; instance: ResourceInstance } | undefined) | undefined,
300
+ ) =>
301
+ | (() =>
302
+ | { kind: string; instance: ResourceInstance; manifest?: ResourceManifest }
303
+ | undefined)
304
+ | undefined,
296
305
  ): void {
297
306
  this.importedScopes.set(alias, { names: new Set(names), terminal });
298
307
  }
@@ -369,7 +378,11 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
369
378
  const kind = rawKind.startsWith("Self.")
370
379
  ? `${moduleName}.${rawKind.slice("Self.".length)}`
371
380
  : rawKind;
372
- return { kind, instance: inst.instance };
381
+ // The manifest rides along because a DECLARATION-derived contract
382
+ // (`x-telo-schema-projection-from`) reads the target's own declaration,
383
+ // and a re-export forwards this getter verbatim — so carrying it here
384
+ // makes it reachable at any depth with no second table.
385
+ return { kind, instance: inst.instance, manifest: inst.resource };
373
386
  });
374
387
  }
375
388
  for (const k of kindEntries) {
@@ -407,6 +420,14 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
407
420
  return scope.terminal(name)?.()?.instance;
408
421
  }
409
422
 
423
+ /** The manifest half of `resolveImportedInstance` — what a name was DECLARED
424
+ * with, for a contract typed from a referenced declaration. */
425
+ override resolveImportedManifest(alias: string, name: string): ResourceManifest | undefined {
426
+ const scope = this.importedScopes.get(alias);
427
+ if (!scope || !scope.names.has(name)) return undefined;
428
+ return scope.terminal(name)?.()?.manifest;
429
+ }
430
+
410
431
  /** Like `resolveImportedInstance`, but returns the `{kind, name}` ref (canonical kind)
411
432
  * for controllers that resolve step/handler invokes to refs rather than live instances
412
433
  * (e.g. Run.Sequence via `ensureKindRef`). The alias is reattached by the caller. */
@@ -26,6 +26,7 @@ import {
26
26
  type ResourceHandle,
27
27
  type RuntimeSeam,
28
28
  type TypeRule,
29
+ type OpenZoneAttributes,
29
30
  type ZoneEntry,
30
31
  } from "@telorun/sdk";
31
32
  import { registerTeloKeywords } from "@telorun/analyzer";
@@ -426,6 +427,10 @@ export class ResourceContextImpl implements ResourceContext {
426
427
  return this.zoneContext().zonesFor(instance, ctx);
427
428
  }
428
429
 
430
+ zoneAttributes(ctx?: InvokeContext): readonly OpenZoneAttributes[] {
431
+ return this.zoneContext().zoneAttributes(ctx);
432
+ }
433
+
429
434
  /** The root context for runtime-driven inbound work — inherits nothing from
430
435
  * whatever ambient happens to be live at the registration site. */
431
436
  rootContext(opts?: { cancellation?: CancellationSource }): InvokeContext {
@@ -514,6 +519,19 @@ export class ResourceContextImpl implements ResourceContext {
514
519
  return this.moduleContext.resolveImportedInstance(alias, name);
515
520
  }
516
521
 
522
+ /**
523
+ * The manifest a name was DECLARED with, resolved in the context that OWNS
524
+ * this resource — scope-local first, enclosing module as the fallback, an
525
+ * alias routing into that import's exports. The same order `resolveRef` and
526
+ * the CEL `resources` layering use, so a declaration lookup cannot disagree
527
+ * with them about what a name means.
528
+ *
529
+ * Used to resolve a DECLARATION-derived contract slot at bind time.
530
+ */
531
+ resolveDeclaredManifest(name: string, alias?: string): ResourceManifest | undefined {
532
+ return this.owningContext.resolveDeclaredManifest?.(name, alias);
533
+ }
534
+
517
535
  resolveRef<T>(
518
536
  value: unknown,
519
537
  guard: (candidate: unknown) => candidate is T,
@@ -71,6 +71,13 @@ export interface OciManifest {
71
71
  annotations?: Record<string, string>;
72
72
  }
73
73
 
74
+ /** A blob's OCI digest, as `pushBlob` addresses it. Exported because the
75
+ * `layers:` index has to name a layer's digest BEFORE it is pushed — a second
76
+ * spelling of this would be a second answer to what a blob is called. */
77
+ export function blobDigest(bytes: Uint8Array): string {
78
+ return `sha256:${sha256Hex(bytes)}`;
79
+ }
80
+
74
81
  function sha256Hex(bytes: Uint8Array): string {
75
82
  return createHash("sha256").update(bytes).digest("hex");
76
83
  }
@@ -270,7 +277,7 @@ export class OciClient {
270
277
  * `sha256:<hex>` digest. Two-step upload: obtain a session, then PUT with
271
278
  * the digest. */
272
279
  async pushBlob(bytes: Uint8Array): Promise<string> {
273
- const digest = `sha256:${sha256Hex(bytes)}`;
280
+ const digest = blobDigest(bytes);
274
281
 
275
282
  const head = await this.authedFetch(
276
283
  `${this.base()}/blobs/${digest}`,
@@ -5,25 +5,25 @@ import {
5
5
  sha256Base64Url,
6
6
  verifyIntegrity,
7
7
  type ArtifactLayer,
8
+ type ArtifactSelector,
8
9
  type ManifestCacheCoords,
9
10
  type ManifestSource,
10
11
  } from "@telorun/analyzer";
11
- import { createHash } from "node:crypto";
12
-
13
12
  import {
14
13
  computeFilesIntegrity,
15
- injectLayerIndex,
16
14
  type PayloadFile,
17
15
  } from "../../bundle/files-integrity.js";
18
16
  import { readOwnerManifest, type OwnerManifest } from "../../bundle/module-manifest.js";
19
17
  import { makeTarGz, readTarGz, toPayloadFiles } from "../../bundle/tar.js";
20
18
  import type {
19
+ PayloadLayer,
21
20
  PublishBundle,
22
21
  PublishOptions,
23
22
  PublishResult,
24
23
  Transport,
25
24
  } from "../transport.js";
26
25
  import {
26
+ blobDigest,
27
27
  OciClient,
28
28
  OCI_MANIFEST_MEDIA_TYPE,
29
29
  TELO_LAYER_ROLE_ANNOTATION,
@@ -42,6 +42,21 @@ import {
42
42
  withRefVersion,
43
43
  } from "./oci-ref.js";
44
44
 
45
+ /** A layer's identity within one artifact: role plus, for a controller layer,
46
+ * its selector. Role alone is not it — there is one controller layer per
47
+ * selector. Same shape the release ledger keys on. */
48
+ function layerKey(role: string, selector?: ArtifactSelector): string {
49
+ return selector ? `${role}/${selectorKey(selector)}` : role;
50
+ }
51
+
52
+ /** The `layers:` index the bundle's own manifest declares, keyed for lookup.
53
+ * Read back out of the text rather than passed alongside it, because the text
54
+ * is what ships — anything else would be a second copy to keep in agreement. */
55
+ function declaredLayerIndex(bundle: PublishBundle): Map<string, ArtifactLayer> {
56
+ const declared = readOwnerManifest(bundle.manifest).layers ?? [];
57
+ return new Map(declared.map((layer) => [layerKey(layer.role, layer.selector), layer]));
58
+ }
59
+
45
60
  /**
46
61
  * Pull only the **manifest layer** and return its verified `telo.yaml` text.
47
62
  *
@@ -211,17 +226,17 @@ export class OciTransport implements Transport {
211
226
  * republish that reorders layers is simply invisible here rather than a
212
227
  * failure. Content verification is the artifact handle's, which holds the
213
228
  * expected `integrity`. */
214
- async fetchLayer(ref: string, blobDigest: string): Promise<PayloadFile[]> {
229
+ async fetchLayer(ref: string, digest: string): Promise<PayloadFile[]> {
215
230
  const { host, repo } = parseOciRef(ref);
216
- const tar = await this.readClient(host, repo).pullBlob(blobDigest);
231
+ const tar = await this.readClient(host, repo).pullBlob(digest);
217
232
  // Verify the transfer against the digest that addressed it. A registry is
218
233
  // not trusted to return the blob that was asked for, and this is the only
219
234
  // place the pushed bytes exist — the content digest checked after extraction
220
235
  // covers the file set, not the archive that carried it.
221
- const actual = `sha256:${createHash("sha256").update(tar).digest("hex")}`;
222
- if (actual !== blobDigest) {
236
+ const actual = blobDigest(tar);
237
+ if (actual !== digest) {
223
238
  throw new IntegrityError(
224
- `Blob digest mismatch fetching a layer of ${ref}: requested ${blobDigest}, ` +
239
+ `Blob digest mismatch fetching a layer of ${ref}: requested ${digest}, ` +
225
240
  `received ${actual}. The registry returned different bytes than were addressed.`,
226
241
  );
227
242
  }
@@ -258,6 +273,27 @@ export class OciTransport implements Transport {
258
273
  return Object.fromEntries(mapped.filter((e): e is [string, string] => Boolean(e[1])));
259
274
  }
260
275
 
276
+ /** The index as it will be published: `blob` over the gzipped tar this
277
+ * transport pushes, `integrity` over the layer's file contents. Both are
278
+ * computed from the files alone, which is what lets the payload builder
279
+ * write the index into `telo.yaml` before a single byte is pushed. */
280
+ async layerIndex(layers: readonly PayloadLayer[]): Promise<ArtifactLayer[]> {
281
+ const index: ArtifactLayer[] = [];
282
+ for (const layer of layers) {
283
+ if (layer.files.length === 0) continue;
284
+ const tar = await makeTarGz(
285
+ layer.files.map((f) => ({ name: f.name, content: Buffer.from(f.content) })),
286
+ );
287
+ index.push({
288
+ role: layer.role,
289
+ ...(layer.selector ? { selector: layer.selector } : {}),
290
+ blob: blobDigest(tar),
291
+ integrity: await computeFilesIntegrity(layer.files),
292
+ });
293
+ }
294
+ return index;
295
+ }
296
+
261
297
  async publish(
262
298
  destination: string,
263
299
  bundle: PublishBundle,
@@ -285,17 +321,42 @@ export class OciTransport implements Transport {
285
321
  const tag = identity.version;
286
322
  const client = new OciClient(host, repo);
287
323
 
288
- // Push every payload layer first, collecting the digests that address them.
289
- // This ordering is what keeps the index non-circular: the manifest layer is
290
- // pushed last and names only the layers pushed before it, never itself.
324
+ // Push every payload layer first. This ordering is what keeps the index
325
+ // non-circular: the manifest layer is pushed last and names only the layers
326
+ // pushed before it, never itself.
327
+ //
328
+ // The index is NOT written here — `bundle.manifest` already carries it, and
329
+ // rewriting the manifest at this point is precisely the bug this replaced:
330
+ // the bytes a dependent hashed to derive its pin would then never be the
331
+ // bytes pushed. So each pushed blob is CHECKED against what the manifest
332
+ // already claims, which is also the standing test that framing stayed
333
+ // deterministic — a claim nobody can satisfy is a hard failure, not a
334
+ // silently corrected index.
335
+ const declared = declaredLayerIndex(bundle);
291
336
  const payloadDescriptors: OciDescriptor[] = [];
292
- const index: ArtifactLayer[] = [];
293
337
  for (const layer of bundle.layers) {
294
338
  if (layer.files.length === 0) continue;
339
+ const key = layerKey(layer.role, layer.selector);
295
340
  const tar = await makeTarGz(
296
341
  layer.files.map((f) => ({ name: f.name, content: Buffer.from(f.content) })),
297
342
  );
298
343
  const blob = await client.pushBlob(tar);
344
+ const claim = declared.get(key);
345
+ if (!claim) {
346
+ throw new Error(
347
+ `layer '${key}' is being pushed but the manifest's 'layers:' index does not name it. ` +
348
+ `The index is written by the payload builder before publish, so a layer missing ` +
349
+ `from it would be unaddressable by any importer.`,
350
+ );
351
+ }
352
+ if (claim.blob !== blob) {
353
+ throw new Error(
354
+ `layer '${key}' framed to ${blob}, but the manifest's 'layers:' index claims ` +
355
+ `${claim.blob}. The published telo.yaml is hashed into every dependent's import ` +
356
+ `pin, so it cannot be corrected here — the archive framing must be a pure ` +
357
+ `function of the layer's files.`,
358
+ );
359
+ }
299
360
  payloadDescriptors.push({
300
361
  mediaType: TELO_PAYLOAD_LAYER_MEDIA_TYPE,
301
362
  digest: blob,
@@ -307,18 +368,11 @@ export class OciTransport implements Transport {
307
368
  : {}),
308
369
  },
309
370
  });
310
- index.push({
311
- role: layer.role,
312
- ...(layer.selector ? { selector: layer.selector } : {}),
313
- blob,
314
- integrity: await computeFilesIntegrity(layer.files),
315
- });
316
371
  }
317
372
 
318
- // Inject the index, then push telo.yaml as its own layer so a manifest read
319
- // never has to pull a payload.
320
- const manifestText =
321
- index.length > 0 ? injectLayerIndex(bundle.manifest, index) : bundle.manifest;
373
+ // Push telo.yaml as its own layer so a manifest read never has to pull a
374
+ // payload. Verbatim these are the bytes importers pin.
375
+ const manifestText = bundle.manifest;
322
376
  const manifestTar = await makeTarGz([
323
377
  { name: DEFAULT_MANIFEST_FILENAME, content: manifestText },
324
378
  ]);
@@ -8,6 +8,7 @@ import {
8
8
  sha256Base64Url,
9
9
  withRefVersion,
10
10
  splitIntegrity,
11
+ type ArtifactLayer,
11
12
  type ManifestCacheCoords,
12
13
  type ManifestSource,
13
14
  } from "@telorun/analyzer";
@@ -17,6 +18,7 @@ import { createHash } from "crypto";
17
18
  import type { PayloadFile } from "../bundle/files-integrity.js";
18
19
  import { assertPublicEgress } from "./egress-guard.js";
19
20
  import type {
21
+ PayloadLayer,
20
22
  PublishBundle,
21
23
  PublishOptions,
22
24
  PublishResult,
@@ -279,6 +281,18 @@ export class RegistryTransport implements Transport {
279
281
  );
280
282
  }
281
283
 
284
+ async layerIndex(layers: readonly PayloadLayer[]): Promise<ArtifactLayer[]> {
285
+ // Same boundary `fetchLayer` and `publish` draw: the HTTP registry serves
286
+ // manifests only, so it frames no layer and can name no blob. An empty set
287
+ // is not a payload, so it answers rather than throws — a manifest-only
288
+ // module builds its payload through this transport during analysis.
289
+ if (layers.every((layer) => layer.files.length === 0)) return [];
290
+ throw new Error(
291
+ "The Telo registry serves manifests only, so it cannot index payload layers. " +
292
+ "A module with a bundled payload is published as an OCI artifact (oci://host/repo).",
293
+ );
294
+ }
295
+
282
296
  async publish(
283
297
  destination: string,
284
298
  bundle: PublishBundle,
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ ArtifactLayer,
2
3
  ArtifactSelector,
3
4
  LayerRole,
4
5
  ManifestCacheCoords,
@@ -19,10 +20,10 @@ export interface PayloadLayer {
19
20
 
20
21
  /** The module bundle handed to a transport for publishing: the final,
21
22
  * already-analyzed / pinned / canonicalized `telo.yaml` bytes plus the payload
22
- * partitioned into layers (empty for a manifest-only module). The transport
23
- * pushes each layer as its own blob, injects the resulting `layers:` index into
24
- * the manifest, and only then pushes the manifest layer the order that keeps
25
- * the index non-circular. */
23
+ * partitioned into layers (empty for a manifest-only module), whose `layers:`
24
+ * index the manifest already carries. The transport pushes each layer as its
25
+ * own blob, checks the digest against what the index claims, and only then
26
+ * pushes the manifest layer — the order that keeps the index non-circular. */
26
27
  export interface PublishBundle {
27
28
  manifest: string;
28
29
  layers: PayloadLayer[];
@@ -151,9 +152,29 @@ export interface Transport {
151
152
  * verifies. */
152
153
  manifestHash(ref: string): Promise<string>;
153
154
 
155
+ /** The `layers:` index `layers` will be published under: each entry's
156
+ * `integrity` over the layer's file contents and its `blob` over the exact
157
+ * bytes this transport will push.
158
+ *
159
+ * On the interface, and called BEFORE anything is pushed, because the index
160
+ * goes *inside* `telo.yaml` and a dependent's import pin is a hash of that
161
+ * file. Injecting the index at push time — which is what this replaced —
162
+ * meant the payload builder's manifest was never the published one, so every
163
+ * sibling pin derived from it named bytes the registry does not have. The
164
+ * builder therefore needs the index up front, and only the transport knows
165
+ * the framing a `blob` digest covers.
166
+ *
167
+ * Requires the framing to be a pure function of the files (see `makeTarGz`),
168
+ * since `publish` re-frames the same layers and hard-fails if a digest moved.
169
+ * Returns `[]` for an empty layer set; throws when this transport cannot
170
+ * publish payload layers at all. */
171
+ layerIndex(layers: readonly PayloadLayer[]): Promise<ArtifactLayer[]>;
172
+
154
173
  /** Push `bundle` to `destination` (a base ref / repo whose scheme this
155
174
  * transport owns), pinning the payload and writing the transport-native
156
- * artifact shape. Throws on failure. Used by `telo publish`. */
175
+ * artifact shape. `bundle.manifest` already carries the `layers:` index this
176
+ * transport returned from {@link layerIndex}; publish verifies rather than
177
+ * rewrites it. Throws on failure. Used by `telo publish`. */
157
178
  publish(
158
179
  destination: string,
159
180
  bundle: PublishBundle,
@@ -28,9 +28,16 @@ import {
28
28
  type ResourceDefinition,
29
29
  type ResourceHandle,
30
30
  type ResourceInstance,
31
+ type OpenZoneAttributes,
32
+ type ZoneAttributes,
31
33
  type ZoneEntry,
32
34
  } from "@telorun/sdk";
33
- import { readProvidesZone, readRequiresZone, type RequiresZoneSlot } from "@telorun/analyzer";
35
+ import {
36
+ effectiveAuthorSchema,
37
+ readProvidesZone,
38
+ readRequiresZone,
39
+ type RequiresZoneSlot,
40
+ } from "@telorun/analyzer";
34
41
  import { isRefSentinel } from "@telorun/templating";
35
42
  import { ambientInvokeContext, runWithAmbientContext } from "./evaluation-context.js";
36
43
  import { handleOfInstance } from "./resource-handle.js";
@@ -77,6 +84,10 @@ export class ZoneContext {
77
84
  readonly #requirements = new Map<string, ResolvedRequirement>();
78
85
  /** Slot → resolved correlation handle for a providing slot. */
79
86
  readonly #providers = new Map<string, { key?: ResourceHandle }>();
87
+ /** Providing kind → what the zone it provides declares about its contents.
88
+ * Keyed by kind rather than by slot because the question a caller asks is
89
+ * about an OPEN zone, which is reached by its entry's kind. */
90
+ readonly #attributes = new Map<string, ZoneAttributes>();
80
91
 
81
92
  constructor(host: ZoneHost) {
82
93
  this.#host = host;
@@ -132,6 +143,64 @@ export class ZoneContext {
132
143
  return out;
133
144
  }
134
145
 
146
+ zoneAttributes(ctx?: InvokeContext): readonly OpenZoneAttributes[] {
147
+ const zones = (ctx ?? ambientInvokeContext())?.zones;
148
+ if (!zones || zones.length === 0) return [];
149
+ const out: OpenZoneAttributes[] = [];
150
+ // Innermost first, matching `match` and `zonesFor` — a nested zone's
151
+ // constraint is the one a controller meets first.
152
+ for (let i = zones.length - 1; i >= 0; i--) {
153
+ const entry = zones[i]!;
154
+ out.push({ kind: entry.kind, attributes: this.attributesOfKind(entry.kind), entry });
155
+ }
156
+ return out;
157
+ }
158
+
159
+ /**
160
+ * What the zone a kind provides declares, read off that kind's own schema.
161
+ *
162
+ * NOT off the entry: a `ZoneEntry` is three identities precisely so it stays
163
+ * ABI-serializable and no module can read another's private state off the
164
+ * stack, and an attributes field on it would trade that away for every zone.
165
+ * The kernel is the one place the declaring kind's schema is already
166
+ * reachable, so it resolves and hands the values over — branching on no name,
167
+ * exactly as `readRefSlot` returns `use` without acting on it.
168
+ *
169
+ * Memoized per kind: this sits behind every dispatch a durable step engine
170
+ * makes, and the answer is fixed once definitions are registered.
171
+ */
172
+ private attributesOfKind(kind: string): ZoneAttributes {
173
+ const cached = this.#attributes.get(kind);
174
+ if (cached) return cached;
175
+ const def = this.#host.resolveDefinition(kind);
176
+ // A kind that does not resolve is NOT cached: definitions are registered
177
+ // during init and this is called at dispatch, so an unresolved lookup is a
178
+ // transient state — caching the empty answer would make it permanent, and a
179
+ // zone would silently declare nothing for the life of the process.
180
+ if (!def) return {};
181
+ // Resolved along `extends`, so a child that inherits its parent's
182
+ // zone-providing slot reports the attributes that slot declares. Reading the
183
+ // own schema alone would have a child's zone silently constrain nothing.
184
+ const properties = (effectiveAuthorSchema(def, (k) => this.#host.resolveDefinition(k)) as
185
+ | { properties?: Record<string, unknown> }
186
+ | undefined)?.properties;
187
+ const merged: Record<string, string> = {};
188
+ for (const slotSchema of Object.values(properties ?? {})) {
189
+ // Every providing slot of one kind provides that same kind's zone — the
190
+ // annotation never names a zone — so their attributes describe one region
191
+ // and merge. First declaration wins, so the reading is stable rather than
192
+ // dependent on property order.
193
+ for (const [name, reason] of Object.entries(
194
+ readProvidesZone(slotSchema as Record<string, any>)?.attributes ?? {},
195
+ )) {
196
+ if (!(name in merged) && typeof reason === "string") merged[name] = reason;
197
+ }
198
+ }
199
+ const attributes = merged as ZoneAttributes;
200
+ this.#attributes.set(kind, attributes);
201
+ return attributes;
202
+ }
203
+
135
204
  // ── resolution ────────────────────────────────────────────────────────────
136
205
 
137
206
  private resolveProvider(slot: string): { key?: ResourceHandle } {