@intentius/chant 0.33.1 → 0.34.1

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 (51) hide show
  1. package/dist/cli/commands/onboard.d.ts.map +1 -1
  2. package/dist/cli/handlers/graph.d.ts.map +1 -1
  3. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  4. package/dist/cli/handlers/search.d.ts +49 -1
  5. package/dist/cli/handlers/search.d.ts.map +1 -1
  6. package/dist/cli/main.d.ts.map +1 -1
  7. package/dist/cli/registry.d.ts +26 -0
  8. package/dist/cli/registry.d.ts.map +1 -1
  9. package/dist/graph-ir.d.ts +14 -0
  10. package/dist/graph-ir.d.ts.map +1 -1
  11. package/dist/graph-refs.d.ts +19 -0
  12. package/dist/graph-refs.d.ts.map +1 -1
  13. package/dist/lexicon.d.ts +141 -0
  14. package/dist/lexicon.d.ts.map +1 -1
  15. package/dist/lifecycle/deep-observe.d.ts +4 -0
  16. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  17. package/dist/lifecycle/live-diff.d.ts.map +1 -1
  18. package/dist/lifecycle/observe.d.ts +55 -1
  19. package/dist/lifecycle/observe.d.ts.map +1 -1
  20. package/dist/lifecycle/replay.d.ts +47 -0
  21. package/dist/lifecycle/replay.d.ts.map +1 -0
  22. package/dist/lifecycle/snapshot.d.ts +6 -0
  23. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  24. package/dist/lifecycle/types.d.ts +46 -0
  25. package/dist/lifecycle/types.d.ts.map +1 -1
  26. package/package.json +1 -1
  27. package/src/cli/commands/onboard.ts +10 -25
  28. package/src/cli/handlers/graph.test.ts +74 -0
  29. package/src/cli/handlers/graph.ts +77 -36
  30. package/src/cli/handlers/lifecycle.test.ts +86 -0
  31. package/src/cli/handlers/lifecycle.ts +43 -10
  32. package/src/cli/handlers/search.test.ts +200 -4
  33. package/src/cli/handlers/search.ts +383 -29
  34. package/src/cli/main.ts +9 -0
  35. package/src/cli/registry.ts +27 -0
  36. package/src/codegen/lexicon-wiring.test.ts +53 -0
  37. package/src/codegen/publish-order.test.ts +133 -0
  38. package/src/codegen/release-wiring.test.ts +92 -0
  39. package/src/graph-ir-live.test.ts +83 -0
  40. package/src/graph-ir.ts +51 -1
  41. package/src/graph-refs.test.ts +59 -0
  42. package/src/graph-refs.ts +39 -8
  43. package/src/lexicon.ts +145 -0
  44. package/src/lifecycle/deep-observe.ts +5 -0
  45. package/src/lifecycle/live-diff.test.ts +38 -0
  46. package/src/lifecycle/live-diff.ts +45 -2
  47. package/src/lifecycle/observe.ts +186 -4
  48. package/src/lifecycle/replay.ts +141 -0
  49. package/src/lifecycle/snapshot.test.ts +179 -0
  50. package/src/lifecycle/snapshot.ts +88 -3
  51. package/src/lifecycle/types.ts +47 -0
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Replay a recorded snapshot as if it had just been observed (#1266, #1279).
3
+ *
4
+ * Shared by `chant search --at` and `chant graph --at`. A snapshot already holds
5
+ * what an observation is — resources with their physical ids and attributes,
6
+ * and since #1266 the relationships between them — so turning it back into
7
+ * `LiveObservation[]` lets a replay rejoin the live path at `buildLiveGraphIr`.
8
+ * Every fold, overlay and edge reconstruction below that point is shared, and
9
+ * nothing downstream needs to know which source it got.
10
+ *
11
+ * That sharing is the reason this is a module rather than a helper inside one
12
+ * command: `graph` had no `--at` at all, so anyone wanting the raw IR of a
13
+ * recorded estate had to reach for the live endpoint, and a snapshot could
14
+ * answer most questions but never all of them.
15
+ */
16
+
17
+ import { readEnvironmentSnapshots } from "./git";
18
+ import type { LiveObservation } from "../graph-ir";
19
+ import type { LifecycleSnapshot } from "./types";
20
+
21
+ /**
22
+ * Rebuild observations from a recorded snapshot (#1266).
23
+ *
24
+ * A snapshot already holds what an observation is: resources with their
25
+ * physical ids and attributes, and — since #1266 — the relationships between
26
+ * them. Turning it back into `LiveObservation[]` means the replay rejoins the
27
+ * live path at `buildLiveGraphIr`, and every fold, overlay and query below that
28
+ * is shared. Nothing downstream needs to know which source it got.
29
+ *
30
+ * `latest` is the only ref for now. A specific commit is the natural extension
31
+ * and the storage already supports it (`readSnapshotAt`), but "answer from what
32
+ * is recorded" is the question worth settling first.
33
+ */
34
+ /**
35
+ * Whether this environment has a recording, without reading one.
36
+ *
37
+ * Used to turn "the estate could not be read" into "the estate could not be
38
+ * read, and you already have a recording of it". Cheap and best-effort: a
39
+ * failure here means the caller says the plain version of the message, never
40
+ * that the command fails.
41
+ */
42
+ export async function hasSnapshot(environment: string): Promise<boolean> {
43
+ try {
44
+ return (await readEnvironmentSnapshots(environment)).size > 0;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ export async function replaySnapshots(
51
+ environment: string,
52
+ ref: string,
53
+ scopedStacks: Set<string>,
54
+ ): Promise<{ observations: LiveObservation[]; commit: string; timestamp: string } | { error: string; hint?: string }> {
55
+ if (ref !== "latest" && ref !== "true") {
56
+ return {
57
+ error: `chant search --at only accepts "latest" for now, got "${ref}"`,
58
+ hint: "a specific snapshot commit is not wired up yet",
59
+ };
60
+ }
61
+ const stored = await readEnvironmentSnapshots(environment);
62
+ if (stored.size === 0) {
63
+ return {
64
+ error: `No snapshots found for environment "${environment}"`,
65
+ hint: `Record one first: chant lifecycle snapshot ${environment}`,
66
+ };
67
+ }
68
+ const observations: LiveObservation[] = [];
69
+ let commit = "";
70
+ let timestamp = "";
71
+ // Ambient and dependency resources are keyed by physical id and are
72
+ // account-level: the default security group three stacks each recorded is one
73
+ // group, not three. Managed resources are stack-qualified below and cannot
74
+ // collide, so only the unqualified ones need this.
75
+ const seenUnqualified = new Set<string>();
76
+ // A stack's snapshot could only exclude what THAT stack manages, so a stack
77
+ // declaring no security groups reported the neighbouring stack's as ambient.
78
+ // The union is only knowable here, with every snapshot in hand.
79
+ const managedPhysicalIds = new Set<string>();
80
+ for (const content of stored.values()) {
81
+ const snap = JSON.parse(content) as LifecycleSnapshot;
82
+ for (const meta of Object.values(snap.resources ?? {})) {
83
+ if (!meta.ambient && !meta.referencedBy?.length && meta.physicalId) {
84
+ managedPhysicalIds.add(meta.physicalId);
85
+ }
86
+ }
87
+ }
88
+ for (const [key, content] of stored) {
89
+ const snapshot = JSON.parse(content) as LifecycleSnapshot;
90
+ // The storage key is `<stack>__<lexicon>` for a multi-stack project; the
91
+ // snapshot carries its own lexicon, which is the one to trust.
92
+ const lexicon = snapshot.lexicon ?? key;
93
+ // A scoped stack's ids are qualified `${stack}::${id}` on the live path
94
+ // (#1162), because the same bare LogicalResourceId exists in every region's
95
+ // stack. A snapshot stores them bare, so a replay has to re-apply the same
96
+ // rule — otherwise `server` from us-west-1 and `server` from us-west-2
97
+ // collide, and none of them join the declared canvas, which qualifies.
98
+ const stack = snapshot.stack;
99
+ const qualify = stack !== undefined && scopedStacks.has(stack);
100
+ // Dependencies (#1273) are keyed by physical id and are account-level: the
101
+ // default VPC's route table is one resource however many stacks route
102
+ // through it. Qualifying those would split it per stack and break the
103
+ // edges into it.
104
+ const managed = (id: string, meta: { referencedBy?: string[]; ambient?: boolean }): string =>
105
+ qualify && !meta.ambient && !(meta.referencedBy && meta.referencedBy.length > 0)
106
+ ? `${stack}::${id}`
107
+ : id;
108
+ const resources: Record<string, (typeof snapshot.resources)[string]> = {};
109
+ for (const [id, meta] of Object.entries(snapshot.resources ?? {})) {
110
+ const key = managed(id, meta);
111
+ if (key === id) {
112
+ // Ambient means "nothing manages this". Another stack managing it makes
113
+ // that false, and reporting it twice would inflate any count over it.
114
+ if (meta.ambient && meta.physicalId && managedPhysicalIds.has(meta.physicalId)) continue;
115
+ // Unqualified: account-level, so first sighting wins and the rest are
116
+ // the same resource seen again from another stack's snapshot.
117
+ if (seenUnqualified.has(id)) continue;
118
+ seenUnqualified.add(id);
119
+ }
120
+ resources[key] = meta;
121
+ }
122
+ const known = new Set(Object.keys(snapshot.resources ?? {}));
123
+ const requalify = (id: string): string => {
124
+ const meta = (snapshot.resources ?? {})[id];
125
+ return known.has(id) && meta ? managed(id, meta) : id;
126
+ };
127
+ const edges = (snapshot.edges ?? []).map((e) => ({ ...e, from: requalify(e.from), to: requalify(e.to) }));
128
+ observations.push({
129
+ lexicon,
130
+ resources,
131
+ ...(edges.length > 0 ? { edges } : {}),
132
+ });
133
+ commit ||= snapshot.commit ?? "";
134
+ // Report the OLDEST timestamp across stacks: a caller asking how stale this
135
+ // answer is wants the weakest link, not the freshest one.
136
+ if (!timestamp || (snapshot.timestamp && snapshot.timestamp < timestamp)) {
137
+ timestamp = snapshot.timestamp ?? timestamp;
138
+ }
139
+ }
140
+ return { observations, commit, timestamp };
141
+ }
@@ -1,6 +1,8 @@
1
1
  import { describe, test, expect, vi, beforeEach } from "vitest";
2
2
  import { createMockPlugin, staticDescribeResources, staticObservation, staticListArtifacts } from "@intentius/chant-test-utils";
3
3
  import type { BuildResult } from "../build";
4
+ import type { DeepResourceObservation } from "../deep-observation";
5
+ import type { UnobservedEntity } from "../observation";
4
6
 
5
7
  const writeSnapshotMock = vi.fn();
6
8
  const getHeadCommitMock = vi.fn();
@@ -83,6 +85,126 @@ describe("takeSnapshot", () => {
83
85
  expect(writeSnapshotMock.mock.calls[0][1]).toBe("loom-backend__aws");
84
86
  });
85
87
 
88
+ test("region option: the stack's own region reaches describeResources (#1261)", async () => {
89
+ let observedRegion: string | undefined = "unset";
90
+ const plugin = createMockPlugin({
91
+ name: "aws",
92
+ describeResources: async (options: { region?: string }) => {
93
+ observedRegion = options.region;
94
+ return { bucket: { type: "AWS::S3::Bucket", status: "CREATE_COMPLETE", physicalId: "b" } };
95
+ },
96
+ });
97
+ await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["bucket"] }), {
98
+ stack: "loom-us-west-2",
99
+ region: "us-west-2",
100
+ });
101
+ // Without this the reader falls back to the ambient region, and every stack
102
+ // outside it snapshots as "no valid resources or artifacts returned".
103
+ expect(observedRegion).toBe("us-west-2");
104
+ });
105
+
106
+ test("no region declared: describeResources keeps its ambient-region default", async () => {
107
+ let observedRegion: string | undefined = "unset";
108
+ const plugin = createMockPlugin({
109
+ name: "aws",
110
+ describeResources: async (options: { region?: string }) => {
111
+ observedRegion = options.region;
112
+ return { bucket: { type: "AWS::S3::Bucket", status: "CREATE_COMPLETE", physicalId: "b" } };
113
+ },
114
+ });
115
+ await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["bucket"] }));
116
+ expect(observedRegion).toBeUndefined();
117
+ });
118
+
119
+ // #1267 — a snapshot records identity by default; --deep also records each
120
+ // resource's property tree, which is what a fold over topology needs.
121
+ describe("deep snapshots (#1267)", () => {
122
+ const identity = { bucket: { type: "AWS::S3::Bucket", status: "CREATE_COMPLETE", physicalId: "b" } };
123
+
124
+ function deepPlugin(
125
+ resources: Record<string, DeepResourceObservation>,
126
+ unobserved: Record<string, UnobservedEntity> = {},
127
+ ) {
128
+ return createMockPlugin({
129
+ name: "aws",
130
+ describeResources: staticDescribeResources(identity),
131
+ observeResourcesDeep: async () => ({ deepObservation: "v1" as const, resources, unobserved }),
132
+ });
133
+ }
134
+
135
+ test("without --deep: identity only, and depth is not written", async () => {
136
+ const plugin = deepPlugin({
137
+ bucket: { type: "AWS::S3::Bucket", physicalId: "b", properties: { versioning: "Enabled" } },
138
+ });
139
+ const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["bucket"] }));
140
+ // Absent, not "identity" — every snapshot written before #1267 is thin,
141
+ // and a reader must treat a missing field as thin rather than unknown.
142
+ expect(result.snapshots[0].depth).toBeUndefined();
143
+ expect(result.snapshots[0].properties).toBeUndefined();
144
+ });
145
+
146
+ test("with --deep: records the property trees alongside identity", async () => {
147
+ const plugin = deepPlugin({
148
+ bucket: { type: "AWS::S3::Bucket", physicalId: "b", properties: { versioning: "Enabled" } },
149
+ });
150
+ const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["bucket"] }), { deep: true });
151
+ expect(result.snapshots[0].depth).toBe("deep");
152
+ expect(result.snapshots[0].properties?.bucket.properties).toEqual({ versioning: "Enabled" });
153
+ // Identity is still there — deep adds, it does not replace.
154
+ expect(result.snapshots[0].resources.bucket).toMatchObject({ type: "AWS::S3::Bucket" });
155
+ });
156
+
157
+ test("--deep against a lexicon with no deep reader: identity snapshot plus a warning", async () => {
158
+ const plugin = createMockPlugin({ name: "aws", describeResources: staticDescribeResources(identity) });
159
+ const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["bucket"] }), { deep: true });
160
+ // Still a usable snapshot, but it must not claim a depth it does not have.
161
+ expect(result.snapshots).toHaveLength(1);
162
+ expect(result.snapshots[0].depth).toBeUndefined();
163
+ expect(result.warnings.join("\n")).toContain("no deep reader");
164
+ });
165
+
166
+ test("--deep returning nothing: downgrades to identity rather than discarding a good snapshot", async () => {
167
+ const plugin = deepPlugin({});
168
+ const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["bucket"] }), { deep: true });
169
+ expect(result.snapshots).toHaveLength(1);
170
+ expect(result.snapshots[0].depth).toBeUndefined();
171
+ expect(result.warnings.join("\n")).toContain("deep read returned no properties");
172
+ });
173
+
174
+ test("--deep passes the stack's region to the deep reader (#1261 family)", async () => {
175
+ let seen: string | undefined = "unset";
176
+ const plugin = createMockPlugin({
177
+ name: "aws",
178
+ describeResources: staticDescribeResources(identity),
179
+ observeResourcesDeep: async (options: { region?: string }) => {
180
+ seen = options.region;
181
+ return {
182
+ deepObservation: "v1" as const,
183
+ resources: { bucket: { type: "AWS::S3::Bucket", physicalId: "b", properties: {} } },
184
+ unobserved: {},
185
+ };
186
+ },
187
+ });
188
+ await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["bucket"] }), {
189
+ stack: "app-us-west-2",
190
+ region: "us-west-2",
191
+ deep: true,
192
+ });
193
+ // Without this the deep read targets the ambient region and comes back
194
+ // empty, which downgrades a multi-region snapshot to identity silently.
195
+ expect(seen).toBe("us-west-2");
196
+ });
197
+
198
+ test("--deep reports entities the deep reader could not read", async () => {
199
+ const plugin = deepPlugin(
200
+ { bucket: { type: "AWS::S3::Bucket", physicalId: "b", properties: {} } },
201
+ { queue: { reason: "read-failed", detail: "boom" } },
202
+ );
203
+ const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["bucket"] }), { deep: true });
204
+ expect(result.warnings.join("\n")).toContain("not observed deeply");
205
+ });
206
+ });
207
+
86
208
  test("plugin without describeResources is skipped", async () => {
87
209
  const plugin = createMockPlugin({ name: "aws" });
88
210
  const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["x"] }));
