@intentius/chant 0.31.0 → 0.33.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 (63) hide show
  1. package/dist/cli/command-group.d.ts +134 -0
  2. package/dist/cli/command-group.d.ts.map +1 -0
  3. package/dist/cli/conflict-check.d.ts +1 -1
  4. package/dist/cli/conflict-check.d.ts.map +1 -1
  5. package/dist/cli/handlers/graph.d.ts.map +1 -1
  6. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  7. package/dist/cli/handlers/search.d.ts +58 -0
  8. package/dist/cli/handlers/search.d.ts.map +1 -0
  9. package/dist/cli/main.d.ts.map +1 -1
  10. package/dist/cli/registry.d.ts +4 -0
  11. package/dist/cli/registry.d.ts.map +1 -1
  12. package/dist/config.d.ts +3 -0
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/graph-declared.d.ts +20 -0
  15. package/dist/graph-declared.d.ts.map +1 -0
  16. package/dist/graph-effective.d.ts +25 -0
  17. package/dist/graph-effective.d.ts.map +1 -0
  18. package/dist/graph-ir.d.ts +17 -3
  19. package/dist/graph-ir.d.ts.map +1 -1
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/lexicon.d.ts +49 -0
  23. package/dist/lexicon.d.ts.map +1 -1
  24. package/dist/lifecycle/change-set.d.ts +15 -7
  25. package/dist/lifecycle/change-set.d.ts.map +1 -1
  26. package/dist/lifecycle/live-diff.d.ts +25 -1
  27. package/dist/lifecycle/live-diff.d.ts.map +1 -1
  28. package/dist/lifecycle/observe.d.ts +14 -6
  29. package/dist/lifecycle/observe.d.ts.map +1 -1
  30. package/dist/managed-fields.d.ts +118 -0
  31. package/dist/managed-fields.d.ts.map +1 -0
  32. package/dist/owner-chain.d.ts +99 -0
  33. package/dist/owner-chain.d.ts.map +1 -0
  34. package/package.json +1 -1
  35. package/src/cli/command-group.test.ts +208 -0
  36. package/src/cli/command-group.ts +199 -0
  37. package/src/cli/conflict-check.test.ts +36 -1
  38. package/src/cli/conflict-check.ts +22 -1
  39. package/src/cli/handlers/graph.test.ts +1 -1
  40. package/src/cli/handlers/graph.ts +33 -11
  41. package/src/cli/handlers/lifecycle.ts +5 -0
  42. package/src/cli/handlers/search.test.ts +113 -0
  43. package/src/cli/handlers/search.ts +263 -0
  44. package/src/cli/main.ts +114 -27
  45. package/src/cli/registry.ts +4 -0
  46. package/src/config.ts +3 -0
  47. package/src/graph-declared.ts +33 -0
  48. package/src/graph-effective.test.ts +97 -0
  49. package/src/graph-effective.ts +110 -0
  50. package/src/graph-ir-live.test.ts +40 -0
  51. package/src/graph-ir.ts +32 -7
  52. package/src/index.ts +1 -0
  53. package/src/lexicon.ts +50 -1
  54. package/src/lifecycle/change-set.test.ts +100 -0
  55. package/src/lifecycle/change-set.ts +39 -10
  56. package/src/lifecycle/live-diff.test.ts +88 -0
  57. package/src/lifecycle/live-diff.ts +55 -8
  58. package/src/lifecycle/observe.test.ts +66 -2
  59. package/src/lifecycle/observe.ts +79 -18
  60. package/src/managed-fields.test.ts +179 -0
  61. package/src/managed-fields.ts +328 -0
  62. package/src/owner-chain.test.ts +97 -0
  63. package/src/owner-chain.ts +128 -0
