@intentius/chant 0.27.0 → 0.29.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 (106) hide show
  1. package/dist/cli/commands/build.d.ts.map +1 -1
  2. package/dist/cli/commands/lint.d.ts.map +1 -1
  3. package/dist/cli/handlers/components.d.ts.map +1 -1
  4. package/dist/cli/handlers/graph.d.ts.map +1 -1
  5. package/dist/cli/handlers/lifecycle.d.ts +5 -3
  6. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  7. package/dist/config.d.ts +46 -4
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/discovery/fold-import.d.ts +153 -17
  10. package/dist/discovery/fold-import.d.ts.map +1 -1
  11. package/dist/discovery/sandbox/config-run.d.ts.map +1 -1
  12. package/dist/discovery/sandbox/config-wire.d.ts +3 -2
  13. package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
  14. package/dist/discovery/sandbox/fork.d.ts +25 -0
  15. package/dist/discovery/sandbox/fork.d.ts.map +1 -1
  16. package/dist/discovery/sandbox/policy-run.d.ts.map +1 -1
  17. package/dist/discovery/sandbox/run.d.ts.map +1 -1
  18. package/dist/env.d.ts +5 -2
  19. package/dist/env.d.ts.map +1 -1
  20. package/dist/fold/fold.d.ts +12 -0
  21. package/dist/fold/fold.d.ts.map +1 -1
  22. package/dist/graph-ir.d.ts +29 -4
  23. package/dist/graph-ir.d.ts.map +1 -1
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/kubectl-context.d.ts +100 -0
  27. package/dist/kubectl-context.d.ts.map +1 -0
  28. package/dist/lexicon.d.ts +31 -6
  29. package/dist/lexicon.d.ts.map +1 -1
  30. package/dist/lifecycle/change-set.d.ts +26 -5
  31. package/dist/lifecycle/change-set.d.ts.map +1 -1
  32. package/dist/lifecycle/live-diff.d.ts +25 -1
  33. package/dist/lifecycle/live-diff.d.ts.map +1 -1
  34. package/dist/lifecycle/observe.d.ts +4 -2
  35. package/dist/lifecycle/observe.d.ts.map +1 -1
  36. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  37. package/dist/lifecycle/status.d.ts +26 -1
  38. package/dist/lifecycle/status.d.ts.map +1 -1
  39. package/dist/lifecycle/types.d.ts +8 -0
  40. package/dist/lifecycle/types.d.ts.map +1 -1
  41. package/dist/lint/config.d.ts +80 -0
  42. package/dist/lint/config.d.ts.map +1 -1
  43. package/dist/lint/policy.d.ts +8 -2
  44. package/dist/lint/policy.d.ts.map +1 -1
  45. package/dist/lint/post-synth.d.ts +18 -1
  46. package/dist/lint/post-synth.d.ts.map +1 -1
  47. package/dist/live-endpoint.d.ts +92 -0
  48. package/dist/live-endpoint.d.ts.map +1 -0
  49. package/dist/observation.d.ts +123 -0
  50. package/dist/observation.d.ts.map +1 -0
  51. package/dist/stack-output.d.ts +9 -4
  52. package/dist/stack-output.d.ts.map +1 -1
  53. package/package.json +1 -1
  54. package/src/cli/commands/build.test.ts +190 -0
  55. package/src/cli/commands/build.ts +34 -2
  56. package/src/cli/commands/lint.ts +17 -25
  57. package/src/cli/handlers/components.test.ts +63 -4
  58. package/src/cli/handlers/components.ts +78 -35
  59. package/src/cli/handlers/graph.test.ts +69 -6
  60. package/src/cli/handlers/graph.ts +61 -27
  61. package/src/cli/handlers/lifecycle.test.ts +285 -6
  62. package/src/cli/handlers/lifecycle.ts +297 -185
  63. package/src/config.test.ts +75 -0
  64. package/src/config.ts +61 -3
  65. package/src/discovery/fold-composite.test.ts +594 -0
  66. package/src/discovery/fold-import.ts +987 -43
  67. package/src/discovery/sandbox/config-boundary.test.ts +55 -1
  68. package/src/discovery/sandbox/config-run.ts +3 -0
  69. package/src/discovery/sandbox/config-wire.ts +3 -2
  70. package/src/discovery/sandbox/fork.ts +75 -1
  71. package/src/discovery/sandbox/policy-boundary.test.ts +56 -1
  72. package/src/discovery/sandbox/policy-run.ts +15 -1
  73. package/src/discovery/sandbox/run.test.ts +85 -1
  74. package/src/discovery/sandbox/run.ts +3 -0
  75. package/src/env.test.ts +12 -0
  76. package/src/env.ts +12 -4
  77. package/src/fold/fold.ts +12 -2
  78. package/src/graph-ir-live.test.ts +28 -1
  79. package/src/graph-ir.ts +68 -12
  80. package/src/index.ts +1 -0
  81. package/src/kubectl-context.test.ts +94 -0
  82. package/src/kubectl-context.ts +207 -0
  83. package/src/lexicon.ts +41 -6
  84. package/src/lifecycle/change-set.test.ts +93 -1
  85. package/src/lifecycle/change-set.ts +65 -13
  86. package/src/lifecycle/live-diff.test.ts +39 -0
  87. package/src/lifecycle/live-diff.ts +51 -5
  88. package/src/lifecycle/observe.test.ts +74 -3
  89. package/src/lifecycle/observe.ts +82 -22
  90. package/src/lifecycle/snapshot.test.ts +39 -1
  91. package/src/lifecycle/snapshot.ts +34 -9
  92. package/src/lifecycle/status.test.ts +89 -8
  93. package/src/lifecycle/status.ts +53 -3
  94. package/src/lifecycle/types.ts +8 -0
  95. package/src/lint/config.test.ts +93 -1
  96. package/src/lint/config.ts +108 -0
  97. package/src/lint/policy.test.ts +90 -0
  98. package/src/lint/policy.ts +17 -5
  99. package/src/lint/post-synth.test.ts +4 -0
  100. package/src/lint/post-synth.ts +30 -1
  101. package/src/live-endpoint.test.ts +115 -0
  102. package/src/live-endpoint.ts +148 -0
  103. package/src/observation.test.ts +96 -0
  104. package/src/observation.ts +213 -0
  105. package/src/stack-output.test.ts +76 -3
  106. package/src/stack-output.ts +59 -26