@@ -231,3 +353,60 @@ describe("takeSnapshot", () => {
231
353
  expect(result.snapshots).toEqual([]);
232
354
  });
233
355
  });
356
+
357
+ // #1266 — a snapshot that records only what it manages cannot answer a fold
358
+ // question when it is replayed: the account's default VPC routing is not in it,
359
+ // so `internetFacing` is unanswerable and `search --at` would be quietly weaker
360
+ // than `search --live`.
361
+ describe("dependencies and edges in a snapshot (#1266)", () => {
362
+ const identity = { webServer: { type: "AWS::EC2::Instance", status: "OK", physicalId: "i-1" } };
363
+
364
+ test("records the dependencies the estate references, and the edges to them", async () => {
365
+ const plugin = createMockPlugin({
366
+ name: "aws",
367
+ describeResources: staticDescribeResources(identity),
368
+ observeDependencies: async () => ({
369
+ resources: {
370
+ "rtb-default": {
371
+ type: "AWS::EC2::RouteTable",
372
+ status: "OBSERVED",
373
+ physicalId: "rtb-default",
374
+ referencedBy: ["webServer"],
375
+ },
376
+ },
377
+ edges: [{ from: "webServer", to: "rtb-default", kind: "ref" as const, viaAttr: "RouteTableId" }],
378
+ }),
379
+ });
380
+ const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["webServer"] }));
381
+ // Both, in one record: what exists and how it connects.
382
+ expect(result.snapshots[0].resources["rtb-default"]).toMatchObject({ referencedBy: ["webServer"] });
383
+ expect(result.snapshots[0].edges).toEqual([
384
+ { from: "webServer", to: "rtb-default", kind: "ref", viaAttr: "RouteTableId" },
385
+ ]);
386
+ });
387
+
388
+ test("a lexicon with no dependency reader snapshots exactly as before", async () => {
389
+ const plugin = createMockPlugin({ name: "aws", describeResources: staticDescribeResources(identity) });
390
+ const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["webServer"] }));
391
+ expect(Object.keys(result.snapshots[0].resources)).toEqual(["webServer"]);
392
+ // Absent, not empty — "no relationships recorded", not "none existed".
393
+ expect(result.snapshots[0].edges).toBeUndefined();
394
+ });
395
+
396
+ test("a dependency read that fails warns and keeps the managed snapshot", async () => {
397
+ const plugin = createMockPlugin({
398
+ name: "aws",
399
+ describeResources: staticDescribeResources(identity),
400
+ observeDependencies: async () => {
401
+ throw new Error("route tables unreadable");
402
+ },
403
+ });
404
+ const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["webServer"] }));
405
+ // The managed observation is complete and useful on its own; losing it
406
+ // because an ambient dependency could not be read trades a whole answer
407
+ // for none.
408
+ expect(result.snapshots).toHaveLength(1);
409
+ expect(result.snapshots[0].resources.webServer).toBeDefined();
410
+ expect(result.warnings.join("\n")).toContain("dependencies not read");
411
+ });
412
+ });
@@ -5,8 +5,11 @@
5
5
  import type { ObservationLexicon, ResourceMetadata, ArtifactMetadata } from "../lexicon";