@@ -0,0 +1,110 @@
1
+ import type { GraphIR, IRNode } from "./graph-ir";
2
+
3
+ /**
4
+ * Fold DERIVED reachability facts onto EC2 instance nodes so a single-node query
5
+ * can answer questions that are otherwise a multi-hop join with a union (#1139).
6
+ *
7
+ * Two facts, both things a live AWS-CLI sweep gets wrong because it can't cheaply
8
+ * resolve the topology:
9
+ *
10
+ * - `effectiveIngress` — the union of security-group ingress rules reachable
11
+ * from the instance, BOTH directly (`SecurityGroupIds`) AND through its launch
12
+ * template (`LaunchTemplate → LaunchTemplateData → SecurityGroupIds`). The
13
+ * launch-template hop is exactly what a CLI agent misses (it under-counts
14
+ * SSH-reachable instances). Each rule is normalized to `proto:port:cidr`
15
+ * (e.g. `tcp:22:0.0.0.0/0`) so it is precisely queryable.
16
+ * - `internetFacing` — whether the instance's subnet routes to an Internet
17
+ * Gateway (`subnet ← SubnetRouteTableAssociation → RouteTable ← Route →
18
+ * InternetGateway`). "Public subnet" means an IGW route, not
19
+ * `MapPublicIpOnLaunch`.
20
+ *
21
+ * With these, "instances SSH-reachable from the internet" is one predicate:
22
+ * `kind:EC2::Instance attr:internetFacing=true attr:effectiveIngress=tcp:22:0.0.0.0/0`
23
+ * — no hand-joined CLI sweep, no over/under-counting.
24
+ */
25
+ export function enrichEffectiveTopology(ir: GraphIR): GraphIR {
26
+ const byId = new Map(ir.nodes.map((n) => [n.id, n]));
27
+ const edges = ir.edges ?? [];
28
+ const kind = (n?: IRNode): string => n?.kind ?? "";
29
+ const isKind = (n: IRNode | undefined, suffix: string): boolean =>
30
+ !!n && (kind(n) === suffix || kind(n).endsWith("::" + suffix));
31
+ const via = (...names: string[]) => (v: string): boolean => names.includes(v);
32
+
33
+ /** Out-neighbours of `id` (edges from → to), optionally filtered by viaAttr. */
34
+ const out = (id: string, pred?: (v: string) => boolean): IRNode[] =>
35
+ edges
36
+ .filter((e) => e.from === id && (!pred || pred(e.viaAttr ?? e.kind ?? "")))
37
+ .map((e) => byId.get(e.to))
38
+ .filter((x): x is IRNode => !!x);
39
+ /** In-neighbours of `id` (edges to ← from), optionally filtered by viaAttr. */
40
+ const incoming = (id: string, pred?: (v: string) => boolean): IRNode[] =>
41
+ edges
42
+ .filter((e) => e.to === id && (!pred || pred(e.viaAttr ?? e.kind ?? "")))
43
+ .map((e) => byId.get(e.from))
44
+ .filter((x): x is IRNode => !!x);
45
+
46
+ const normalizeIngress = (sg: IRNode): string[] => {
47
+ const rules = (sg.attrs as Record<string, unknown> | undefined)?.["SecurityGroupIngress"];
48
+ if (!Array.isArray(rules)) return [];
49
+ return rules.map((r) => {
50
+ const rule = r as Record<string, unknown>;
51
+ const proto = String(rule.IpProtocol ?? "-1");
52
+ const from = rule.FromPort as number | undefined;
53
+ const to = rule.ToPort as number | undefined;
54
+ const port = from == null ? "all" : from === to ? `${from}` : `${from}-${to}`;
55
+ const cidr =
56
+ (rule.CidrIp as string | undefined) ??
57
+ (rule.CidrIpv6 as string | undefined) ??
58
+ (rule.SourceSecurityGroupId ? `sg:${String(rule.SourceSecurityGroupId)}` : "?");
59
+ return `${proto}:${port}:${cidr}`;
60
+ });
61
+ };
62
+
63
+ /** Security groups reachable from an instance — direct and via launch template. */
64
+ const effectiveSgs = (inst: IRNode): IRNode[] => {
65
+ const direct = out(inst.id, via("SecurityGroupIds", "SecurityGroupId"));
66
+ const templates = out(inst.id, via("LaunchTemplate", "LaunchTemplateId"));
67
+ const viaTemplate = templates
68
+ .flatMap((lt) => out(lt.id, via("LaunchTemplateData", "SecurityGroupIds", "SecurityGroupId")))
69
+ .filter((n) => isKind(n, "SecurityGroup"));
70
+ const all = [...direct.filter((n) => isKind(n, "SecurityGroup")), ...viaTemplate];
71
+ return [...new Map(all.map((s) => [s.id, s])).values()];
72
+ };
73
+
74
+ /** The IGW an instance's subnet routes to (evidence), or undefined. */
75
+ const internetFacingVia = (inst: IRNode): string | undefined => {
76
+ for (const subnet of out(inst.id, via("SubnetId")).filter((n) => isKind(n, "Subnet"))) {
77
+ const assocs = incoming(subnet.id, via("SubnetId")).filter((a) => isKind(a, "SubnetRouteTableAssociation"));
78
+ const routeTables = assocs.flatMap((a) => out(a.id, via("RouteTableId")).filter((n) => isKind(n, "RouteTable")));
79
+ for (const rt of routeTables) {
80
+ const routes = incoming(rt.id, via("RouteTableId")).filter((n) => isKind(n, "Route"));
81
+ for (const route of routes) {
82
+ const dest = (route.attrs as Record<string, unknown> | undefined)?.["DestinationCidrBlock"];
83
+ const igw = out(route.id, via("GatewayId")).find((g) => isKind(g, "InternetGateway"));
84
+ if (igw && (dest == null || dest === "0.0.0.0/0")) {
85
+ const id = igw.id.includes("::") ? igw.id.slice(igw.id.lastIndexOf("::") + 2) : igw.id;
86
+ return `${rt.id.includes("::") ? rt.id.slice(rt.id.lastIndexOf("::") + 2) : rt.id} → ${id}`;
87
+ }
88
+ }
89
+ }
90
+ }
91
+ return undefined;
92
+ };
93
+
94
+ const nodes = ir.nodes.map((n) => {
95
+ if (!isKind(n, "Instance")) return n;
96
+ const effectiveIngress = effectiveSgs(n).flatMap(normalizeIngress);
97
+ // A live enrichment may already have set internetFacing (+ its evidence) for
98
+ // a subnet chant doesn't model declaratively (e.g. the account's default
99
+ // VPC). Keep that truth; otherwise derive it from the declared route topology.
100
+ const attrs = (n.attrs ?? {}) as Record<string, unknown>;
101
+ const liveFacing = attrs["internetFacing"] === true;
102
+ const declaredVia = internetFacingVia(n);
103
+ const via = (attrs["internetFacingVia"] as string | undefined) ?? declaredVia;
104
+ return {
105
+ ...n,
106
+ attrs: { ...attrs, effectiveIngress, internetFacing: liveFacing || !!declaredVia, ...(via ? { internetFacingVia: via } : {}) },
107
+ };
108
+ });
109
+ return { ...ir, nodes };
110
+ }
@@ -60,6 +60,23 @@ describe("buildLiveGraphIr", () => {
60
60
  expect(node.attrs).toEqual({});
61
61
  });