package/src/index.ts CHANGED
@@ -48,6 +48,7 @@ export * from "./lint/discover";
48
48
  export * from "./import/parser";
49
49
  export * from "./import/generator";
50
50
  export * from "./lexicon";
51
+ export * from "./observation";
51
52
  export * from "./lexicon-integrity";
52
53
  export * from "./lexicon-manifest";
53
54
  export * from "./lexicon-schema";
@@ -0,0 +1,94 @@
1
+ import { describe, test, expect, vi, beforeEach } from "vitest";
2
+
3
+ const execMock = vi.fn();
4
+ vi.mock("node:child_process", async () => {
5
+ const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
6
+ return {
7
+ ...actual,
8
+ exec: (cmd: string, cb: (err: Error | null, out: { stdout: string; stderr: string }) => void) => {
9
+ Promise.resolve(execMock(cmd)).then(
10
+ (out) => cb(null, out as { stdout: string; stderr: string }),
11
+ (err) => cb(err as Error, { stdout: "", stderr: "" }),
12
+ );
13
+ },
14
+ };
15
+ });
16
+
17
+ const { resolveClusterTarget, ClusterBindingMismatchError } = await import("./kubectl-context");
18
+
19
+ describe("resolveClusterTarget (chant #1100)", () => {
20
+ beforeEach(() => {
21
+ execMock.mockReset();
22
+ });
23
+
24
+ test("no binding declared: returns ambient source, warns visibly, never probes kubectl", async () => {
25
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
26
+
27
+ const target = await resolveClusterTarget({}, "prod", "k8s");
28
+
29
+ expect(target).toEqual({ source: "ambient" });
30
+ expect(execMock).not.toHaveBeenCalled();
31
+ expect(warnSpy).toHaveBeenCalledWith(
32
+ expect.stringMatching(/\[k8s\].*environment "prod".*k8s\.profiles\.prod\.context/s),
33
+ );
34
+ warnSpy.mockRestore();
35
+ });
36
+
37
+ test("bound and ambient context matches: returns the bound context", async () => {
38
+ execMock.mockResolvedValue({ stdout: "prod-eks\n", stderr: "" });
39
+
40
+ const target = await resolveClusterTarget(
41
+ { k8s: { profiles: { prod: { context: "prod-eks" } } } },
42
+ "prod",
43
+ "k8s",
44
+ );
45
+
46
+ expect(target).toEqual({ context: "prod-eks", source: "bound" });
47
+ expect(execMock).toHaveBeenCalledWith(expect.stringContaining("current-context"));
48
+ });
49
+
50
+ test("bound and ambient context cannot be determined: proceeds with the bound context anyway", async () => {
51
+ execMock.mockRejectedValue(new Error("no kubeconfig"));
52
+
53
+ const target = await resolveClusterTarget(
54
+ { k8s: { profiles: { prod: { context: "prod-eks" } } } },
55
+ "prod",
56
+ "k8s",
57
+ );
58
+
59
+ expect(target).toEqual({ context: "prod-eks", source: "bound" });
60
+ });
61
+
62
+ test("bound and ambient context mismatches: refuses loudly, naming env/expected/ambient", async () => {
63
+ execMock.mockResolvedValue({ stdout: "staging-eks\n", stderr: "" });
64
+
65
+ const err: unknown = await resolveClusterTarget(
66
+ { k8s: { profiles: { prod: { context: "prod-eks" } } } },
67
+ "prod",
68
+ "k8s",
69
+ ).catch((e: unknown) => e);
70
+
71
+ expect(err).toBeInstanceOf(ClusterBindingMismatchError);
72
+ const mismatch = err as InstanceType<typeof ClusterBindingMismatchError>;
73
+ expect(mismatch.environment).toBe("prod");
74
+ expect(mismatch.expectedContext).toBe("prod-eks");
75
+ expect(mismatch.ambientContext).toBe("staging-eks");
76
+ expect(mismatch.message).toContain('environment "prod"');
77
+ expect(mismatch.message).toContain('"prod-eks"');
78
+ expect(mismatch.message).toContain('"staging-eks"');
79
+ });
80
+
81
+ test("bound for a different environment than the one requested: treated as unbound for this environment", async () => {
82
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
83
+
84
+ const target = await resolveClusterTarget(
85
+ { k8s: { profiles: { staging: { context: "staging-eks" } } } },
86
+ "prod",
87
+ "k8s",
88
+ );
89
+
90
+ expect(target).toEqual({ source: "ambient" });
91
+ expect(execMock).not.toHaveBeenCalled();
92
+ warnSpy.mockRestore();
93
+ });
94
+ });
@@ -0,0 +1,207 @@
1
+ /**
2
+ * K8s environment→cluster binding — chant #1100.
3
+ *
4
+ * Every cloud lexicon binds an environment to a scope: AWS resolves `<env>`
5
+ * to a CloudFormation stack, Azure treats `<env>` as the resource group,
6
+ * Temporal looks up `temporal.profiles.<env>` in `chant.config.ts`. K8s (and
7
+ * GCP-via-Config-Connector, which observes through the same kubectl path)
8
+ * bound nothing — `describeResources` shelled out to `kubectl get` with no
9
+ * `--context`, so it read whatever cluster `kubectl config current-context`
10
+ * happened to point at. Point `prod` at a dev cluster and every declared
11
+ * resource reads as missing — a wrong-cluster diff that looks like a
12
+ * confident list of deletions.
13
+ *
14
+ * This module is the shared resolver both the k8s and gcp lexicons'
15
+ * `describeResources` call, so they resolve a cluster identity the same way
16
+ * (see `lexicons/k8s/src/config.ts`'s `K8sChantConfig` for the declared
17
+ * shape). It is intentionally provider-agnostic and lives in core (like
18
+ * `./ownership.ts`) rather than in the k8s lexicon package, since gcp's
19
+ * Config Connector observation needs it too without taking a dependency on
20
+ * the k8s lexicon.
21
+ */
22
+
23
+ import { exec } from "node:child_process";
24
+ import { promisify } from "node:util";
25
+ import type { UnobservedReason } from "./observation";
26
+
27
+ const execAsync = promisify(exec);
28
+
29
+ /** A single environment's cluster binding — see `K8sChantConfig` in the k8s lexicon. */
30
+ export interface K8sClusterProfile {
31
+ /** kubectl context name this environment is bound to. */
32
+ context: string;
33
+ }
34
+
35
+ /** Shape of the `k8s` passthrough key in `chant.config.ts` that this resolver reads. */
36
+ export interface K8sConfigShape {
37
+ profiles?: Record<string, K8sClusterProfile>;
38
+ }
39
+
40
+ /**
41
+ * Thrown when an environment declares a cluster binding but the ambient
42
+ * kubectl context disagrees with it. Refusing here — instead of silently
43
+ * observing whichever cluster is ambient — is the fix for #1100: a
44
+ * wrong-cluster read reports every declared resource as missing, which #1089
45
+ * then classifies as a confident (and wrong) list of `create` actions.
46
+ */
47
+ export class ClusterBindingMismatchError extends Error {
48
+ constructor(
49
+ public readonly environment: string,
50
+ public readonly expectedContext: string,
51
+ public readonly ambientContext: string,
52
+ ) {
53
+ super(
54
+ `k8s: environment "${environment}" is bound to cluster context "${expectedContext}" ` +
55
+ `(k8s.profiles.${environment}.context), but the ambient kubectl context is ` +
56
+ `"${ambientContext}". Refusing to observe — reading the wrong cluster would misreport ` +
57
+ `every declared resource as missing. Run \`kubectl config use-context ${expectedContext}\` ` +
58
+ `to switch, or update the binding in chant.config.ts if "${ambientContext}" is actually correct.`,
59
+ );
60
+ this.name = "ClusterBindingMismatchError";
61
+ }
62
+ }
63
+
64
+ export interface ResolvedClusterTarget {
65
+ /**
66
+ * Explicit `--context` value to pass to every kubectl invocation. Present
67
+ * only when the environment has a declared binding — undefined means
68
+ * "no binding, keep today's ambient-context behavior".
69
+ */
70
+ context?: string;
71
+ /** Where the target came from. */
72
+ source: "bound" | "ambient";
73
+ }
74
+
75
+ /** Reads `kubectl config current-context`. Returns undefined if unset or kubectl fails. */
76
+ async function currentAmbientContext(): Promise<string | undefined> {
77
+ try {
78
+ const { stdout } = await execAsync("kubectl config current-context");
79
+ const trimmed = stdout.trim();
80
+ return trimmed.length > 0 ? trimmed : undefined;
81
+ } catch {
82
+ return undefined;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Resolve the kubectl context an environment should be observed/applied
88
+ * against, reading `k8s.profiles.<environment>.context` from `chant.config.ts`
89
+ * (the `config` passed in is the passthrough `ChantConfig`, cast loosely since
90
+ * the `k8s` key isn't declared on the core schema — same pattern as
91
+ * `temporal.profiles`).
92
+ *
93
+ * - No binding declared: returns `{ source: "ambient" }` — unchanged
94
+ * behavior — but logs a visible warning identifying the caller and
95
+ * environment, so the fallback is never silent (#1100 acceptance).
96
+ * - Binding declared and the ambient context agrees (or ambient can't be
97
+ * determined): returns `{ context: bound, source: "bound" }`. Callers
98
+ * should pass this context explicitly on every kubectl invocation rather
99
+ * than relying on it also being ambient.
100
+ * - Binding declared and the ambient context disagrees: throws
101
+ * {@link ClusterBindingMismatchError} — a loud refusal instead of quietly
102
+ * reading the wrong cluster.
103
+ */
104
+ export async function resolveClusterTarget(
105
+ config: Record<string, unknown>,
106
+ environment: string,
107
+ lexiconName: string,
108
+ ): Promise<ResolvedClusterTarget> {
109
+ const k8sConfig = config.k8s as K8sConfigShape | undefined;
110
+ const bound = k8sConfig?.profiles?.[environment]?.context;
111
+
112
+ if (!bound) {
113
+ console.warn(
114
+ `[${lexiconName}] no cluster binding for environment "${environment}" ` +
115
+ `(k8s.profiles.${environment}.context in chant.config.ts) — observing whatever kubectl ` +
116
+ `context is ambient. Add a binding to pin this environment to a specific cluster (chant #1100).`,
117
+ );
118
+ return { source: "ambient" };
119
+ }
120
+
121
+ const ambient = await currentAmbientContext();
122
+ if (ambient && ambient !== bound) {
123
+ throw new ClusterBindingMismatchError(environment, bound, ambient);
124
+ }
125
+
126
+ return { context: bound, source: "bound" };
127
+ }
128
+
129
+ // ── kubectl read outcomes (#1089) ───────────────────────────────────────────
130
+
131
+ /**
132
+ * What a failed `kubectl get` actually proved. Shared by the k8s and gcp
133
+ * lexicons, which read through the same kubectl path and used to collapse every
134
+ * non-zero exit into "not there" — so an expired token, a downed API server, or
135
+ * an uninstalled CRD all classified as `create`.
136
+ */
137
+ export type KubectlReadOutcome =
138
+ /** The API server answered and the object is not there. Safe to plan a create. */
139
+ | { kind: "absent" }
140
+ /** The read proved nothing about the object's existence. */
141
+ | { kind: "unobserved"; reason: UnobservedReason; detail: string };
142
+
143
+ /** Pull whatever the child process actually said out of an exec rejection. */
144
+ function execErrorText(err: unknown): string {
145
+ if (typeof err === "object" && err !== null) {
146
+ const e = err as { stderr?: unknown; message?: unknown };
147
+ const stderr = typeof e.stderr === "string" ? e.stderr.trim() : "";
148
+ if (stderr) return stderr;
149
+ if (typeof e.message === "string") return e.message.trim();
150
+ }
151
+ return String(err);
152
+ }
153
+
154
+ /** Collapse kubectl's noise to one line for a plan/diff entry. */
155
+ function firstLine(text: string, max = 200): string {
156
+ const line = text.split("\n").find((l) => l.trim().length > 0)?.trim() ?? text.trim();
157
+ return line.length > max ? `${line.slice(0, max - 3)}...` : line;
158
+ }
159
+
160
+ /**
161
+ * Classify a `kubectl get` failure into the observation tri-state (#1089).
162
+ *
163
+ * Only a genuine `NotFound` from the API server — or a kind the server does not
164
+ * serve at all, where no instance can exist — establishes absence. Auth,
165
+ * connectivity, and unresolvable contexts establish nothing, and must reach the
166
+ * change set as NOT-OBSERVED rather than as an empty result.
167
+ */
168
+ export function classifyKubectlFailure(err: unknown): KubectlReadOutcome {
169
+ const text = execErrorText(err);
170
+ const lower = text.toLowerCase();
171
+
172
+ // The object was looked for and is not there.
173
+ if (lower.includes("notfound") || /error from server \(notfound\)/.test(lower) || lower.includes("not found")) {
174
+ return { kind: "absent" };
175
+ }
176
+ // The cluster serves no such kind, so no instance of it can exist there. The
177
+ // usual cause is a CRD this same plan has not applied yet — a real absence,
178
+ // and the case a create is for.
179
+ if (
180
+ lower.includes("the server doesn't have a resource type") ||
181
+ lower.includes("the server could not find the requested resource")
182
+ ) {
183
+ return { kind: "absent" };
184
+ }
185
+ if (
186
+ lower.includes("unauthorized") ||
187
+ lower.includes("forbidden") ||
188
+ lower.includes("you must be logged in") ||
189
+ lower.includes("invalid bearer token") ||
190
+ lower.includes("credentials")
191
+ ) {
192
+ return { kind: "unobserved", reason: "no-credentials", detail: firstLine(text) };
193
+ }
194
+ if (
195
+ lower.includes("unable to connect to the server") ||
196
+ lower.includes("connection refused") ||
197
+ lower.includes("no configuration has been provided") ||
198
+ lower.includes("did you specify the right host or port") ||
199
+ lower.includes("context was not found") ||
200
+ /context ".*" does not exist/.test(lower) ||
201
+ lower.includes("no such host") ||
202
+ lower.includes("i/o timeout")
203
+ ) {
204
+ return { kind: "unobserved", reason: "no-binding", detail: firstLine(text) };
205
+ }
206
+ return { kind: "unobserved", reason: "read-failed", detail: firstLine(text) };
207
+ }
package/src/lexicon.ts CHANGED
@@ -11,11 +11,23 @@ import type { DriverComponent } from "./components/driver";
11
11
  import type { EmulatorCapability } from "./op/emulator-lifecycle";
12
12
  import type { RuleMeta } from "./audit/catalog";
13
13
  import type { ReferenceCatalog } from "./graph-refs";
14
+ import type { DescribeResourcesResult } from "./observation";
14
15
 
15
16
  // Re-exported so lexicons can author a reference catalog (#778) from the same
16
17
  // `@intentius/chant/lexicon` entry they import the plugin contract from.
17
18
  export type { ReferenceCatalog, IdentityRule, RefRule } from "./graph-refs";
18
19
 
20
+ // The observation contract (#1089), re-exported from the same entry so a
21
+ // lexicon's `describeResources` can report NOT-OBSERVED without a second
22
+ // import path. Runtime helpers live in `@intentius/chant/observation`.
23
+ export type {
24
+ DescribeResourcesResult,
25
+ ObservationResult,
26
+ NormalizedObservation,
27
+ UnobservedEntity,
28
+ UnobservedReason,
29
+ } from "./observation";
30
+
19
31
  /**
20
32
  * Manifest for a packaged lexicon — metadata embedded in the tarball.
21
33
  *
@@ -507,6 +519,27 @@ export interface LexiconPlugin {
507
519
  * Use this when each chant entity has a 1:1 cloud equivalent — e.g. an
508
520
  * AWS CFN resource, a K8s object, an ARM resource, a Temporal namespace.
509
521
  *
522
+ * **The observation contract (#1089).** Returning nothing for a declared
523
+ * entity is a claim, and there are two different claims to make. Either the
524
+ * provider was asked and reported the resource absent — which is what lets
525
+ * the change set propose `create` — or the lexicon never looked, which must
526
+ * not. An implementation that has a "did not look" case (no reader for the
527
+ * kind, the read errored, no credentials, no cluster binding) must return the
528
+ * {@link ObservationResult} envelope and name those entities in `unobserved`
529
+ * with a total {@link UnobservedReason}. Warning on stderr is not enough: a
530
+ * warning is invisible to `lifecycle plan`, which is where the wrong `create`
531
+ * gets proposed. Returning the bare `name → ResourceMetadata` map is still
532
+ * valid and means "everything I was asked about, I looked at".
533
+ *
534
+ * Throwing is the whole-lexicon failure (see the k8s cluster-binding refusal,
535
+ * #1100): core catches it and marks every declared entity NOT-OBSERVED with
536
+ * `read-failed`, so a failed read is never a list of creates.
537
+ *
538
+ * Ownership verdicts are total (#1089). When `owned` is requested and the
539
+ * lexicon has no marker channel on this path, it must stamp
540
+ * `ownership: "unknown"` on what it returns rather than degrading silently —
541
+ * the change set never escalates `unknown` to a `delete`.
542
+ *
510
543
  * `entities` carries the chant-side entity declarations for this lexicon,
511
544
  * keyed by chant entity name (e.g. the export name from a `*.ts` file).
512
545
  * Implementations that need to map cloud-side names back to chant entity
@@ -536,7 +569,7 @@ export interface LexiconPlugin {
536
569
  * everything.
537
570
  */
538
571
  owned?: boolean;
539
- }): Promise<Record<string, ResourceMetadata>>;
572
+ }): Promise<DescribeResourcesResult>;
540
573
 
