@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
@@ -30,6 +30,11 @@ import {
30
30
  import { RuntimeError } from "@telorun/sdk";
31
31
  import { evalPathCovers } from "@telorun/analyzer";
32
32
  import { effectOwnerOf, executeReturnedChain } from "./effect-scope.js";
33
+ import {
34
+ REDACTED,
35
+ redactSensitive,
36
+ sensitivePathsOfInstance,
37
+ } from "./instance-sensitive-paths.js";
33
38
  import {
34
39
  classifyInitFailures,
35
40
  isDeferral,
@@ -82,6 +87,12 @@ const SCHEMA_AS_CONTRACT_KINDS = new Set(["Telo.Definition", "Telo.Abstract", "T
82
87
  function collectResourceRefs(resource: ResourceManifest): ResourceRef[] {
83
88
  const found = new Map<string, ResourceRef>();
84
89
  const skipSchema = SCHEMA_AS_CONTRACT_KINDS.has(resource.kind as string);
90
+ // A re-created resource is rebuilt from the manifest as REGISTERED, but
91
+ // Phase-5 injection mutated that object in place on the previous pass — so a
92
+ // ref slot may already hold a live instance, whose object graph is cyclic.
93
+ // The walk is a best-effort edge collection for failure attribution, so it
94
+ // stops at anything it has already seen rather than recursing forever.
95
+ const seen = new WeakSet<object>();
85
96
  const visit = (value: unknown): void => {
86
97
  if (isResolvedRef(value)) {
87
98
  const key = `${value.alias ?? ""}::${value.name}`;
@@ -89,8 +100,12 @@ function collectResourceRefs(resource: ResourceManifest): ResourceRef[] {
89
100
  return;
90
101
  }
91
102
  if (Array.isArray(value)) {
103
+ if (seen.has(value)) return;
104
+ seen.add(value);
92
105
  for (const item of value) visit(item);
93
106
  } else if (value && typeof value === "object") {
107
+ if (seen.has(value)) return;
108
+ seen.add(value);
94
109
  for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
95
110
  if (k === "metadata" || (skipSchema && k === "schema")) continue;
96
111
  visit(v);
@@ -132,6 +147,12 @@ function localDependencyNames(refs: ResourceRef[]): string[] {
132
147
  * whole-value match is redacted. */
133
148
  const MIN_SUBSTRING_SCRUB_LEN = 5;
134
149
 
150
+ /** A live resource instance, as opposed to the raw config value a slot held
151
+ * before Phase-5 injection — the same duck-test the reference-resolving
152
+ * controllers use. */
153
+ const hasSnapshotMethod = (value: unknown): boolean =>
154
+ !!value && typeof (value as { snapshot?: unknown }).snapshot === "function";
155
+
135
156
  export function buildResolvedProperties(
136
157
  resource: ResourceManifest,
137
158
  secretValues: Set<string>,
@@ -484,6 +505,10 @@ export class EvaluationContext implements IEvaluationContext {
484
505
  parent: IEvaluationContext | undefined = undefined;
485
506
  readonly children: IEvaluationContext[] = [];
486
507
 
508
+ /** Where this node sits in its parent's teardown cascade — ascending, default
509
+ * 0, reverse-registration within a tier. See `childTeardownOrder`. */
510
+ teardownPriority: number | undefined = undefined;
511
+
487
512
  /** Current lifecycle state of this context node. */
488
513
  state: LifecycleState = "Pending";
489
514
 
@@ -515,6 +540,84 @@ export class EvaluationContext implements IEvaluationContext {
515
540
  * Their re-creation is not progress — see the create sub-phase. */
516
541
  private readonly recreatedResources = new Set<string>();
517
542
 
543
+ /**
544
+ * Instances this context can reach by name but does NOT own — a library's
545
+ * declared `resources:` inputs, bound here by the import that handed them
546
+ * down.
547
+ *
548
+ * **Borrowed, not owned.** The instance's effect frame belongs to the scope
549
+ * that DECLARED it, so this context must never include it in its own
550
+ * teardown: a library tearing one down would close the application's
551
+ * connection out from under everything else still using it.
552
+ */
553
+ protected readonly borrowedResources = new Set<string>();
554
+
555
+ /**
556
+ * Bind an instance this context does not own under `name`, and mirror its
557
+ * published reading into this context's `resources` scope so
558
+ * `resources.<name>.<field>` reads here exactly as it does where the resource
559
+ * is declared.
560
+ *
561
+ * The mirror is a subscription rather than a copy because a published value is
562
+ * a READING: the owner republishes after `run()`, after every `invoke()` and
563
+ * on every `setStatus()`, and a snapshot taken once at binding would go stale
564
+ * at the first of those — silently, since nothing downstream can tell a stale
565
+ * reading from a current one.
566
+ */
567
+ adoptBorrowedResource(
568
+ name: string,
569
+ resource: ResourceManifest,
570
+ instance: ResourceInstance,
571
+ owner: EvaluationContext,
572
+ ): void | (() => void) {
573
+ this.resourceInstances.set(name, { resource, instance });
574
+ this.borrowedResources.add(name);
575
+ this.declaredManifests.set(name, resource);
576
+ const unmirror = owner.mirrorPublications(resource.metadata.name as string, (props) =>
577
+ this.onResourceSnapshotted(name, props),
578
+ );
579
+ // The INVERSE, returned rather than performed: an import whose `init()`
580
+ // fails is discarded and re-created on the next pass, so a subscription left
581
+ // behind would be appended again on every pass — unbounded — and would keep
582
+ // a dead child context reachable from the live owner. The binding is not on
583
+ // this context's teardown path either (`teardownOrder` filters a borrowed
584
+ // name out, and that is also the only place an entry is deleted), so
585
+ // undoing it has to be stated here.
586
+ return () => {
587
+ unmirror();
588
+ this.borrowedResources.delete(name);
589
+ this.resourceInstances.delete(name);
590
+ this.declaredManifests.delete(name);
591
+ };
592
+ }
593
+
594
+ /** Publication mirrors registered by {@link adoptBorrowedResource}, by the
595
+ * OWNER's name for the resource. */
596
+ private readonly publicationMirrors = new Map<
597
+ string,
598
+ Array<(props: Record<string, unknown>) => void>
599
+ >();
600
+
601
+ /** Register a mirror and replay the current reading, so a borrower that binds
602
+ * after the owner has already published does not wait for the next one.
603
+ * Returns the unsubscribe — a subscription with no way to end it outlives
604
+ * whatever registered it. */
605
+ mirrorPublications(name: string, sink: (props: Record<string, unknown>) => void): () => void {
606
+ const bucket = this.publicationMirrors.get(name);
607
+ if (bucket) bucket.push(sink);
608
+ else this.publicationMirrors.set(name, [sink]);
609
+ const entry = this.resourceInstances.get(name) ?? this.createdInstances.get(name);
610
+ const current = entry ? publishedByInstance.get(entry.instance) : undefined;
611
+ if (current) sink(current);
612
+ return () => {
613
+ const sinks = this.publicationMirrors.get(name);
614
+ if (!sinks) return;
615
+ const at = sinks.indexOf(sink);
616
+ if (at >= 0) sinks.splice(at, 1);
617
+ if (sinks.length === 0) this.publicationMirrors.delete(name);
618
+ };
619
+ }
620
+
518
621
  /** Resources queued for initialization on this context node. */
519
622
  private pendingResources: ResourceManifest[] = [];
520
623
 
@@ -660,6 +763,43 @@ export class EvaluationContext implements IEvaluationContext {
660
763
  return def?.status;
661
764
  }
662
765
 
766
+ /**
767
+ * The configured values a merge-form inheriting child publishes over the
768
+ * parent instance's reading, or undefined when the kind declares none.
769
+ *
770
+ * Such a child IS the parent instance — the inherited controller returns it
771
+ * verbatim — so `snapshot()` is the parent's and a field the parent never
772
+ * declared would be readable from nowhere. The field NAMES come from
773
+ * `publishedOwnFields`, stamped onto the definition at registration in the
774
+ * scope that declared the `extends` alias; resolving the chain here would
775
+ * resolve it in the READING module's scope, which may have no alias for the
776
+ * parent's library. Done here rather than by rebinding the instance's
777
+ * `snapshot()` because this is definition-derived data joined into a reading —
778
+ * exactly what `statusSchemaOf` already does — and a second kernel cannot
779
+ * rebind a trait method.
780
+ *
781
+ * Two values are withheld rather than published, both conservatively: a
782
+ * runtime-eval field is still a `CompiledValue` at this point (only compile
783
+ * paths are expanded at create), and a live instance is a collaborator rather
784
+ * than a reading. Publishing either would put a wrong-typed value in the CEL
785
+ * scope, which is the failure this overlay exists to remove.
786
+ */
787
+ private ownFieldOverlay(
788
+ kind: string,
789
+ resource: Record<string, unknown>,
790
+ ): Record<string, unknown> | undefined {
791
+ const def = this.getDefinition?.(this.resolveKindSafe(kind)) ?? this.getDefinition?.(kind);
792
+ const fields = (def as { publishedOwnFields?: string[] } | undefined)?.publishedOwnFields;
793
+ if (!fields?.length) return undefined;
794
+ let overlay: Record<string, unknown> | undefined;
795
+ for (const field of fields) {
796
+ const value = resource[field];
797
+ if (value === undefined || isCompiledValue(value) || hasSnapshotMethod(value)) continue;
798
+ (overlay ??= {})[field] = value;
799
+ }
800
+ return overlay;
801
+ }
802
+
663
803
  /**
664
804
  * Re-read a resource's `snapshot()` and republish it, joined with whatever
665
805
  * observed state the resource has reported. The single publication path — the
@@ -668,11 +808,14 @@ export class EvaluationContext implements IEvaluationContext {
668
808
  */
669
809
  async publishSnapshot(name: string): Promise<void> {
670
810
  const entry = this.resourceInstances.get(name) ?? this.createdInstances.get(name);
671
- if (!entry?.instance.snapshot) return;
672
- const snap = (await Promise.resolve(entry.instance.snapshot())) as
673
- | Record<string, unknown>
674
- | undefined;
811
+ if (!entry) return;
675
812
  const kind = entry.resource.kind as string;
813
+ const own = this.ownFieldOverlay(kind, entry.resource);
814
+ if (!entry.instance.snapshot && !own) return;
815
+ const snapshot = entry.instance.snapshot
816
+ ? ((await Promise.resolve(entry.instance.snapshot())) as Record<string, unknown> | undefined)
817
+ : undefined;
818
+ const snap = own ? { ...(snapshot ?? {}), ...own } : snapshot;
676
819
  const props = buildPublishedProps(snap, {
677
820
  kind,
678
821
  name,
@@ -684,6 +827,7 @@ export class EvaluationContext implements IEvaluationContext {
684
827
  });
685
828
  publishedByInstance.set(entry.instance, props);
686
829
  this.onResourceSnapshotted(name, props);
830
+ for (const sink of this.publicationMirrors.get(name) ?? []) sink(props);
687
831
  }
688
832
 
689
833
  get context(): Record<string, unknown> {
@@ -801,6 +945,21 @@ export class EvaluationContext implements IEvaluationContext {
801
945
  return child;
802
946
  }
803
947
 
948
+ /**
949
+ * Bind a name into this context's own CEL scope.
950
+ *
951
+ * A copy is written rather than a mutation: `spawnChildContext` hands the
952
+ * child the PARENT's context object by reference, so mutating it in place
953
+ * would put the binding in the parent's scope as well.
954
+ *
955
+ * Used by a template to put `self` in scope for the body it creates, so a node
956
+ * the nested kind evaluates later can read the enclosing resource's
957
+ * configuration beside the call-time names that kind binds.
958
+ */
959
+ bindContextValue(name: string, value: unknown): void {
960
+ this._context = { ...this._context, [name]: value };
961
+ }
962
+
804
963
  /** Spawn a fresh child context attached to this node — the isolated scope a
805
964
  * templated definition registers its `resources:` into. Rooting it on the
806
965
  * context that DEFINED the template (not the consumer that instantiated the
@@ -1216,7 +1375,7 @@ export class EvaluationContext implements IEvaluationContext {
1216
1375
  this.state = "Draining";
1217
1376
  const failures: Array<{ resource: string; error: unknown }> = [];
1218
1377
 
1219
- for (const child of [...this.children].reverse()) {
1378
+ for (const child of this.childTeardownOrder()) {
1220
1379
  try {
1221
1380
  await child.teardownResources();
1222
1381
  } catch (err) {
@@ -1294,6 +1453,28 @@ export class EvaluationContext implements IEvaluationContext {
1294
1453
  }
1295
1454
  }
1296
1455
 
1456
+ /**
1457
+ * Child contexts in teardown order: ascending `teardownPriority` (default 0),
1458
+ * with the base reverse-registration order preserved within each tier.
1459
+ *
1460
+ * The same rule `teardownOrder` applies to resource instances, and for the
1461
+ * same reason: reverse registration is reverse init order in the happy path,
1462
+ * but a node that must reliably outlive the rest has to say so rather than
1463
+ * depend on when it happened to be created. A `lifecycle: shared` library is
1464
+ * registered when the FIRST import reaches it — which, for an import declared
1465
+ * inside another library, is after that library's own context — so reverse
1466
+ * registration would tear the singleton down while a borrower still holds it.
1467
+ */
1468
+ private childTeardownOrder(): IEvaluationContext[] {
1469
+ return [...this.children]
1470
+ .reverse()
1471
+ .sort(
1472
+ (a, b) =>
1473
+ ((a as { teardownPriority?: number }).teardownPriority ?? 0) -
1474
+ ((b as { teardownPriority?: number }).teardownPriority ?? 0),
1475
+ );
1476
+ }
1477
+
1297
1478
  /**
1298
1479
  * Resource instances in teardown order: ascending `teardownPriority`, with the
1299
1480
  * base reverse-insertion order preserved within each priority tier.
@@ -1308,7 +1489,11 @@ export class EvaluationContext implements IEvaluationContext {
1308
1489
  * instance shape.
1309
1490
  */
1310
1491
  private teardownOrder(): Array<[string, { resource: any; instance: any }]> {
1311
- const entries = [...this.resourceInstances.entries()].reverse();
1492
+ // A borrowed instance is torn down by the scope that declared it, never
1493
+ // here — see `borrowedResources`.
1494
+ const entries = [...this.resourceInstances.entries()]
1495
+ .filter(([name]) => !this.borrowedResources.has(name))
1496
+ .reverse();
1312
1497
  // Stable sort by priority (default 0); Array.prototype.sort is stable, so
1313
1498
  // the reverse-insertion order survives within each tier.
1314
1499
  return entries.sort(
@@ -1472,6 +1657,30 @@ export class EvaluationContext implements IEvaluationContext {
1472
1657
  : undefined;
1473
1658
  // Capture the root CEL scope once, on the trace's root span's terminal event.
1474
1659
  const rootScope = tracing && parentInvocationId === undefined ? this.traceRootScope() : undefined;
1660
+ // What a payload may say about this call. A credential is a dispatched
1661
+ // invocable, so its material is an invoke OUTPUT — and inputs and outputs
1662
+ // ride the debug wire on every call under `--inspect`, i.e. every watch
1663
+ // session, which the substring scrubbing does not reach (one call site, the
1664
+ // resource-Created event's properties). The kind that owns the contract
1665
+ // marks the field; nothing here knows which kind that is.
1666
+ const hide = (detail: Record<string, unknown>): Record<string, unknown> => {
1667
+ let out = detail;
1668
+ for (const direction of ["inputType", "outputType"] as const) {
1669
+ const key = direction === "inputType" ? "inputs" : "outputs";
1670
+ if (!(key in out)) continue;
1671
+ const paths = sensitivePathsOfInstance(instance, direction);
1672
+ // Unknown, because the contract would not resolve — withhold the whole
1673
+ // value rather than guess. The dispatch is about to raise that same
1674
+ // failure with its own code.
1675
+ if (paths === undefined) {
1676
+ out = { ...out, [key]: REDACTED };
1677
+ continue;
1678
+ }
1679
+ if (paths.length === 0) continue;
1680
+ out = { ...out, [key]: redactSensitive(out[key], paths) };
1681
+ }
1682
+ return out;
1683
+ };
1475
1684
  const span = (
1476
1685
  phase: "start" | "end",
1477
1686
  outcome: SpanOutcome | undefined,
@@ -1486,7 +1695,7 @@ export class EvaluationContext implements IEvaluationContext {
1486
1695
  "invoke",
1487
1696
  phase,
1488
1697
  outcome,
1489
- phase === "end" && rootScope ? { ...detail, context: rootScope } : detail,
1698
+ phase === "end" && rootScope ? { ...hide(detail), context: rootScope } : hide(detail),
1490
1699
  );
1491
1700
  // When tracing, a derived context carries the new id down the tree so nested
1492
1701
  // invokes read it as their parent; it is never `=== ambient`, so the call
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Instance → the contract paths its kind marked `x-telo-sensitive`, recorded at
3
+ * `create()` beside the handle and the declaration.
4
+ *
5
+ * The trace site has only the instance. It cannot re-derive this: the resolved
6
+ * contract is compiled inside `bindContract`'s closure and dropped, the
7
+ * definition carries the DECLARATION rather than the resolved schema, and an
8
+ * instance-manifest override would be missed by re-resolving from the kind. So
9
+ * the answer is recorded where both halves are in hand, exactly as
10
+ * `instance-declaration.ts` records the other direction.
11
+ *
12
+ * TWO entries per instance, kept apart by direction, because a contract may mark
13
+ * a field on the way in as well as on the way out — `forceRefresh` going to a
14
+ * credential is not sensitive, but a signing key handed to one would be, and a
15
+ * single merged list would redact an input path in an output payload where it
16
+ * names something else entirely.
17
+ *
18
+ * Weak and one-way: paths are obtainable FROM an instance, never an instance
19
+ * from paths, so nothing here extends a lifetime or hands out live state.
20
+ * Resolution is LAZY — the paths are read through a thunk rather than eagerly,
21
+ * because compiling a contract at create time would make every contract-bearing
22
+ * kind depend on type-registration order, which is the reason the binding defers
23
+ * it in the first place.
24
+ */
25
+
26
+ export type ContractDirection = "inputType" | "outputType";
27
+
28
+ interface SensitiveThunks {
29
+ inputType?: () => string[][];
30
+ outputType?: () => string[][];
31
+ }
32
+
33
+ const sensitive = new WeakMap<object, SensitiveThunks>();
34
+
35
+ /** Record how to obtain one direction's sensitive paths for a live instance.
36
+ * First record wins, matching the handle and declaration rules: a `base:` child
37
+ * IS its parent instance, and the parent's binding is the one that produced
38
+ * it. */
39
+ export function recordSensitivePaths(
40
+ instance: object,
41
+ direction: ContractDirection,
42
+ paths: () => string[][],
43
+ ): void {
44
+ const entry = sensitive.get(instance) ?? {};
45
+ if (entry[direction] !== undefined) return;
46
+ entry[direction] = paths;
47
+ sensitive.set(instance, entry);
48
+ }
49
+
50
+ /**
51
+ * The paths one direction of a live instance's contract marked sensitive.
52
+ *
53
+ * An EMPTY list means the contract marked nothing, or the kind declares no
54
+ * contract at all — carry the payload verbatim. `undefined` means the contract
55
+ * could not be resolved, so WHICH fields are sensitive is unknown and the
56
+ * payload must be withheld whole.
57
+ *
58
+ * Nothing is swallowed by that: resolving is exactly what the dispatch about to
59
+ * follow does, so the same failure surfaces from it a moment later, with its own
60
+ * code and its own message. What the catch avoids is a trace site becoming the
61
+ * place an unrelated contract defect first appears — and, far worse, emitting
62
+ * auth material onto the wire because a schema failed to compile.
63
+ */
64
+ export function sensitivePathsOfInstance(
65
+ instance: unknown,
66
+ direction: ContractDirection,
67
+ ): string[][] | undefined {
68
+ if (!instance || typeof instance !== "object") return [];
69
+ const thunk = sensitive.get(instance as object)?.[direction];
70
+ if (!thunk) return [];
71
+ try {
72
+ return thunk();
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ }
77
+
78
+ /** What a hidden value reads as. The key is KEPT and only the value replaced,
79
+ * per the logging spec §14: a payload that silently loses a key reads as a
80
+ * value that was never produced. */
81
+ export const REDACTED = "[redacted]";
82
+
83
+ /**
84
+ * `value` with the marked paths replaced by {@link REDACTED}.
85
+ *
86
+ * Copy-on-write ALONG THE PATHS ONLY, so the caller's own object — the very
87
+ * object being handed to a controller, or the one it just returned — is never
88
+ * mutated. A whole-payload clone would be the obvious alternative and is wrong
89
+ * twice: it costs a deep copy per span on the dispatch path, and it would
90
+ * rewrite live values (a stream handle, a resource instance) that only survive
91
+ * by identity.
92
+ */
93
+ export function redactSensitive(value: unknown, paths: readonly string[][]): unknown {
94
+ if (paths.length === 0) return value;
95
+ let out = value;
96
+ for (const path of paths) out = redactAt(out, path, 0);
97
+ return out;
98
+ }
99
+
100
+ function redactAt(node: unknown, path: readonly string[], index: number): unknown {
101
+ if (node === null || node === undefined) return node;
102
+ if (index === path.length) return REDACTED;
103
+ const segment = path[index];
104
+ if (segment === "[]") {
105
+ return Array.isArray(node) ? node.map((element) => redactAt(element, path, index + 1)) : node;
106
+ }
107
+ // The map-value wildcard: every own key, whatever it is named. What
108
+ // `additionalProperties` / `patternProperties` emit, since neither carries
109
+ // property names to walk.
110
+ if (segment === "{}") {
111
+ if (typeof node !== "object" || Array.isArray(node)) return node;
112
+ const source = node as Record<string, unknown>;
113
+ const out: Record<string, unknown> = {};
114
+ for (const [key, value] of Object.entries(source)) out[key] = redactAt(value, path, index + 1);
115
+ return out;
116
+ }
117
+ if (typeof node !== "object" || Array.isArray(node)) return node;
118
+ const object = node as Record<string, unknown>;
119
+ // A path the value does not carry is not a defect — the contract describes
120
+ // what MAY be there, and an optional field is routinely absent.
121
+ if (!(segment in object)) return node;
122
+ return { ...object, [segment]: redactAt(object[segment], path, index + 1) };
123
+ }
@@ -5,6 +5,7 @@ import {
5
5
  type DeclaredScalarForm,
6
6
  type DeclaredScalarPath,
7
7
  defaultBearingPaths,
8
+ sensitivePaths,
8
9
  effectiveContractField,
9
10
  describeProjectionFailure,
10
11
  resolveSchemaProjections,
@@ -75,6 +76,9 @@ export interface BoundContract {
75
76
  * value is normalized at, in either direction. Empty when the contract
76
77
  * declares none. */
77
78
  scalarPaths(): DeclaredScalarPath[];
79
+ /** Paths the contract marked `x-telo-sensitive` — the values a trace payload
80
+ * carries as `[redacted]`. Empty when the contract marks none. */
81
+ sensitivePaths(): string[][];
78
82
  }
79
83
 
80
84
  const CONTRACT_ERROR: Record<ContractDirection, string> = {
@@ -149,6 +153,7 @@ export function resolveBoundContract(
149
153
  let compiled: { validate(value: unknown): void } | undefined;
150
154
  let paths: string[][] | undefined;
151
155
  let scalars: DeclaredScalarPath[] | undefined;
156
+ let sensitive: string[][] | undefined;
152
157
 
153
158
  const resolve = (): { validate(value: unknown): void } => {
154
159
  if (compiled !== undefined) return compiled;
@@ -193,6 +198,9 @@ export function resolveBoundContract(
193
198
  const stripped = withLiveValuesSkipped(projected, factory.resolveRef);
194
199
  paths = defaultBearingPaths(stripped, factory.resolveRef);
195
200
  scalars = declaredScalarPaths(stripped, factory.resolveRef);
201
+ // Read off the STRIPPED schema like the other two, so a marked node behind a
202
+ // live value is not reported: nothing walks into a stream to redact it.
203
+ sensitive = sensitivePaths(stripped, factory.resolveRef);
196
204
  // Compile by NAME whenever the declaration is one, so the type's CEL
197
205
  // `rules:` are composed in — including when a stream had to be stripped, in
198
206
  // which case the stream-bearing properties are dropped from the schema the
@@ -220,6 +228,10 @@ export function resolveBoundContract(
220
228
  resolve();
221
229
  return scalars ?? [];
222
230
  },
231
+ sensitivePaths: () => {
232
+ resolve();
233
+ return sensitive ?? [];
234
+ },
223
235
  };
224
236
  }
225
237
 
package/src/kernel.ts CHANGED
@@ -53,6 +53,7 @@ import { ResourceContextImpl } from "./resource-context.js";
53
53
  import { mintResourceHandle } from "./resource-handle.js";
54
54
  import { bindEffectOwner } from "./effect-scope.js";
55
55
  import { declarationOfInstance, recordInstanceDeclaration } from "./instance-declaration.js";
56
+ import { recordSensitivePaths } from "./instance-sensitive-paths.js";
56
57
  import { nodeHostVersions } from "./host-versions.js";
57
58
  import { nodeCelHandlers } from "./cel-handlers.js";
58
59
  import { parseRef, seedInvokeSource } from "./invoke-dispatch.js";
@@ -1479,6 +1480,26 @@ export class Kernel implements IKernel {
1479
1480
 
1480
1481
  if (!runtime.length) return { instance, ctx, resource: processedResource };
1481
1482
 
1483
+ // Runtime eval paths are expanded against a CALL's inputs, so they need a
1484
+ // call. `invoke` is the only entry point that takes any — `run()` and
1485
+ // `provide()` are parameterless — so a kind that declares one of these paths
1486
+ // and has no `invoke()` has annotated something nothing can ever expand: the
1487
+ // value stays a compiled expression for the life of the resource. Reported
1488
+ // here rather than dereferenced: this used to be a non-null assertion, and it
1489
+ // failed as `undefined is not an object (evaluating 'instance.invoke.bind')`
1490
+ // against the kernel's own source, naming neither the kind nor the field.
1491
+ // Method presence rather than declared capability, because the kernel
1492
+ // dispatches on the method — a Provider that implements `invoke` is bound
1493
+ // exactly like an Invocable.
1494
+ if (typeof instance.invoke !== "function") {
1495
+ throw new RuntimeError(
1496
+ "ERR_RUNTIME_EVAL_WITHOUT_INVOKE",
1497
+ `Kind ${resolvedKind} declares 'x-telo-eval: runtime' (at ${runtime.join(", ")}), but its resources have no invoke() — ` +
1498
+ `runtime evaluation expands a call's inputs, and run() / provide() take none. ` +
1499
+ `Use 'x-telo-eval: compile' for a value resolved once when the resource is created, or give the kind an invocable controller.`,
1500
+ );
1501
+ }
1502
+
1482
1503
  // Override invoke in-place so all lifecycle methods (init/invoke/teardown/snapshot)
1483
1504
  // share the same `this`. A wrapper object would split identity: state mutated by
1484
1505
  // init() on the wrapper would be invisible to the original invoke(), which still
@@ -1487,7 +1508,7 @@ export class Kernel implements IKernel {
1487
1508
  // Every argument is forwarded: `invoke(inputs, ctx)` carries the
1488
1509
  // InvokeContext (cancellation, tracing) as its second parameter, and a
1489
1510
  // wrapper that declares only `inputs` silently drops it.
1490
- const originalInvoke = instance.invoke!.bind(instance);
1511
+ const originalInvoke = instance.invoke.bind(instance);
1491
1512
  instance.invoke = async (inputs: any, ...rest: unknown[]) => {
1492
1513
  const expanded = evalContext.expandPaths(inputs as Record<string, unknown>, runtime);
1493
1514
  return (originalInvoke as (i: any, ...r: unknown[]) => Promise<unknown>)(expanded, ...rest);
@@ -1564,6 +1585,13 @@ export class Kernel implements IKernel {
1564
1585
  );
1565
1586
  if (!input && !output) return;
1566
1587
 
1588
+ // Recorded here because this is the only point holding both the instance and
1589
+ // its resolved contract — `bindContract` closes over the contract and drops
1590
+ // it, and the trace site has only the instance. Lazily, so a contract is
1591
+ // still compiled on first dispatch rather than at create time.
1592
+ if (input) recordSensitivePaths(instance, "inputType", () => input.sensitivePaths());
1593
+ if (output) recordSensitivePaths(instance, "outputType", () => output.sensitivePaths());
1594
+
1567
1595
  bindContract(instance, {
1568
1596
  input,
1569
1597
  output,
@@ -552,6 +552,12 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
552
552
  return `${realModule}.${suffix}`;
553
553
  }
554
554
 
555
+ /** A module context IS the alias table, so it answers the resolver seam
556
+ * directly rather than inheriting one from a parent it does not have. Public
557
+ * because a template body's nested kind is written in the DEFINING library's
558
+ * scope and its controller has to resolve it there. */
559
+ override kindResolver = (kind: string): string => this.resolveKindSafe(kind);
560
+
555
561
  protected override resolveKindSafe(kind: string): string {
556
562
  // `resolveKind` throws for unqualified / ungated kinds — an expected signal,
557
563
  // not a failure: a capability probe that can't resolve falls back to the raw