62
62
 
63
+ // #1077 — owner-reference chain classification
64
+ it("carries runtimeOwner only when the owner chain resolves to a declared entity", () => {
65
+ const ir = buildLiveGraphIr([
66
+ {
67
+ lexicon: "k8s",
68
+ resources: {
69
+ "prod/web-abc": { type: "K8s::Core::Pod", status: "Running", ownerChain: { root: "declared", entity: "web" } },
70
+ "prod/other": { type: "K8s::Core::Pod", status: "Running", ownerChain: { root: "foreign" } },
71
+ "prod/plain": { type: "K8s::Core::Pod", status: "Running" },
72
+ },
73
+ },
74
+ ]);
75
+ expect(ir.nodes.find((n) => n.id === "prod/web-abc")!.runtimeOwner).toBe("web");
76
+ expect(ir.nodes.find((n) => n.id === "prod/other")!.runtimeOwner).toBeUndefined();
77
+ expect(ir.nodes.find((n) => n.id === "prod/plain")!.runtimeOwner).toBeUndefined();
78
+ });
79
+
63
80
  it("is deterministic for a fixed observation set", () => {
64
81
  expect(JSON.stringify(buildLiveGraphIr(observations))).toBe(
65
82
  JSON.stringify(buildLiveGraphIr(observations)),
@@ -85,6 +102,18 @@ describe("overlayGraphs (#780 drift overlay)", () => {
85
102
  expect(ir.nodes.map((n) => n.id).sort()).toEqual(["planned-db", "rogue-sg", "web-vpc"]);
86
103
  expect(ir.edges).toHaveLength(1);
87
104
  });
105
+
106
+ // #1077 — a provisioned, undeclared node whose owner chain reaches a
107
+ // declared entity paints `runtime`, not `warn` — it is expected runtime,
108
+ // not a foreign resource needing attention.
109
+ it("classifies a runtime child via _status, distinct from foreign", () => {
110
+ const podNode = { id: "prod/web-abc", kind: "K8s::Core::Pod", lexicon: "k8s", attrs: {}, runtimeOwner: "web" };
111
+ const liveWithChild: GraphIR = { nodes: [node("web-vpc"), node("rogue-sg"), podNode], edges: [], groups: {} };
112
+ const ir = overlayGraphs(liveWithChild, declared);
113
+ const statusOf = (id: string) => (ir.nodes.find((n) => n.id === id)!.attrs as { _status?: string })._status;
114
+ expect(statusOf("prod/web-abc")).toBe("runtime");
115
+ expect(statusOf("rogue-sg")).toBe("warn"); // still foreign — no runtimeOwner
116
+ });
88
117
  });
89
118
 
90
119
  describe("sourceOverlayGraphs (#821 source-anchored overlay)", () => {
@@ -135,6 +164,17 @@ describe("sourceOverlayGraphs (#821 source-anchored overlay)", () => {
135
164
  expect(ir.groups.byLexicon).toEqual({ aws: ["planned-db", "web-vpc"], k8s: ["app-ingress"] });
136
165
  });
137
166
 
167
+ // #1077 — a live, undeclared node whose owner chain reaches a declared
168
+ // entity is appended `runtime`, not `warn`, even though it is just as
169
+ // "foreign" (undeclared) from the declared graph's point of view.
170
+ it("appends a runtime child as `runtime`, distinct from foreign", () => {
171
+ const podNode = { id: "prod/web-abc", kind: "K8s::Core::Pod", lexicon: "k8s", attrs: {}, runtimeOwner: "app-ingress" };
172
+ const liveWithChild: GraphIR = { ...live, nodes: [...live.nodes, podNode] };
173
+ const ir = sourceOverlayGraphs(declared, liveWithChild);
174
+ expect(statusOf(ir, "prod/web-abc")).toBe("runtime");
175
+ expect(statusOf(ir, "rogue-sg")).toBe("warn"); // still foreign
176
+ });
177
+
138
178
  it("drops a live edge between two managed nodes — declared edges already cover it", () => {
139
179
  const liveDup: GraphIR = { ...live, edges: [{ from: "app-ingress", to: "web-vpc", kind: "ref", viaAttr: "live-label" }] };
140
180
  const ir = sourceOverlayGraphs(declared, liveDup);
package/src/graph-ir.ts CHANGED
@@ -67,6 +67,15 @@ export interface IRNode {
67
67
  * `owned` = chant-managed. Absent for source-derived IR.
68
68
  */
69
69
  ownership?: "owned" | "foreign";
70
+ /**
71
+ * Live-only, undeclared nodes: the declared entity this node's
72
+ * owner-reference chain resolves to (#1077) — a Pod a declared Deployment's
73
+ * controller created, for instance. Presence of this field is what an
74
+ * overlay reads to paint the node `runtime` instead of `warn`/foreign; a
75
+ * node without it (including every declared, source-derived node) is
76
+ * unaffected.
77
+ */
78
+ runtimeOwner?: string;
70
79
  }
71
80
 
72
81
  /** A directed dependency: `from` references an attribute of `to`. */
@@ -504,6 +513,11 @@ export function buildLiveGraphIr(observations: LiveObservation[]): GraphIR {
504
513
  // information for a painter, and the IR's `ownership` field means "a
505
514
  // verdict was reached" — so only owned/foreign land on the node.
506
515
  if (meta.ownership === "owned" || meta.ownership === "foreign") node.ownership = meta.ownership;
516
+ // Owner-reference chain (#1077): only a resolved `declared` root is
517
+ // carried onto the node — the same "a verdict was reached" rule as
518
+ // ownership above, since `unowned`/`foreign`/`unknown` all mean "no
519
+ // declared owner", which is simply the absence of this field.
520
+ if (meta.ownerChain?.root === "declared") node.runtimeOwner = meta.ownerChain.entity;
507
521
  nodes.push(node);
508
522
  (byLexicon[lexicon] ??= []).push(name);
509
523
  // A live lexicon maps to one deployable stack, same as the source IR.
@@ -540,8 +554,9 @@ export function collectUnobserved(observations: LiveObservation[]): Record<strin
540
554
  return out;
541
555
  }
542
556
 
543
- /** Paint status a node carries in an overlay. `neutral` = chant could not look. */
544
- type OverlayNodeStatus = "good" | "warn" | "accent" | "neutral";
557
+ /** Paint status a node carries in an overlay. `neutral` = chant could not look;
558
+ * `runtime` = live, undeclared, owner chain reaches a declared entity (#1077). */
559
+ type OverlayNodeStatus = "good" | "warn" | "accent" | "neutral" | "runtime";
545
560
 
546
561
  function tagStatus(n: IRNode, status: OverlayNodeStatus, unobserved?: UnobservedEntity): IRNode {
547
562
  return {
@@ -558,7 +573,10 @@ function tagStatus(n: IRNode, status: OverlayNodeStatus, unobserved?: Unobserved
558
573
  * Overlay the declared graph on the provisioned one (#780, `chant graph --live
559
574
  * --overlay`) and classify each resource, tagging a `_status` a renderer colours:
560
575
  * - **managed** (declared + provisioned) → `good`
561
- * - **foreign** (provisioned, not declared) `warn`
576
+ * - **runtime** (provisioned, not declared, owner chain reaches a declared
577
+ * entity — #1077) → `runtime`, e.g. a Pod a declared Deployment's
578
+ * controller created — expected, not a foreign resource needing attention
579
+ * - **foreign** (provisioned, not declared, no declared owner) → `warn`
562
580
  * - **pending** (declared, provider confirmed absent) → `accent`
563
581
  * - **unobserved** (declared, chant could not look — #1089) → `neutral`,
564
582
  * plus an `_unobserved` attr carrying the reason
@@ -570,7 +588,9 @@ export function overlayGraphs(live: GraphIR, declared: GraphIR, opts?: OverlayOp
570
588
  const liveIds = new Set(live.nodes.map((n) => n.id));
571
589
  const unobserved = opts?.unobserved ?? {};
572
590
 
573
- const nodes: IRNode[] = live.nodes.map((n) => tagStatus(n, declaredIds.has(n.id) ? "good" : "warn"));
591
+ const nodes: IRNode[] = live.nodes.map((n) =>
592
+ tagStatus(n, declaredIds.has(n.id) ? "good" : n.runtimeOwner ? "runtime" : "warn"),
593
+ );
574
594
  for (const n of declared.nodes) {
575
595
  if (liveIds.has(n.id)) continue;
576
596
  const u = unobserved[n.id];
@@ -597,8 +617,10 @@ export function overlayGraphs(live: GraphIR, declared: GraphIR, opts?: OverlayOp
597
617
  * the reason on `_unobserved`. A wrong-cluster or unsupported-kind read used
598
618
  * to paint the whole estate "pending", which is the diagram equivalent of
599
619
  * planning a create for something that already exists.
600
- * **Foreign** resources (provisioned, not declared) are appended and tagged
601
- * `warn`, together with any live-reconstructed edges that touch them — a declared
620
+ * **Foreign** resources (provisioned, not declared, no declared owner) are
621
+ * appended and tagged `warn`; a provisioned-but-undeclared resource whose
622
+ * owner chain reaches a declared entity (#1077) is tagged `runtime` instead —
623
+ * both carry any live-reconstructed edges that touch them, since a declared
602
624
  * edge cannot describe an undeclared resource. Declared groups/exports pass
603
625
  * through unchanged; nodes and edges are sorted for deterministic output.
604
626
  */
@@ -620,7 +642,10 @@ export function sourceOverlayGraphs(declared: GraphIR, live: GraphIR, opts?: Ove
620
642
  if (obs.ownership) merged.ownership = obs.ownership;
621
643
  return tagStatus(merged, "good");
622
644
  });
623
- for (const n of live.nodes) if (foreignIds.has(n.id)) nodes.push(tagStatus(n, "warn")); // foreign
645
+ for (const n of live.nodes) {
646
+ if (!foreignIds.has(n.id)) continue;
647
+ nodes.push(tagStatus(n, n.runtimeOwner ? "runtime" : "warn")); // runtime child or foreign
648
+ }
624
649
  nodes.sort((a, b) => a.id.localeCompare(b.id));
625
650
 
626
651
  // Declared edges are the canvas (the cross-substrate topology). Add only the
package/src/index.ts CHANGED
@@ -50,6 +50,7 @@ export * from "./import/generator";
50
50
  export * from "./lexicon";
51
51
  export * from "./observation";
52
52
  export * from "./deep-observation";
53
+ export * from "./owner-chain";
53
54
  export * from "./lexicon-integrity";
54
55
  export * from "./lexicon-manifest";
55
56
  export * from "./lexicon-schema";
package/src/lexicon.ts CHANGED
@@ -13,6 +13,12 @@ import type { RuleMeta } from "./audit/catalog";
13
13
  import type { ReferenceCatalog } from "./graph-refs";
14
14
  import type { DescribeResourcesResult } from "./observation";
15
15
  import type { DeepNormalizationHooks, DeepObservationResult } from "./deep-observation";
16
+ import type { OwnerChainVerdict } from "./owner-chain";
17
+ import type { CommandGroup } from "./cli/command-group";
18
+
19
+ // Re-exported so a lexicon can author its command group (#1078) from the
20
+ // same `@intentius/chant/lexicon` entry it imports the plugin contract from.
21
+ export type { CommandGroup, CommandGroupCommand, CommandGroupContext } from "./cli/command-group";
16
22
 
17
23
  // Re-exported so lexicons can author a reference catalog (#778) from the same
18
24
  // `@intentius/chant/lexicon` entry they import the plugin contract from.
@@ -434,6 +440,24 @@ export interface LexiconPlugin {
434
440
  * account. Absent when the lexicon has no local emulator. */
435
441
  readonly emulator?: EmulatorCapability;
436
442
 
443
+ /**
444
+ * A CLI verb group this lexicon contributes, mounted under `chant <name>
445
+ * <verb>` (#1078). Core learns that a lexicon MAY contribute a command
446
+ * group and learns nothing about what is inside it — it finds the group by
447
+ * name and dispatches to the matched verb's handler wholesale, the same
448
+ * "spec, not behavior" shape as {@link emulator}. Unlike `emulator`, which
449
+ * core itself aggregates across every configured lexicon in one command
450
+ * (`chant emulator up --all`), a command group is owned end-to-end by ONE
451
+ * lexicon: `get -o wide -l app=x --field-selector` is irreducibly
452
+ * Kubernetes vocabulary, not something core could generalize or merge
453
+ * across plugins even if it wanted to. Absent when the lexicon contributes
454
+ * no CLI surface — registering nothing here changes nothing else about how
455
+ * the lexicon behaves; the build/fold path never calls this or invokes any
456
+ * verb's handler, since command dispatch happens only in the CLI's own
457
+ * entry point, never in discovery/build/fold.
458
+ */
459
+ commands?(): CommandGroup;
460
+
437
461
  // ── Optional extensions ───────────────────────────────────
438
462
  /** Return lint rules provided by this lexicon */
439
463
  lintRules?(): LintRule[];
@@ -555,6 +579,13 @@ export interface LexiconPlugin {
555
579
  * `ownership: "unknown"` on what it returns rather than degrading silently —
556
580
  * the change set never escalates `unknown` to a `delete`.
557
581
  *
582
+ * An undeclared entry this method returns may carry {@link
583
+ * ResourceMetadata.ownerChain} (#1077) — set it when the provider's own
584
+ * parent/child graph (Kubernetes `ownerReferences`) shows this object's
585
+ * chain reaching a declared entity, so the diff engine classifies it
586
+ * `runtime` instead of `orphan`. Optional; a lexicon that never sets it
587
+ * keeps every undeclared entry classified `orphan`, unchanged.
588
+ *
558
589
  * `entities` carries the chant-side entity declarations for this lexicon,
559
590
  * keyed by chant entity name (e.g. the export name from a `*.ts` file).
560
591
  * Implementations that need to map cloud-side names back to chant entity
@@ -577,6 +608,9 @@ export interface LexiconPlugin {
577
608
  * convention (AWS: the stack named after `environment`).
578
609
  */
579
610
  stack?: string;
611
+ /** AWS region the stack is in (multi-region). When set, the observation
612
+ * targets this region instead of the ambient one (#1161 follow-up). */
613
+ region?: string;
580
614
  /**
581
615
  * Restrict the result to chant-owned resources (those carrying the
582
616
  * ownership marker, #119). Where a lexicon has no durable marker channel,
@@ -664,7 +698,7 @@ export interface LexiconPlugin {
664
698
  * thin to carry references (e.g. AWS CloudFormation, where it's sourced from
665
699
  * the fuller `exportResources` config).
666
700
  */
667
- enrichLiveAttrs?(options: { environment: string; stack?: string; owned?: boolean }): Promise<Record<string, Record<string, unknown>>>;
701
+ enrichLiveAttrs?(options: { environment: string; stack?: string; stacks?: Array<string | { name: string; region?: string }>; owned?: boolean }): Promise<Record<string, Record<string, unknown>>>;
668
702
 
669
703
  /**
670
704
  * List runtime artifacts in the given environment. Opt-in.
@@ -717,6 +751,8 @@ export interface LexiconPlugin {
717
751
  * keeps its single-stack convention (AWS: the stack named after
718
752
  * `environment`). */
719
753
  stack?: string;
754
+ /** AWS region the stack is in (multi-region estates). */
755
+ region?: string;
720
756
  selector?: ResourceSelector;
721
757
  owned?: boolean;
722
758
  verbatim?: boolean;
@@ -783,6 +819,19 @@ export interface ResourceMetadata {
783
819
  * a delete, and never escalates `unknown` to one.
784
820
  */
785
821
  ownership?: "owned" | "foreign" | "unknown";
822
+ /**
823
+ * Where this resource's owner-reference chain leads, for a live resource
824
+ * that is not itself declared (#1077). A lexicon that maintains an
825
+ * owner-reference graph (Kubernetes) sets this on an undeclared entry it
826
+ * returns; the diff engine reads `{ root: "declared" }` as `runtime`
827
+ * (a Pod a declared Deployment's controller created) rather than `orphan`.
828
+ * Distinct from {@link ownership}: that is chant's own managed-by marker,
829
+ * this is the provider's native parent/child graph — a runtime child
830
+ * usually carries no chant marker of its own at all. Absent means the
831
+ * lexicon supplies no chain, which is exactly today's behavior: every
832
+ * undeclared live resource stays `orphan`.
833
+ */
834
+ ownerChain?: OwnerChainVerdict;
786
835
  }
787
836
 
788
837
  /**
@@ -106,6 +106,77 @@ describe("buildChangeSet (#118)", () => {
106
106
  expect(cs.entries.filter((e) => e.action === "adopt").map((e) => e.name)).toEqual(["b", "c"]);
107
107
  });
108
108
 
109
+ // ── Owner-reference chain classification (#1077) ──────────────────────────
110
+
111
+ test("undeclared, owner chain reaches a declared entity → runtime, never delete or adopt", () => {
112
+ const cs = buildChangeSet("prod", {
113
+ declared: new Set(["web"]),
114
+ observedNow: {
115
+ web: meta({ type: "K8s::Apps::Deployment" }),
116
+ "prod/web-abc": meta({ type: "K8s::Core::Pod", ownerChain: { root: "declared", entity: "web" } }),
117
+ },
118
+ observedThen: undefined,
119
+ });
120
+ const e = cs.entries.find((x) => x.name === "prod/web-abc")!;
121
+ expect(e.action).toBe("runtime");
122
+ expect(e.runtimeOwner).toBe("web");
123
+ });
124
+
125
+ test("a runtime child that also carries chant's own ownership marker is still `runtime`, never `delete`", () => {
126
+ // Guards the ordering in buildChangeSet: runtimeOwner must be checked
127
+ // before the ownership marker, in case a runtime child ever inherits the
128
+ // marker (e.g. label propagation from its owner's pod template).
129
+ const cs = buildChangeSet("prod", {
130
+ declared: new Set(),
131
+ observedNow: {
132
+ "prod/web-abc": meta({ ownership: "owned", ownerChain: { root: "declared", entity: "web" } }),
133
+ },
134
+ observedThen: undefined,
135
+ });
136
+ const e = cs.entries.find((x) => x.name === "prod/web-abc")!;
137
+ expect(e.action).toBe("runtime");
138
+ });
139
+
140
+ test("undeclared, unowned → orphan/adopt, not runtime", () => {
141
+ const cs = buildChangeSet("prod", {
142
+ declared: new Set(),
143
+ observedNow: { "prod/standalone": meta({ ownerChain: { root: "unowned" } }) },
144
+ observedThen: undefined,
145
+ });
146
+ const e = cs.entries.find((x) => x.name === "prod/standalone")!;
147
+ expect(e.action).toBe("adopt");
148
+ expect(e.runtimeOwner).toBeUndefined();
149
+ });
150
+
151
+ test("undeclared, foreign root → orphan/adopt, not runtime", () => {
152
+ const cs = buildChangeSet("prod", {
153
+ declared: new Set(),
154
+ observedNow: { "prod/other": meta({ ownerChain: { root: "foreign" } }) },
155
+ observedThen: undefined,
156
+ });
157
+ expect(cs.entries.find((x) => x.name === "prod/other")!.action).toBe("adopt");
158
+ });
159
+
160
+ test("undeclared, unresolved chain (unreadable/cycle/depth) → conservative adopt, not runtime", () => {
161
+ const cs = buildChangeSet("prod", {
162
+ declared: new Set(),
163
+ observedNow: { "prod/mystery": meta({ ownerChain: { root: "unknown" } }) },
164
+ observedThen: undefined,
165
+ });
166
+ const e = cs.entries.find((x) => x.name === "prod/mystery")!;
167
+ expect(e.action).toBe("adopt");
168
+ expect(e.runtimeOwner).toBeUndefined();
169
+ });
170
+
171
+ test("a lexicon with no owner chain at all is unaffected — undeclared stays adopt/delete as before", () => {
172
+ const cs = buildChangeSet("prod", {
173
+ declared: new Set(),
174
+ observedNow: { orphan: meta({ ownership: "owned" }) },
175
+ observedThen: undefined,
176
+ });
177
+ expect(cs.entries.find((x) => x.name === "orphan")!.action).toBe("delete");
178
+ });
179
+
109
180
  test("only in snapshot (gone now, undeclared) → noop", () => {
110
181
  const cs = buildChangeSet("prod", {
111
182
  declared: new Set(),
@@ -148,6 +219,23 @@ describe("summarize / renderChangeSet", () => {
148
219
  expect(out).toContain("ADOPT:");
149
220
  expect(out).toContain("orphan");
150
221
  });
222
+
223
+ test("summarize and render surface the runtime action (#1077)", () => {
224
+ const withRuntime = buildChangeSet("prod", {
225
+ declared: new Set(["web"]),
226
+ observedNow: {
227
+ web: meta({ type: "K8s::Apps::Deployment" }),
228
+ "prod/web-abc": meta({ type: "K8s::Core::Pod", ownerChain: { root: "declared", entity: "web" } }),
229
+ },
230
+ observedThen: undefined,
231
+ });
232
+ expect(summarize(withRuntime).runtime).toBe(1);
233
+ expect(summarize(withRuntime).adopt).toBe(0);
234
+ const out = renderChangeSet(withRuntime);
235
+ expect(out).toContain("RUNTIME");
236
+ expect(out).toContain("prod/web-abc");
237
+ expect(out).toContain("owned by web");
238
+ });
151
239
  });
152
240
 
153
241
  describe("gitlabMrReport (#329)", () => {
@@ -169,6 +257,18 @@ describe("gitlabMrReport (#329)", () => {
169
257
  expect(gitlabMrReport(cs)).toEqual({ create: 1, update: 1, delete: 1 });
170
258
  });
171
259
 
260
+ test("a runtime child (#1077) is excluded from the widget — never counted as a change", () => {
261
+ const cs = buildChangeSet("prod", {
262
+ declared: new Set(["web"]),
263
+ observedNow: {
264
+ web: meta({ type: "K8s::Apps::Deployment" }),
265
+ "prod/web-abc": meta({ type: "K8s::Core::Pod", ownerChain: { root: "declared", entity: "web" } }),
266
+ },
267
+ observedThen: undefined,
268
+ });
269
+ expect(gitlabMrReport(cs)).toEqual({ create: 0, update: 0, delete: 0 });
270
+ });
271
+
172
272
  test("empty plan reports all zeros", () => {
173
273
  const cs = buildChangeSet("prod", {
174
274
  declared: new Set(),
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * `chant lifecycle diff --live` computes a three-way comparison — declared now /
5
5
  * last snapshot / live now — and prints it. `buildChangeSet` promotes that
6
- * same signal into a classified create/update/delete/adopt/noop set that other
7
- * tooling (reconcile, apply) can act on.
6
+ * same signal into a classified create/update/delete/adopt/runtime/noop set
7
+ * that other tooling (reconcile, apply) can act on.
8
8
  *
9
9
  * Strictly read-only and pure: no I/O, no mutation. The classification reads
10
10
  * ownership from the live marker only (populated downstream); until ownership
@@ -25,12 +25,17 @@ import { unobservedReasonText, type UnobservedReason } from "../observation";
25
25
  * snapshot.
26
26
  * - `adopt` — live but undeclared, ownership not established → a candidate to
27
27
  * pull back into source, never an auto-delete.
28
+ * - `runtime` — live but undeclared, and its owner-reference chain reaches a
29
+ * declared entity (#1077): a Pod a declared Deployment's controller
30
+ * created, for instance. Never a delete, never an adopt candidate — it is
31
+ * not drift, just the runtime doing its job. `runtimeOwner` names the
32
+ * declared entity it belongs to.
28
33
  * - `noop` — declared and live with no drift, or already reconciled.
29
34
  * - `unobserved` — declared, and the lexicon could not look (#1089). Not a
30
35
  * proposal at all: it is the plan admitting a hole. Never a create, never a
31
36
  * delete. Read `unobservedReason` for which hole.
32
37
  */
33
- export type ChangeAction = "create" | "update" | "delete" | "adopt" | "noop" | "unobserved";
38
+ export type ChangeAction = "create" | "update" | "delete" | "adopt" | "runtime" | "noop" | "unobserved";
34
39
 
35
40
  /**
36
41
  * Who answers "is this resource chant's?". `unknown` until a live ownership
@@ -69,6 +74,8 @@ export interface ChangeSetEntry {
69
74
  unobservedReason?: UnobservedReason;
70
75
  /** Human-readable backing for `unobservedReason` (the failing command, the missing binding). */
71
76
  unobservedDetail?: string;
77
+ /** The declared entity this resource's owner chain resolves to, for `action: "runtime"` (#1077). */
78
+ runtimeOwner?: string;
72
79
  }
73
80
 
74
81
  export interface ChangeSet {
@@ -121,6 +128,15 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
121
128
  // record chant has to host.
122
129
  const ownership: Ownership = observedNow[name]?.ownership ?? "unknown";
123
130
 
131
+ // Owner-reference chain (#1077), same live-only provenance as ownership
132
+ // above. Only a `declared` root changes the classification; `unknown` is
133
+ // deliberately not escalated (#1168's tri-state precedent — an
134
+ // unconfirmed chain never earns the more confident verdict).
135
+ const runtimeOwner =
136
+ !isDeclared && observedNow[name]?.ownerChain?.root === "declared"
137
+ ? observedNow[name]!.ownerChain!.entity
138
+ : undefined;
139
+
124
140
  let action: ChangeAction;
125
141
  let deltas: AttributeChange[] | undefined;
126
142
 
@@ -140,6 +156,13 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
140
156
  } else {
141
157
  action = "noop";
142
158
  }
159
+ } else if (live && runtimeOwner) {
160
+ // Live, undeclared, and its owner chain reaches a declared entity
161
+ // (#1077) — expected runtime, never a delete/adopt candidate, checked
162
+ // ahead of the ownership marker below: even a runtime child that
163
+ // happens to carry chant's own marker (label propagation from its
164
+ // owner's template) must never be proposed for deletion.
165
+ action = "runtime";
143
166
  } else if (live) {
144
167
  // Live but undeclared. Only a chant-owned orphan is a safe delete; a
145
168
  // foreign or unknown orphan can be adopted but never auto-deleted.
@@ -162,6 +185,7 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
162
185
  ...(unobservedEntry.detail ? { unobservedDetail: unobservedEntry.detail } : {}),
163
186
  }
164
187
  : {}),
188
+ ...(runtimeOwner ? { runtimeOwner } : {}),
165
189
  });
166
190
  }
167
191
 
@@ -169,7 +193,7 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
169
193
  return { env, entries };
170
194
  }
171
195
 
172
- const ACTION_ORDER: ChangeAction[] = ["create", "update", "delete", "adopt", "noop", "unobserved"];
196
+ const ACTION_ORDER: ChangeAction[] = ["create", "update", "delete", "adopt", "runtime", "noop", "unobserved"];
173
197
 
174
198
  /** Count entries per action. */
175
199
  export function summarize(cs: ChangeSet): Record<ChangeAction, number> {
@@ -178,6 +202,7 @@ export function summarize(cs: ChangeSet): Record<ChangeAction, number> {
178
202
  update: 0,
179
203
  delete: 0,
180
204
  adopt: 0,
205
+ runtime: 0,
181
206
  noop: 0,
182
207
  unobserved: 0,
183
208
  };
@@ -191,10 +216,11 @@ export function summarize(cs: ChangeSet): Record<ChangeAction, number> {
191
216
  * GitLab renders an `artifacts:reports:terraform` artifact in the merge-request
192
217
  * UI as "N to add, M to change, K to delete". The format is generic — any tool
193
218
  * that emits this JSON gets the widget — and the chant plan maps onto it
194
- * directly. Only the mutating actions count: `adopt`, `noop` and `unobserved`
195
- * are excluded, since the widget has no column for "live but undeclared", "no
196
- * change", or "could not look" (#1089). The widget is therefore a floor, not a
197
- * complete plan: read the full change set when entities are unobserved.
219
+ * directly. Only the mutating actions count: `adopt`, `runtime`, `noop` and
220
+ * `unobserved` are excluded, since the widget has no column for "live but
221
+ * undeclared", "expected runtime child" (#1077), "no change", or "could not
222
+ * look" (#1089). The widget is therefore a floor, not a complete plan: read
223
+ * the full change set when entities are unobserved or classified runtime.
198
224
  *
199
225
  * The widget label reads "Terraform" regardless of producer; that is GitLab's
200
226
  * fixed string, not a claim chant makes.
@@ -223,14 +249,17 @@ export function renderChangeSet(cs: ChangeSet): string {
223
249
  lines.push(
224
250
  action === "unobserved"
225
251
  ? "\nUNOBSERVED (declared; chant could not read live state — no action proposed):"
226
- : `\n${action.toUpperCase()}:`,
252
+ : action === "runtime"
253
+ ? "\nRUNTIME (owned by a declared resource; not drift, never a delete/adopt candidate):"
254
+ : `\n${action.toUpperCase()}:`,
227
255
  );
228
256
  for (const e of group) {
229
257
  const own = e.ownership === "unknown" ? "" : ` [${e.ownership}]`;
230
258
  const why = e.unobservedReason
231
259
  ? ` — ${unobservedReasonText(e.unobservedReason)}${e.unobservedDetail ? `: ${e.unobservedDetail}` : ""}`
232
260
  : "";
233
- lines.push(` ${e.name}${e.type ? ` (${e.type})` : ""}${own}${why}`);
261
+ const owner = e.runtimeOwner ? ` — owned by ${e.runtimeOwner}` : "";
262
+ lines.push(` ${e.name}${e.type ? ` (${e.type})` : ""}${own}${why}${owner}`);
234
263
  for (const d of e.deltas ?? []) {
235
264
  lines.push(` ${d.path}: ${fmt(d.oldValue)} → ${fmt(d.newValue)}`);
236
265
  }