541
574
  /**
542
575
  * Report the live status of one deploy unit by its deployed name. Opt-in.
@@ -683,12 +716,14 @@ export interface ResourceMetadata {
683
716
  /** Cloud-assigned output properties */
684
717
  attributes?: Record<string, unknown>;
685
718
  /**
686
- * Live ownership verdict from the resource's marker (#119/#120), when the
687
- * lexicon could determine it. `owned` = carries chant's marker; `foreign` =
688
- * no marker. Absent = the lexicon has no marker channel here. The change set
689
- * reads thisnever the snapshot to decide whether an orphan is a delete.
719
+ * Live ownership verdict from the resource's marker (#119/#120). `owned` =
720
+ * carries chant's marker; `foreign` = no marker; `unknown` = the lexicon has
721
+ * no marker channel on this read path and says so rather than degrading
722
+ * silently (#1089verdicts are total). Absent is read as `unknown`. The
723
+ * change set reads this — never the snapshot — to decide whether an orphan is
724
+ * a delete, and never escalates `unknown` to one.
690
725
  */
691
- ownership?: "owned" | "foreign";
726
+ ownership?: "owned" | "foreign" | "unknown";
692
727
  }
693
728
 
694
729
  /**
@@ -17,7 +17,7 @@ describe("buildChangeSet (#118)", () => {
17
17
  });
18
18
  const e = cs.entries.find((x) => x.name === "bucket")!;
19
19
  expect(e.action).toBe("create");
20
- expect(e.evidence).toEqual({ declared: true, inSnapshot: false, live: false });
20
+ expect(e.evidence).toEqual({ declared: true, inSnapshot: false, live: false, observed: true });
21
21
  expect(e.ownership).toBe("unknown");
22
22
  });
23
23
 
@@ -187,3 +187,95 @@ describe("gitlabMrReport (#329)", () => {
187
187
  expect(gitlabMrReport(cs)).toEqual({ create: 0, update: 0, delete: 0 });
188
188
  });
189
189
  });
190
+
191
+ // ── The observation tri-state (#1089) ───────────────────────────────────────
192
+
193
+ describe("buildChangeSet: not-observed is not absent (#1089)", () => {
194
+ test("declared and not observed → unobserved, never create", () => {
195
+ const cs = buildChangeSet("prod", {
196
+ declared: new Set(["crd-widget"]),
197
+ observedNow: {},
198
+ observedThen: undefined,
199
+ unobserved: {
200
+ "crd-widget": {
201
+ type: "K8s::Example::Widget",
202
+ reason: "unsupported-kind",
203
+ detail: "no kubectl mapping",
204
+ },
205
+ },
206
+ });
207
+ const e = cs.entries.find((x) => x.name === "crd-widget")!;
208
+ expect(e.action).toBe("unobserved");
209
+ expect(e.evidence).toEqual({ declared: true, inSnapshot: false, live: false, observed: false });
210
+ expect(e.unobservedReason).toBe("unsupported-kind");
211
+ expect(e.type).toBe("K8s::Example::Widget");
212
+ });
213
+
214
+ test("the same entity, confirmed absent, still classifies as create", () => {
215
+ const cs = buildChangeSet("prod", {
216
+ declared: new Set(["crd-widget"]),
217
+ observedNow: {},
218
+ observedThen: undefined,
219
+ });
220
+ expect(cs.entries.find((x) => x.name === "crd-widget")!.action).toBe("create");
221
+ });
222
+
223
+ test("a returned resource wins over an unobserved claim for the same name", () => {
224
+ const cs = buildChangeSet("prod", {
225
+ declared: new Set(["queue"]),
226
+ observedNow: { queue: meta() },
227
+ observedThen: undefined,
228
+ unobserved: { queue: { reason: "read-failed" } },
229
+ });
230
+ const e = cs.entries.find((x) => x.name === "queue")!;
231
+ expect(e.action).toBe("noop");
232
+ expect(e.evidence.observed).toBe(true);
233
+ });
234
+
235
+ test("an unobserved entity that is in the snapshot is not read as gone", () => {
236
+ const cs = buildChangeSet("prod", {
237
+ declared: new Set(["queue"]),
238
+ observedNow: {},
239
+ observedThen: { queue: meta() },
240
+ unobserved: { queue: { reason: "no-credentials" } },
241
+ });
242
+ const e = cs.entries.find((x) => x.name === "queue")!;
243
+ expect(e.action).toBe("unobserved");
244
+ expect(e.evidence.inSnapshot).toBe(true);
245
+ });
246
+
247
+ test("an unobserved entity is never a delete, even with an owned marker in the snapshot", () => {
248
+ const cs = buildChangeSet("prod", {
249
+ declared: new Set(),
250
+ observedNow: {},
251
+ observedThen: { legacy: meta({ ownership: "owned" }) },
252
+ unobserved: { legacy: { reason: "read-failed" } },
253
+ });
254
+ expect(cs.entries.find((x) => x.name === "legacy")!.action).toBe("unobserved");
255
+ });
256
+
257
+ test("summarize and render surface the hole", () => {
258
+ const cs = buildChangeSet("prod", {
259
+ declared: new Set(["a"]),
260
+ observedNow: {},
261
+ observedThen: undefined,
262
+ unobserved: { a: { reason: "no-binding", detail: "no kubectl context for prod" } },
263
+ });
264
+ expect(summarize(cs).unobserved).toBe(1);
265
+ expect(summarize(cs).create).toBe(0);
266
+ const out = renderChangeSet(cs);
267
+ expect(out).toContain("UNOBSERVED");
268
+ expect(out).toContain("no binding for this environment");
269
+ expect(out).toContain("no kubectl context for prod");
270
+ });
271
+
272
+ test("the GitLab widget excludes unobserved — its three columns cannot express a hole", () => {
273
+ const cs = buildChangeSet("prod", {
274
+ declared: new Set(["a"]),
275
+ observedNow: {},
276
+ observedThen: undefined,
277
+ unobserved: { a: { reason: "read-failed" } },
278
+ });
279
+ expect(gitlabMrReport(cs)).toEqual({ create: 0, update: 0, delete: 0 });
280
+ });
281
+ });
@@ -13,11 +13,12 @@
13
13
  * load-bearing.
14
14
  */
