@intentius/chant 0.28.0 → 0.30.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/handlers/components.d.ts.map +1 -1
  2. package/dist/cli/handlers/graph.d.ts.map +1 -1
  3. package/dist/cli/handlers/lifecycle.d.ts +5 -3
  4. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  5. package/dist/cli/main.d.ts.map +1 -1
  6. package/dist/cli/registry.d.ts +14 -0
  7. package/dist/cli/registry.d.ts.map +1 -1
  8. package/dist/config.d.ts +46 -4
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/deep-observation.d.ts +257 -0
  11. package/dist/deep-observation.d.ts.map +1 -0
  12. package/dist/discovery/fold-import.d.ts +153 -17
  13. package/dist/discovery/fold-import.d.ts.map +1 -1
  14. package/dist/discovery/sandbox/config-wire.d.ts +3 -2
  15. package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
  16. package/dist/env.d.ts +5 -2
  17. package/dist/env.d.ts.map +1 -1
  18. package/dist/fold/fold.d.ts +35 -3
  19. package/dist/fold/fold.d.ts.map +1 -1
  20. package/dist/fold/subset.d.ts +9 -0
  21. package/dist/fold/subset.d.ts.map +1 -1
  22. package/dist/graph-ir.d.ts +73 -4
  23. package/dist/graph-ir.d.ts.map +1 -1
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/kubectl-context.d.ts +27 -0
  27. package/dist/kubectl-context.d.ts.map +1 -1
  28. package/dist/lexicon.d.ts +78 -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/deep-diff.d.ts +103 -0
  33. package/dist/lifecycle/deep-diff.d.ts.map +1 -0
  34. package/dist/lifecycle/deep-observe.d.ts +62 -0
  35. package/dist/lifecycle/deep-observe.d.ts.map +1 -0
  36. package/dist/lifecycle/index.d.ts +3 -0
  37. package/dist/lifecycle/index.d.ts.map +1 -1
  38. package/dist/lifecycle/live-diff.d.ts +25 -1
  39. package/dist/lifecycle/live-diff.d.ts.map +1 -1
  40. package/dist/lifecycle/observation-baseline.d.ts +118 -0
  41. package/dist/lifecycle/observation-baseline.d.ts.map +1 -0
  42. package/dist/lifecycle/observe.d.ts +4 -2
  43. package/dist/lifecycle/observe.d.ts.map +1 -1
  44. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  45. package/dist/lifecycle/status.d.ts +26 -1
  46. package/dist/lifecycle/status.d.ts.map +1 -1
  47. package/dist/lifecycle/types.d.ts +8 -0
  48. package/dist/lifecycle/types.d.ts.map +1 -1
  49. package/dist/live-endpoint.d.ts +92 -0
  50. package/dist/live-endpoint.d.ts.map +1 -0
  51. package/dist/observation.d.ts +123 -0
  52. package/dist/observation.d.ts.map +1 -0
  53. package/dist/stack-output.d.ts.map +1 -1
  54. package/package.json +1 -1
  55. package/src/cli/handlers/components.test.ts +63 -4
  56. package/src/cli/handlers/components.ts +78 -35
  57. package/src/cli/handlers/graph.test.ts +155 -6
  58. package/src/cli/handlers/graph.ts +124 -29
  59. package/src/cli/handlers/lifecycle.test.ts +410 -6
  60. package/src/cli/handlers/lifecycle.ts +475 -182
  61. package/src/cli/main.test.ts +6 -0
  62. package/src/cli/main.ts +12 -0
  63. package/src/cli/registry.ts +14 -0
  64. package/src/config.test.ts +75 -0
  65. package/src/config.ts +61 -3
  66. package/src/deep-observation.test.ts +234 -0
  67. package/src/deep-observation.ts +489 -0
  68. package/src/discovery/fold-composite.test.ts +594 -0
  69. package/src/discovery/fold-import.test.ts +372 -1
  70. package/src/discovery/fold-import.ts +1216 -116
  71. package/src/discovery/sandbox/config-wire.ts +3 -2
  72. package/src/env.test.ts +12 -0
  73. package/src/env.ts +12 -4
  74. package/src/fold/fold.test.ts +105 -0
  75. package/src/fold/fold.ts +100 -20
  76. package/src/fold/subset.test.ts +38 -7
  77. package/src/fold/subset.ts +9 -0
  78. package/src/graph-ir-live.test.ts +28 -1
  79. package/src/graph-ir.ts +115 -12
  80. package/src/index.ts +2 -0
  81. package/src/kubectl-context.ts +81 -0
  82. package/src/lexicon.ts +100 -6
  83. package/src/lifecycle/change-set.test.ts +93 -1
  84. package/src/lifecycle/change-set.ts +65 -13
  85. package/src/lifecycle/deep-diff.test.ts +157 -0
  86. package/src/lifecycle/deep-diff.ts +213 -0
  87. package/src/lifecycle/deep-observe.test.ts +174 -0
  88. package/src/lifecycle/deep-observe.ts +173 -0
  89. package/src/lifecycle/index.ts +3 -0
  90. package/src/lifecycle/live-diff.test.ts +39 -0
  91. package/src/lifecycle/live-diff.ts +51 -5
  92. package/src/lifecycle/observation-baseline.test.ts +99 -0
  93. package/src/lifecycle/observation-baseline.ts +217 -0
  94. package/src/lifecycle/observe.test.ts +74 -3
  95. package/src/lifecycle/observe.ts +82 -22
  96. package/src/lifecycle/snapshot.test.ts +39 -1
  97. package/src/lifecycle/snapshot.ts +40 -20
  98. package/src/lifecycle/status.test.ts +89 -8
  99. package/src/lifecycle/status.ts +53 -3
  100. package/src/lifecycle/types.ts +8 -0
  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 +55 -0
  106. package/src/stack-output.ts +41 -20
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect, vi, beforeEach } from "vitest";
2
- import { createMockPlugin, staticDescribeResources, staticListArtifacts } from "@intentius/chant-test-utils";
2
+ import { createMockPlugin, staticDescribeResources, staticObservation, staticListArtifacts } from "@intentius/chant-test-utils";
3
3
  import type { BuildResult } from "../build";