6
6
  import type { BuildResult } from "../build";
7
7
  import type { SerializerResult } from "../serializer";
8
- import type { LifecycleSnapshot } from "./types";
8
+ import type { LifecycleSnapshot, ObservationDepth } from "./types";
9
+ import { observeDeep } from "./deep-observe";
10
+ import type { DeepResourceObservation } from "../deep-observation";
9
11
  import { computeBuildDigest } from "./digest";
12
+ import { collectDependencies, collectAmbient } from "./observe";
10
13
  import { writeSnapshot, snapshotStorageKey, getHeadCommit, pushLifecycle } from "./git";
11
14
  import { sortedJsonReplacer } from "../utils";
12
15
  import { formatUnobserved, normalizeObservation, unobservedAll, type UnobservedEntity } from "../observation";
@@ -80,9 +83,24 @@ export async function takeSnapshot(
80
83
  environment: string,
81
84
  plugins: ObservationLexicon[],
82
85
  buildResult: BuildResult,
83
- opts?: { cwd?: string; stack?: string },
86
+ opts?: {
87
+ cwd?: string;
88
+ stack?: string;
89
+ region?: string;
90
+ deep?: boolean;
91
+ ambient?: boolean;
92
+ /** Kinds the PROJECT manages, not just this stack — a region whose stack
93
+ * declares no security group still has a default one (#1278). */
94
+ ambientKinds?: string[];
95
+ },
84
96
  ): Promise<TakeSnapshotResult> {
85
97
  const stack = opts?.stack;
98
+ const depth: ObservationDepth = opts?.deep ? "deep" : "identity";
99
+ // A stack declares the region it deploys to (#1261). Passing it through is
100
+ // what lets a multi-region estate be observed at all: the reader targets the
101
+ // stack's own region rather than whichever one the shell happens to be set
102
+ // to, so out-of-region stacks stop coming back empty.
103
+ const region = opts?.region;
86
104
  const warnings: string[] = [];
87
105
  const errors: string[] = [];
88
106
  const snapshots: LifecycleSnapshot[] = [];
@@ -131,6 +149,7 @@ export async function takeSnapshot(
131
149
  entityNames,
132
150
  entities,
133
151
  stack,
152
+ region,
134
153
  }),