15
15
  import { diffLive, type AttributeChange, type DiffLiveInput } from "./live-diff";
16
+ import { unobservedReasonText, type UnobservedReason } from "../observation";
16
17
 
17
18
  /**
18
19
  * What the projection proposes for a single resource.
19
20
  *
20
- * - `create` — declared in source, absent from live.
21
+ * - `create` — declared in source, and the provider **confirmed** it absent.
21
22
  * - `update` — declared and live, but live config drifted.
22
23
  * - `delete` — a chant-owned resource that is live but no longer declared.
23
24
  * Only emitted once ownership is known (#121); never inferred from the
@@ -25,8 +26,11 @@ import { diffLive, type AttributeChange, type DiffLiveInput } from "./live-diff"
25
26
  * - `adopt` — live but undeclared, ownership not established → a candidate to
26
27
  * pull back into source, never an auto-delete.
27
28
  * - `noop` — declared and live with no drift, or already reconciled.
29
+ * - `unobserved` — declared, and the lexicon could not look (#1089). Not a
30
+ * proposal at all: it is the plan admitting a hole. Never a create, never a
31
+ * delete. Read `unobservedReason` for which hole.
28
32
  */
29
- export type ChangeAction = "create" | "update" | "delete" | "adopt" | "noop";
33
+ export type ChangeAction = "create" | "update" | "delete" | "adopt" | "noop" | "unobserved";
30
34
 