4
4
 
5
5
  const writeSnapshotMock = vi.fn();
@@ -114,6 +114,44 @@ describe("takeSnapshot", () => {
114
114
  expect(result.errors.some((e) => e.includes("aws") && e.includes("no valid"))).toBe(true);
115
115
  });
116
116
 
117
+ // #1089 — a snapshot is evidence of what was seen. An entity nobody could
118
+ // read must be recorded as a hole, not omitted (which the next diff would
119
+ // read back as "was not there").
120
+ test("records unobserved entities alongside the resources", async () => {
121
+ const plugin = createMockPlugin({
122
+ name: "k8s",
123
+ describeResources: staticObservation(
124
+ { web: { type: "K8s::Apps::Deployment", status: "READY" } },
125
+ { widget: { type: "K8s::X::Widget", reason: "unsupported-kind", detail: "no reader" } },
126
+ ),
127
+ });
128
+ const result = await takeSnapshot("prod", [plugin], makeBuildResult({ k8s: ["web", "widget"] }));
129
+ expect(result.snapshots[0].unobserved).toEqual({
130
+ widget: { type: "K8s::X::Widget", reason: "unsupported-kind", detail: "no reader" },
131
+ });
132
+ expect(result.warnings.some((w) => w.includes("widget") && w.includes("not observed"))).toBe(true);
133
+ });
134
+
135
+ test("an entirely unreadable environment is not snapshotted as empty", async () => {
136
+ const plugin = createMockPlugin({
137
+ name: "k8s",
138
+ describeResources: staticObservation({}, { web: { reason: "no-credentials" } }),
139
+ });
140
+ const result = await takeSnapshot("prod", [plugin], makeBuildResult({ k8s: ["web"] }));
141
+ expect(result.snapshots).toEqual([]);
142
+ expect(result.errors.some((e) => e.includes("could not be read"))).toBe(true);
143
+ });
144
+
145
+ test("a throwing plugin reports each declared entity as unobserved in the warnings", async () => {
146
+ const plugin = createMockPlugin({
147
+ name: "k8s",
148
+ describeResources: async () => { throw new Error("kubeconfig missing"); },
149
+ });
150
+ const result = await takeSnapshot("prod", [plugin], makeBuildResult({ k8s: ["web"] }));
151
+ expect(result.snapshots).toEqual([]);
152
+ expect(result.warnings.some((w) => w.includes("web") && w.includes("kubeconfig missing"))).toBe(true);
153
+ });
154
+
117
155
  test("resources missing required type/status are dropped with warning", async () => {
118
156
  const plugin = createMockPlugin({
119
157
  name: "aws",
@@ -9,16 +9,12 @@ import type { LifecycleSnapshot } from "./types";
9
9
  import { computeBuildDigest } from "./digest";
10
10
  import { writeSnapshot, snapshotStorageKey, getHeadCommit, pushLifecycle } from "./git";
11
11
  import { sortedJsonReplacer } from "../utils";
12
-
13
- /** Patterns in attribute names that suggest sensitive data. */
14
- const SENSITIVE_PATTERNS = [
15
- /password/i,
16
- /secret/i,
17
- /token/i,
18
- /private.?key/i,
19
- /credential/i,
20
- /connection.?string/i,
21
- ];
12
+ import { formatUnobserved, normalizeObservation, unobservedAll, type UnobservedEntity } from "../observation";
13
+ // One list of secret-bearing property names for both observation depths
14
+ // (#1014): the thin path warns on them here, the deep path masks them before a
15
+ // property tree is ever rendered or committed. Two lists would eventually
16
+ // disagree about what counts as a secret.
17
+ import { isSensitiveKey } from "../deep-observation";
22
18
 
23
19
  /**
24
20
  * Check for potential sensitive data in resource attributes and return warnings.
@@ -30,7 +26,7 @@ function checkSensitiveData(
30
26
  for (const [name, meta] of Object.entries(resources)) {
31
27
  if (!meta.attributes) continue;
32
28
  for (const attrName of Object.keys(meta.attributes)) {
33
- if (SENSITIVE_PATTERNS.some((p) => p.test(attrName))) {
29
+ if (isSensitiveKey(attrName)) {
34
30
  warnings.push(
35
31
  `Potential sensitive data in ${name}.attributes.${attrName} — ensure it is scrubbed`,
36
32
  );
@@ -124,22 +120,32 @@ export async function takeSnapshot(
124
120
 
125
121
  let resources: Record<string, ResourceMetadata> = {};
126
122
  let artifacts: Record<string, ArtifactMetadata> = {};
123
+ let unobserved: Record<string, UnobservedEntity> = {};
127
124
 
128
125
  try {
129
126
  if (plugin.describeResources) {
130
- const raw = await plugin.describeResources({
131
- environment,
132
- buildOutput,
133
- entityNames,
134
- entities,
135
- stack,
136
- });
137
- const { valid, dropped, warnings: validationWarnings } = validateResources(raw);
127
+ const observed = normalizeObservation(
128
+ await plugin.describeResources({
129
+ environment,
130
+ buildOutput,
131
+ entityNames,
132
+ entities,
133
+ stack,
134
+ }),
135
+ );
136
+ const { valid, dropped, warnings: validationWarnings } = validateResources(observed.resources);
138
137
  warnings.push(...validationWarnings);
139
138
  if (dropped.length > 0) {
140
139
  warnings.push(`${plugin.name}: dropped ${dropped.length} invalid resource(s)`);
141
140
  }
142
141
  resources = valid;
142
+ // Record the holes (#1089). A snapshot is evidence of what was seen; an
143
+ // entity nobody could read must not be recorded as "was not there",
144
+ // because the next diff would then read it back as absent.
145
+ unobserved = observed.unobserved;
146
+ for (const [name, entry] of Object.entries(unobserved)) {
147
+ warnings.push(`${plugin.name}: not observed — ${formatUnobserved(name, entry)}`);
148
+ }
143
149
  }
144
150
 
145
151
  if (plugin.listArtifacts) {
@@ -153,7 +159,12 @@ export async function takeSnapshot(
153
159
  }
154
160
 
155
161
  if (Object.keys(resources).length === 0 && Object.keys(artifacts).length === 0) {
156
- errors.push(`${plugin.name}: no valid resources or artifacts returned`);
162
+ const unreadable = Object.keys(unobserved).length;
163
+ errors.push(
164
+ unreadable > 0
165
+ ? `${plugin.name}: nothing observed — ${unreadable} declared entity(ies) could not be read (see warnings); not snapshotting an unread environment as empty`
166
+ : `${plugin.name}: no valid resources or artifacts returned`,
167
+ );
157
168
  continue;
158
169
  }
159
170
 
@@ -164,15 +175,24 @@ export async function takeSnapshot(
164
175
  commit: headCommit,
165
176
  timestamp,
166
177
  resources,
178
+ ...(Object.keys(unobserved).length > 0 && { unobserved }),
167
179
  ...(Object.keys(artifacts).length > 0 && { artifacts }),
168
180
  digest,
169
181
  };
170
182
 
171
183
  snapshots.push(snapshot);
172
184
  } catch (err) {
185
+ // A thrown read is not an empty environment — record nothing and say so
186
+ // (#1089). Writing a snapshot here would persist "none of this exists".
173
187
  errors.push(
174
188
  `${plugin.name}: ${err instanceof Error ? err.message : String(err)}`,
175
189
  );
190
+ const message = err instanceof Error ? err.message : String(err);
191
+ for (const [name, entry] of Object.entries(
192
+ unobservedAll(entityNames, "read-failed", message, entities),
193
+ )) {
194
+ warnings.push(`${plugin.name}: not observed — ${formatUnobserved(name, entry)}`);
195
+ }
176
196
  }
177
197
  }
178
198
 
@@ -32,8 +32,8 @@ describe("status", () => {
32
32
  const cs: ChangeSet = {
33
33
  env: "prod",
34
34
  entries: [
35
- { name: "search-service", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true }, ownership: "owned" },
36
- { name: "orphan-thing", type: "T", action: "adopt", evidence: { declared: false, inSnapshot: false, live: true }, ownership: "foreign" },
35
+ { name: "search-service", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true, observed: true }, ownership: "owned" },
36
+ { name: "orphan-thing", type: "T", action: "adopt", evidence: { declared: false, inSnapshot: false, live: true, observed: true }, ownership: "foreign" },
37
37
  ],
38
38
  };
39
39
  const evidence = liveEvidenceFromChangeSet(cs);
@@ -49,7 +49,7 @@ describe("status", () => {
49
49
  const cs: ChangeSet = {
50
50
  env: "prod",
51
51
  entries: [
52
- { name: "search-service-v2", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true }, ownership: "owned" },
52
+ { name: "search-service-v2", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true, observed: true }, ownership: "owned" },
53
53
  ],
54
54
  };
55
55
  const mapping: LiveNameMapping = new Map([["search-svc", ["search-service-v2"]]]);
@@ -63,7 +63,7 @@ describe("status", () => {
63
63
  const cs: ChangeSet = {
64
64
  env: "prod",
65
65
  entries: [
66
- { name: "search-service", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true }, ownership: "owned" },
66
+ { name: "search-service", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true, observed: true }, ownership: "owned" },
67
67
  ],
68
68
  };
69
69
  const mapping: LiveNameMapping = new Map([["some-other-component", ["renamed-thing"]]]);
@@ -75,8 +75,8 @@ describe("status", () => {
75
75
  const cs: ChangeSet = {
76
76
  env: "prod",
77
77
  entries: [
78
- { name: "cluster-node-1", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true }, ownership: "owned" },
79
- { name: "cluster-node-2", type: "T", action: "update", evidence: { declared: true, inSnapshot: true, live: true }, ownership: "owned" },
78
+ { name: "cluster-node-1", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true, observed: true }, ownership: "owned" },
79
+ { name: "cluster-node-2", type: "T", action: "update", evidence: { declared: true, inSnapshot: true, live: true, observed: true }, ownership: "owned" },
80
80
  ],
81
81
  };
82
82
  const mapping: LiveNameMapping = new Map([["neo4j-cluster", ["cluster-node-1", "cluster-node-2"]]]);
@@ -297,7 +297,7 @@ describe("status", () => {
297
297
  const cs: ChangeSet = {
298
298
  env: "prod",
299
299
  entries: [
300
- { name: "search-service-v2", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true }, ownership: "owned" },
300
+ { name: "search-service-v2", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true, observed: true }, ownership: "owned" },
301
301
  ],
302
302
  };
303
303
  const mapping: LiveNameMapping = new Map([["search-svc", ["search-service-v2"]]]);
@@ -311,7 +311,7 @@ describe("status", () => {
311
311
  const cs: ChangeSet = {
312
312
  env: "prod",
313
313
  entries: [
314
- { name: "search-service", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true }, ownership: "owned" },
314
+ { name: "search-service", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true, observed: true }, ownership: "owned" },
315
315
  ],
316
316
  };
317
317
  const liveEvidence = liveEvidenceFromChangeSet(cs);
@@ -381,5 +381,86 @@ describe("status", () => {
381
381
  const merged = mergeLiveEvidence(undefined, supplement);
382
382
  expect(merged.get("c")).toEqual({ live: true, ownership: "owned", action: undefined });
383
383
  });
384
+
385
+ test("a direct stack observation clears an inherited hole; a failing one keeps it (#1089)", () => {
386
+ const base = new Map<string, LiveComponentEvidence>([
387
+ ["a", { live: false, unobserved: { reason: "read-failed" } }],
388
+ ["b", { live: false, unobserved: { reason: "read-failed" } }],
389
+ ]);
390
+ const supplement = new Map<string, LiveComponentEvidence>([
391
+ ["a", { live: true, ownership: "owned" }],
392
+ ["b", { live: false, unobserved: { reason: "read-failed", detail: "no determinate status" } }],
393
+ ]);
394
+ const merged = mergeLiveEvidence(base, supplement);
395
+ expect(merged.get("a")!.unobserved).toBeUndefined();
396
+ expect(merged.get("b")!.unobserved?.reason).toBe("read-failed");
397
+ });
398
+ });
399
+
400
+ // ── The observation tri-state reaches the status join (#1089) ─────────────
401
+
402
+ describe("not-observed never becomes 'stale' (#1089)", () => {
403
+ const record = {
404
+ version: 1,
405
+ component: "search-svc",
406
+ env: "prod",
407
+ digest: "sha256:abc",
408
+ gitSha: "g",
409
+ runId: "r",
410
+ timestamp: "2026-01-01T00:00:00Z",
411
+ actor: "ci",
412
+ } as const;
413
+
414
+ test("a recorded component whose live state could not be read reports unknown", () => {
415
+ const rows = reconcileStatus("prod", [record], {
416
+ liveEvidence: new Map<string, LiveComponentEvidence>([
417
+ ["search-svc", { live: false, unobserved: { reason: "no-binding", detail: "no kubectl context" } }],
418
+ ]),
419
+ });
420
+ expect(rows[0].reconciliation).toBe("unknown");
421
+ expect(rows[0].detail).toContain("could not be observed");
422
+ expect(rows[0].detail).toContain("no kubectl context");
423
+ // `live` is omitted entirely — `false` would read as "not deployed".
424
+ expect(rows[0].live).toBeUndefined();
425
+ expect(rows[0].unobserved).toEqual({ reason: "no-binding", detail: "no kubectl context" });
426
+ });
427
+
428
+ test("the same component, actually observed absent, still reports stale", () => {
429
+ const rows = reconcileStatus("prod", [record], {
430
+ liveEvidence: new Map<string, LiveComponentEvidence>([["search-svc", { live: false }]]),
431
+ });
432
+ expect(rows[0].reconciliation).toBe("stale");
433
+ expect(rows[0].live).toBe(false);
434
+ });
435
+
436
+ test("an unrecorded component that could not be read is unknown, not unrecorded", () => {
437
+ const rows = reconcileStatus("prod", [], {
438
+ allComponents: ["search-svc"],
439
+ liveEvidence: new Map<string, LiveComponentEvidence>([
440
+ ["search-svc", { live: false, unobserved: { reason: "read-failed" } }],
441
+ ]),
442
+ });
443
+ expect(rows[0].reconciliation).toBe("unknown");
444
+ });
445
+
446
+ test("liveEvidenceFromChangeSet carries the plan's unobserved verdict", () => {
447
+ const evidence = liveEvidenceFromChangeSet({
448
+ env: "prod",
449
+ entries: [
450
+ {
451
+ name: "search-svc",
452
+ action: "unobserved",
453
+ evidence: { declared: true, inSnapshot: false, live: false, observed: false },
454
+ ownership: "unknown",
455
+ unobservedReason: "unsupported-kind",
456
+ unobservedDetail: "no reader",
457
+ },
458
+ ],
459
+ });
460
+ expect(evidence.get("search-svc")!.unobserved).toEqual({
461
+ reason: "unsupported-kind",
462
+ detail: "no reader",
463
+ });
464
+ });
384
465
  });
385
466
  });
@@ -21,6 +21,9 @@
21
21
  * lower-confidence signal rather than silently treated as "reconciled".
22
22
  * - **reconciled** — a release record exists and live evidence (via
23
23
  * ownership) confirms the component is present and owned by chant.
24
+ * - **unknown** — live evidence was requested and could not be read (#1089),
25
+ * or was not requested at all. A component chant could not observe is never
26
+ * reported `stale`: "the read failed" and "it is gone" are different facts.
24
27
  *
25
28
  * This is deliberately a light-touch reconciliation: chant's lexicons report
26
29
  * resource-level status (`ResourceMetadata`), not "the digest currently
@@ -36,6 +39,7 @@
36
39
  import type { ChangeSet, ChangeAction } from "./change-set";
37
40
  import { latestPerComponent, type ReleaseRecord } from "./release-ledger";
38
41
  import type { BuildLedgerEntry, ComponentBomSummary } from "./build-ledger";
42
+ import { unobservedReasonText, type UnobservedReason } from "../observation";
39
43
 
40
44
  /** One row of `chant components status [env]` — the per-component join of recorded vs live. */
41
45
  export interface ComponentStatusRow {
@@ -63,9 +67,18 @@ export interface ComponentStatusRow {
63
67
  /**
64
68
  * Machine-readable "observed live", when live evidence was gathered (`--live`).
65
69
  * A consumer joining this row onto a graph node should read this rather than
66
- * string-matching `detail`. Absent when `--live` was not requested.
70
+ * string-matching `detail`. Absent when `--live` was not requested — and, since
71
+ * #1089, also absent when live state could not be read at all: `false` means
72
+ * "looked, not there", never "did not look". Read {@link unobserved} for that
73
+ * case; a consumer that treats absent as "unknown" already handles it.
67
74
  */
68
75
  live?: boolean;
76
+ /**
77
+ * Set when `--live` was requested and the observation could not read this
78
+ * component (#1089). `live` is absent alongside it and `reconciliation` is
79
+ * `unknown` — the row reports a hole rather than a verdict.
80
+ */
81
+ unobserved?: { reason: UnobservedReason; detail?: string };
69
82
  /**
70
83
  * The owning deploy unit's raw status, when a lexicon reported it (AWS: the
71
84
  * component's own CFN stack via `describeStackStatus`). Lets a renderer paint a
@@ -103,6 +116,12 @@ export interface ComponentStatusResult {
103
116
  export interface LiveComponentEvidence {
104
117
  /** True if this component name was observed live at all (declared+live, or orphan+live). */
105
118
  live: boolean;
119
+ /**
120
+ * Set when the observation could not read this component (#1089). `live` is
121
+ * `false` alongside it, but only because the boolean has nowhere else to go —
122
+ * every consumer must branch on this field before believing `live: false`.
123
+ */
124
+ unobserved?: { reason: UnobservedReason; detail?: string };
106
125
  /** The `ChangeSet` action chant's existing plan logic assigned, when the component maps to a tracked entity/resource name. */
107
126
  action?: ChangeAction;
108
127
  /** Ownership verdict, when known. */
@@ -132,6 +151,10 @@ export function mergeLiveEvidence(
132
151
  const b = merged.get(component);
133
152
  merged.set(component, {
134
153
  live: sup.live,
154
+ // A direct stack observation that succeeded answers the question the
155
+ // change-set axis could not — so it clears an inherited "not observed".
156
+ // A supplement that itself could not read keeps the hole.
157
+ ...(sup.unobserved ? { unobserved: sup.unobserved } : {}),
135
158
  ownership: sup.ownership ?? b?.ownership,
136
159
  action: b?.action,
137
160
  stack: sup.stack ?? b?.stack,
@@ -178,8 +201,12 @@ function mergeEvidence(entries: LiveComponentEvidence[]): LiveComponentEvidence
178
201
  const action = entries.some((e) => e.action === "update")
179
202
  ? "update"
180
203
  : entries.find((e) => e.action !== undefined)?.action;
204
+ // A component whose entities were partly readable is still partly unknown, so
205
+ // any unobserved entity keeps the hole — unless something under it was
206
+ // actually seen live, which already answers "is this deployed".
207
+ const unobserved = live ? undefined : entries.find((e) => e.unobserved)?.unobserved;
181
208
 
182
- return { live, ownership, action };
209
+ return { live, ownership, action, ...(unobserved ? { unobserved } : {}) };
183
210
  }
184
211
 
185
212
  /**
@@ -204,6 +231,16 @@ export function liveEvidenceFromChangeSet(
204
231
  live: entry.evidence.live,
205
232
  action: entry.action,
206
233
  ownership: entry.ownership,
234
+ // Carry the plan's "could not look" verdict through so the status join
235
+ // reports a hole instead of reading `live: false` as "gone" (#1089).
236
+ ...(entry.action === "unobserved" && entry.unobservedReason
237
+ ? {
238
+ unobserved: {
239
+ reason: entry.unobservedReason,
240
+ ...(entry.unobservedDetail ? { detail: entry.unobservedDetail } : {}),
241
+ },
242
+ }
243
+ : {}),
207
244
  });
208
245
  }
209
246
 
@@ -275,6 +312,15 @@ export function reconcileStatus(
275
312
  detail = recorded
276
313
  ? "recorded; live status not queried (pass --live to reconcile)"
277
314
  : "no release record found";
315
+ } else if (evidence?.unobserved) {
316
+ // Live evidence was requested and could not be read (#1089). Neither
317
+ // `stale` (which claims the component is gone) nor `reconciled` is
318
+ // supportable — the honest verdict is that nothing is known.
319
+ reconciliation = "unknown";
320
+ const why = `${unobservedReasonText(evidence.unobserved.reason)}${evidence.unobserved.detail ? `: ${evidence.unobserved.detail}` : ""}`;
321
+ detail = recorded
322
+ ? `recorded ${recorded.timestamp} (digest ${recorded.digest}), but live state could not be observed — ${why}`
323
+ : `no release record, and live state could not be observed — ${why}`;
278
324
  } else if (!recorded && evidence?.live) {
279
325
  reconciliation = "unrecorded";
280
326
  detail = `live${evidence.ownership === "owned" ? " and chant-owned" : ""}, but no release record exists — deployed outside the recorded path`;
@@ -300,7 +346,11 @@ export function reconcileStatus(
300
346
  componentBom,
301
347
  reconciliation,
302
348
  detail,
303
- ...(liveEvidence ? { live: !!evidence?.live } : {}),
349
+ // `live` is only emitted when it is a real answer: requested AND read.
350
+ // An unread component leaves it absent (= unknown to every consumer)
351
+ // rather than reporting `false`, which would read as "not deployed".
352
+ ...(liveEvidence && !evidence?.unobserved ? { live: !!evidence?.live } : {}),
353
+ ...(evidence?.unobserved ? { unobserved: evidence.unobserved } : {}),
304
354
  ...(evidence?.stack ? { stack: evidence.stack } : {}),
305
355
  });
306
356
  }
@@ -1,4 +1,5 @@
1
1
  import type { ResourceMetadata, ArtifactMetadata } from "../lexicon";
2
+ import type { UnobservedEntity } from "../observation";
2
3
 
3
4
  export type { ResourceMetadata, ArtifactMetadata } from "../lexicon";
4
5
 
@@ -18,6 +19,13 @@ export interface LifecycleSnapshot {
18
19
  timestamp: string;
19
20
  /** Resource metadata keyed by logical name */
20
21
  resources: Record<string, ResourceMetadata>;
22
+ /**
23
+ * Declared entities this observation could not read (#1089), keyed by logical
24
+ * name. Additive and optional: a snapshot without it observed everything it
25
+ * was asked about. Present so a later diff can tell "was not there when the
26
+ * snapshot was taken" from "was never looked at".
27
+ */
28
+ unobserved?: Record<string, UnobservedEntity>;
21
29
  /** Artifact metadata keyed by server-side identifier (lexicon-specific). */
22
30
  artifacts?: Record<string, ArtifactMetadata>;
23
31
  /** Build digest at snapshot time — what was declared when this snapshot was taken */
@@ -0,0 +1,115 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { applyLiveEndpoint, zeroResourcesWarning, LEXICON_ENDPOINT_ENV_VAR } from "./live-endpoint";
3
+ import type { EnvironmentDeclaration } from "./config";
4
+
5
+ describe("applyLiveEndpoint (#1166)", () => {
6
+ test("no-op — and no notice — when the environment declares no endpoint at all", () => {
7
+ const env: NodeJS.ProcessEnv = {};
8
+ const result = applyLiveEndpoint(["floci", "prod"], "floci", ["aws"], env);
9
+ expect(result.notice).toBeUndefined();
10
+ expect(env.AWS_ENDPOINT_URL).toBeUndefined();
11
+ result.restore(); // always safe, even as a no-op
12
+ expect(env.AWS_ENDPOINT_URL).toBeUndefined();
13
+ });
14
+
15
+ test("applies the declared endpoint to the ambient var of every observing lexicon that has one", () => {
16
+ const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
17
+ const env: NodeJS.ProcessEnv = {};
18
+ const result = applyLiveEndpoint(environments, "floci", ["aws"], env);
19
+ expect(env.AWS_ENDPOINT_URL).toBe("http://localhost:4566");
20
+ expect(result.notice).toMatch(/environment "floci" declares endpoint http:\/\/localhost:4566/);
21
+ expect(result.notice).toMatch(/AWS_ENDPOINT_URL/);
22
+ });
23
+
24
+ test("restore() removes exactly what it set, not a pre-existing value it didn't touch", () => {
25
+ const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
26
+ const env: NodeJS.ProcessEnv = {};
27
+ const result = applyLiveEndpoint(environments, "floci", ["aws"], env);
28
+ expect(env.AWS_ENDPOINT_URL).toBe("http://localhost:4566");
29
+ result.restore();
30
+ expect(env.AWS_ENDPOINT_URL).toBeUndefined();
31
+ });
32
+
33
+ test("ambient wins: an already-set var is left untouched, and the notice says so", () => {
34
+ const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
35
+ const env: NodeJS.ProcessEnv = { AWS_ENDPOINT_URL: "http://real-endpoint.example" };
36
+ const result = applyLiveEndpoint(environments, "floci", ["aws"], env);
37
+ expect(env.AWS_ENDPOINT_URL).toBe("http://real-endpoint.example"); // unchanged
38
+ expect(result.notice).toMatch(/ambient AWS_ENDPOINT_URL already set/);
39
+ result.restore();
40
+ expect(env.AWS_ENDPOINT_URL).toBe("http://real-endpoint.example"); // restore never touches what it didn't set
41
+ });
42
+
43
+ test("a bare-string environment entry has no endpoint to apply", () => {
44
+ const env: NodeJS.ProcessEnv = {};
45
+ const result = applyLiveEndpoint(["floci"], "floci", ["aws"], env);
46
+ expect(result.notice).toBeUndefined();
47
+ expect(env.AWS_ENDPOINT_URL).toBeUndefined();
48
+ });
49
+
50
+ test("only applies to lexicons actually observing, and only those with a known endpoint var", () => {
51
+ const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
52
+ const env: NodeJS.ProcessEnv = {};
53
+ // k8s has no ambient-var knob (config-resolved instead) — nothing to set.
54
+ const result = applyLiveEndpoint(environments, "floci", ["k8s"], env);
55
+ expect(env.AWS_ENDPOINT_URL).toBeUndefined();
56
+ expect(result.notice).toBeUndefined();
57
+ });
58
+
59
+ test("applies to fly's FLY_FLAPS_BASE_URL too, when fly is among the observing lexicons", () => {
60
+ const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
61
+ const env: NodeJS.ProcessEnv = {};
62
+ const result = applyLiveEndpoint(environments, "floci", ["aws", "fly"], env);
63
+ expect(env.AWS_ENDPOINT_URL).toBe("http://localhost:4566");
64
+ expect(env.FLY_FLAPS_BASE_URL).toBe("http://localhost:4566");
65
+ result.restore();
66
+ expect(env.AWS_ENDPOINT_URL).toBeUndefined();
67
+ expect(env.FLY_FLAPS_BASE_URL).toBeUndefined();
68
+ });
69
+
70
+ test("mixed: one lexicon's var is applied, another's ambient value wins — both show up in the notice", () => {
71
+ const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
72
+ const env: NodeJS.ProcessEnv = { FLY_FLAPS_BASE_URL: "http://real-fly.example" };
73
+ const result = applyLiveEndpoint(environments, "floci", ["aws", "fly"], env);
74
+ expect(env.AWS_ENDPOINT_URL).toBe("http://localhost:4566"); // applied
75
+ expect(env.FLY_FLAPS_BASE_URL).toBe("http://real-fly.example"); // ambient wins
76
+ expect(result.notice).toMatch(/applied to AWS_ENDPOINT_URL/);
77
+ expect(result.notice).toMatch(/ambient FLY_FLAPS_BASE_URL already set/);
78
+ });
79
+
80
+ test("audited endpoint-knob registry: only aws and fly (gcp/k8s/azure/temporal resolve via config, not an ambient var)", () => {
81
+ expect(LEXICON_ENDPOINT_ENV_VAR).toEqual({ aws: "AWS_ENDPOINT_URL", fly: "FLY_FLAPS_BASE_URL" });
82
+ });
83
+
84
+ test("a name that isn't declared at all has no endpoint to apply", () => {
85
+ const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
86
+ const env: NodeJS.ProcessEnv = {};
87
+ const result = applyLiveEndpoint(environments, "prod", ["aws"], env);
88
+ expect(result.notice).toBeUndefined();
89
+ expect(env.AWS_ENDPOINT_URL).toBeUndefined();
90
+ });
91
+ });
92
+
93
+ describe("zeroResourcesWarning (#1166)", () => {
94
+ test("undefined when nothing was declared to look for", () => {
95
+ expect(zeroResourcesWarning("aws", "floci", 0, { resources: {}, unobserved: {} })).toBeUndefined();
96
+ });
97
+
98
+ test("undefined when resources were actually observed", () => {
99
+ expect(
100
+ zeroResourcesWarning("aws", "floci", 2, { resources: { a: {} }, unobserved: {} }),
101
+ ).toBeUndefined();
102
+ });
103
+
104
+ test("undefined when the emptiness is already explained by #1089 unobserved", () => {
105
+ expect(
106
+ zeroResourcesWarning("aws", "floci", 2, { resources: {}, unobserved: { a: { reason: "no-binding" } } }),
107
+ ).toBeUndefined();
108
+ });
109
+
110
+ test("warns with declared count and the check-endpoint hint when truly empty and unexplained", () => {
111
+ expect(zeroResourcesWarning("aws", "floci", 3, { resources: {}, unobserved: {} })).toBe(
112
+ 'aws: 0 live resources for env "floci" (3 declared) — check the endpoint/credentials',
113
+ );
114
+ });
115
+ });