135
154
  );
136
155
  const { valid, dropped, warnings: validationWarnings } = validateResources(observed.resources);
@@ -149,6 +168,8 @@ export async function takeSnapshot(
149
168
  }
150
169
 
151
170
  if (plugin.listArtifacts) {
171
+ // No region: artifacts are registry/chart objects (docker, helm), not
172
+ // regional cloud resources, and `listArtifacts` takes no region.
152
173
  const raw = await plugin.listArtifacts({ environment, entities, stack });
153
174
  const { valid, dropped, warnings: validationWarnings } = validateResources(raw);
154
175
  warnings.push(...validationWarnings);
@@ -168,15 +189,79 @@ export async function takeSnapshot(
168
189
  continue;
169
190
  }
170
191
 
192
+ // The deep read is a second pass, after identity is known to be readable
193
+ // (#1267). Keeping it separate means a lexicon with no deep reader still
194
+ // snapshots exactly as before, and a deep read that comes back empty
195
+ // downgrades the record rather than discarding an identity snapshot that
196
+ // was already good.
197
+ let properties: Record<string, DeepResourceObservation> | undefined;
198
+ let recordedDepth: ObservationDepth = "identity";
199
+ if (depth === "deep") {
200
+ if (!plugin.observeResourcesDeep) {
201
+ warnings.push(
202
+ `${plugin.name}: no deep reader — recording an identity snapshot; property questions cannot be answered from it`,
203
+ );
204
+ } else {
205
+ const observed = await observeDeep(plugin, {
206
+ environment,
207
+ buildOutput,
208
+ entities,
209
+ ...(stack ? { stack } : {}),
210
+ ...(region ? { region } : {}),
211
+ });
212
+ for (const [name, entry] of Object.entries(observed.unobserved)) {
213
+ warnings.push(`${plugin.name}: not observed deeply — ${formatUnobserved(name, entry)}`);
214
+ }
215
+ if (Object.keys(observed.resources).length > 0) {
216
+ properties = observed.resources;
217
+ recordedDepth = "deep";
218
+ } else {
219
+ warnings.push(
220
+ `${plugin.name}: deep read returned no properties — recording an identity snapshot`,
221
+ );
222
+ }
223
+ }
224
+ }
225
+ // What this estate depends on but does not manage (#1273), recorded so a
226
+ // replayed snapshot can answer the same questions a live read can (#1266).
227
+ // Without them a snapshot holds the managed resources and no route to the
228
+ // account's default VPC, so `internetFacing` is unanswerable from it —
229
+ // which would make `search --at` quietly weaker than `search --live`.
230
+ const dependencies = await collectDependencies(plugin, {
231
+ environment,
232
+ entities,
233
+ observed: resources,
234
+ stacks: stack ? [{ name: stack, ...(region ? { region } : {}) }] : [],
235
+ });
236
+ for (const message of dependencies.warnings) warnings.push(message);
237
+ // Ambient resources (#1278) are recorded too when asked for, so a replayed
238
+ // snapshot can answer "which of these are unused" without a live read.
239
+ // Without this `search --at --ambient` filters a set that was never
240
+ // recorded and silently returns nothing.
241
+ const ambient = opts?.ambient
242
+ ? await collectAmbient(plugin, {
243
+ environment,
244
+ kinds: opts?.ambientKinds ?? [...new Set([...entities.values()].map((e) => e.entityType))],
245
+ observed: resources,
246
+ stacks: stack ? [{ name: stack, ...(region ? { region } : {}) }] : [],
247
+ warnings,
248
+ })
249
+ : {};
250
+ const withDependencies = { ...resources, ...dependencies.resources, ...ambient };
251
+
171
252
  const snapshot: LifecycleSnapshot = {
172
253
  lexicon: plugin.name,
173
254
  environment,
174
255
  ...(stack ? { stack } : {}),
175
256
  commit: headCommit,
176
257
  timestamp,
177
- resources,
258
+ resources: withDependencies,
259
+ ...(dependencies.edges.length > 0 ? { edges: dependencies.edges } : {}),
178
260
  ...(Object.keys(unobserved).length > 0 && { unobserved }),
179
261
  ...(Object.keys(artifacts).length > 0 && { artifacts }),
262
+ // Only written when deep. An absent field means identity, which is what
263
+ // every snapshot taken before #1267 was.
264
+ ...(recordedDepth === "deep" && { depth: recordedDepth, properties }),
180
265
  digest,
181
266
  };
182
267
 
@@ -1,8 +1,25 @@
1
1
  import type { ResourceMetadata, ArtifactMetadata } from "../lexicon";
2
2
  import type { UnobservedEntity } from "../observation";
3
+ import type { DeepResourceObservation } from "../deep-observation";
4
+ import type { IREdge } from "../graph-ir";
3
5
 
4
6
  export type { ResourceMetadata, ArtifactMetadata } from "../lexicon";
5
7
 
8
+ /**
9
+ * How much of each resource an observation actually read (#1267).
10
+ *
11
+ * `identity` is the thin path: logical name, type, physical id, status. It is
12
+ * what a snapshot recorded before deep reads existed, and it stays the default
13
+ * because a deep read costs more provider calls and a larger record.
14
+ *
15
+ * `deep` additionally carries each resource's normalized property tree, which
16
+ * is what a fold over topology needs — a subnet's route-table association, a
17
+ * security group's rules. A consumer must branch on this rather than assume:
18
+ * asking an `identity` snapshot a property question has no answer, and
19
+ * silently returning nothing would read as "no such resources".
20
+ */
21
+ export type ObservationDepth = "identity" | "deep";
22
+
6
23
  /**
7
24
  * State snapshot for a single lexicon in an environment.
8
25
  */
@@ -28,6 +45,36 @@ export interface LifecycleSnapshot {
28
45
  unobserved?: Record<string, UnobservedEntity>;
29
46
  /** Artifact metadata keyed by server-side identifier (lexicon-specific). */
30
47
  artifacts?: Record<string, ArtifactMetadata>;
48
+ /**
49
+ * How much of each resource this snapshot read (#1267). Absent means
50
+ * `identity` — every snapshot written before deep reads existed was thin, and
51
+ * treating a missing field as unknown rather than as thin would invalidate
52
+ * them all.
53
+ */
54
+ depth?: ObservationDepth;
55
+ /**
56
+ * Normalized per-resource property trees, present only at `deep` depth and
57
+ * keyed by the same logical names as `resources`.
58
+ *
59
+ * Kept beside `resources` rather than merged into it so the thin record stays
60
+ * exactly what it always was: a reader that only wants identity does not have
61
+ * to learn a new shape, and an old snapshot and a new one parse the same way.
62
+ */
63
+ properties?: Record<string, DeepResourceObservation>;
64
+ /**
65
+ * Relationships observed between the recorded resources (#1266).
66
+ *
67
+ * `resources` says what existed; without this a snapshot cannot say how any
68
+ * of it connected, so a fold over topology has nothing to traverse when the
69
+ * snapshot is replayed. That is the difference between a snapshot answering
70
+ * "which instances exist" and answering "which are reachable from the
71
+ * internet" — and the second is the whole reason the graph is worth
72
+ * recording.
73
+ *
74
+ * Absent on every snapshot written before this, which is read as "no
75
+ * relationships recorded" rather than "no relationships existed".
76
+ */
77
+ edges?: IREdge[];
31
78
  /** Build digest at snapshot time — what was declared when this snapshot was taken */
32
79
  digest?: BuildDigest;
33
80
  }