31
35
  /**
32
36
  * Who answers "is this resource chant's?". `unknown` until a live ownership
@@ -47,13 +51,24 @@ export interface ChangeSetEntry {
47
51
  declared: boolean;
48
52
  /** Present in the last snapshot. */
49
53
  inSnapshot: boolean;
50
- /** Observed in the live system right now. */
54
+ /** Observed present in the live system right now. */
51
55
  live: boolean;
56
+ /**
57
+ * The lexicon actually looked at this entity (#1089). `false` with
58
+ * `live: false` means "unknown", not "absent" — the distinction the whole
59
+ * change set now rests on. Absent-and-looked-at is `observed: true,
60
+ * live: false`.
61
+ */
62
+ observed: boolean;
52
63
  };
53
64
  /** Attribute-level changes, for `update`. */
54
65
  deltas?: AttributeChange[];
55
66
  /** Live-marker ownership. Defaults to `unknown`. */
56
67
  ownership: Ownership;
68
+ /** Why the entity could not be observed, for `action: "unobserved"` (#1089). */
69
+ unobservedReason?: UnobservedReason;
70
+ /** Human-readable backing for `unobservedReason` (the failing command, the missing binding). */
71
+ unobservedDetail?: string;
57
72
  }
58
73
 
59
74
  export interface ChangeSet {
@@ -67,11 +82,16 @@ export interface ChangeSet {
67
82
  * `create`/`update` are precise from declared-vs-live. `delete` is never
68
83
  * emitted here — an undeclared live resource classifies as `adopt` until
69
84
  * ownership is known.
85
+ *
86
+ * A declared entity the lexicon could not observe (`input.unobserved`, #1089)
87
+ * classifies as `unobserved` and nothing else: no `create` is ever synthesized
88
+ * from a read that did not happen.
70
89
  */
71
90
  export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
72
91
  const diff = diffLive(input);
73
92
  const { declared, observedNow } = input;
74
93
  const observedThen = input.observedThen ?? {};
94
+ const unobservedInput = input.unobserved ?? {};
75
95
 
76
96
  const driftByName = new Map(
77
97
  diff.driftedSinceSnapshot.map((d) => [d.name, d.changes] as const),
@@ -81,6 +101,7 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
81
101
  ...declared,
82
102
  ...Object.keys(observedNow),
83
103
  ...Object.keys(observedThen),
104
+ ...Object.keys(unobservedInput),
84
105
  ]);
85
106
 
86
107
  const entries: ChangeSetEntry[] = [];
@@ -88,8 +109,11 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
88
109
  const isDeclared = declared.has(name);
89
110
  const live = Object.prototype.hasOwnProperty.call(observedNow, name);
90
111
  const inSnapshot = Object.prototype.hasOwnProperty.call(observedThen, name);
91
- const type = observedNow[name]?.type ?? observedThen[name]?.type;
92
- const evidence = { declared: isDeclared, inSnapshot, live };
112
+ // A returned resource was observed by definition; `unobserved` only counts
113
+ // for entities the lexicon did not return.
114
+ const unobservedEntry = live ? undefined : unobservedInput[name];
115
+ const type = observedNow[name]?.type ?? observedThen[name]?.type ?? unobservedEntry?.type;
116
+ const evidence = { declared: isDeclared, inSnapshot, live, observed: !unobservedEntry };
93
117
 
94
118
  // Ownership comes from the LIVE marker only (carried on observedNow), never
95
119
  // from the snapshot. This is the invariant that keeps the snapshot from
@@ -100,8 +124,13 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
100
124
  let action: ChangeAction;
101
125
  let deltas: AttributeChange[] | undefined;
102
126
 
103
- if (isDeclared && !live) {
104
- // Declared in source, not in the cloud → create.
127
+ if (unobservedEntry) {
128
+ // The lexicon never looked. Absence is not established, so neither a
129
+ // create (declared) nor a delete/adopt (undeclared) can be proposed —
130
+ // the entry exists to say the plan has a hole here.
131
+ action = "unobserved";
132
+ } else if (isDeclared && !live) {
133
+ // Declared in source, and the provider confirmed it absent → create.
105
134
  action = "create";
106
135
  } else if (isDeclared && live) {
107
136
  const drift = driftByName.get(name);
@@ -120,14 +149,27 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
120
149
  action = "noop";
121
150
  }
122
151
 
123
- entries.push({ name, type, action, evidence, deltas, ownership });
152
+ entries.push({
153
+ name,
154
+ type,
155
+ action,
156
+ evidence,
157
+ deltas,
158
+ ownership,
159
+ ...(unobservedEntry
160
+ ? {
161
+ unobservedReason: unobservedEntry.reason,
162
+ ...(unobservedEntry.detail ? { unobservedDetail: unobservedEntry.detail } : {}),
163
+ }
164
+ : {}),
165
+ });
124
166
  }
125
167
 
126
168
  entries.sort((a, b) => a.name.localeCompare(b.name));
127
169
  return { env, entries };
128
170
  }
129
171
 
130
- const ACTION_ORDER: ChangeAction[] = ["create", "update", "delete", "adopt", "noop"];
172
+ const ACTION_ORDER: ChangeAction[] = ["create", "update", "delete", "adopt", "noop", "unobserved"];
131
173
 
132
174
  /** Count entries per action. */
133
175
  export function summarize(cs: ChangeSet): Record<ChangeAction, number> {
@@ -137,6 +179,7 @@ export function summarize(cs: ChangeSet): Record<ChangeAction, number> {
137
179
  delete: 0,
138
180
  adopt: 0,
139
181
  noop: 0,
182
+ unobserved: 0,
140
183
  };
141
184
  for (const e of cs.entries) counts[e.action]++;
142
185
  return counts;
@@ -148,8 +191,10 @@ export function summarize(cs: ChangeSet): Record<ChangeAction, number> {
148
191
  * GitLab renders an `artifacts:reports:terraform` artifact in the merge-request
149
192
  * UI as "N to add, M to change, K to delete". The format is generic — any tool
150
193
  * that emits this JSON gets the widget — and the chant plan maps onto it
151
- * directly. Only the mutating actions count: `adopt` and `noop` are excluded,
152
- * since the widget has no column for "live but undeclared" or "no change".
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.
153
198
  *
154
199
  * The widget label reads "Terraform" regardless of producer; that is GitLab's
155
200
  * fixed string, not a claim chant makes.
@@ -175,10 +220,17 @@ export function renderChangeSet(cs: ChangeSet): string {
175
220
  for (const action of ACTION_ORDER) {
176
221
  const group = cs.entries.filter((e) => e.action === action);
177
222
  if (group.length === 0) continue;
178
- lines.push(`\n${action.toUpperCase()}:`);
223
+ lines.push(
224
+ action === "unobserved"
225
+ ? "\nUNOBSERVED (declared; chant could not read live state — no action proposed):"
226
+ : `\n${action.toUpperCase()}:`,
227
+ );
179
228
  for (const e of group) {
180
229
  const own = e.ownership === "unknown" ? "" : ` [${e.ownership}]`;
181
- lines.push(` ${e.name}${e.type ? ` (${e.type})` : ""}${own}`);
230
+ const why = e.unobservedReason
231
+ ? ` — ${unobservedReasonText(e.unobservedReason)}${e.unobservedDetail ? `: ${e.unobservedDetail}` : ""}`
232
+ : "";
233
+ lines.push(` ${e.name}${e.type ? ` (${e.type})` : ""}${own}${why}`);
182
234
  for (const d of e.deltas ?? []) {
183
235
  lines.push(` ${d.path}: ${fmt(d.oldValue)} → ${fmt(d.newValue)}`);
184
236
  }