@intentius/chant-lexicon-terraform 0.59.0 → 0.61.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 (40) hide show
  1. package/dist/describe-resources.d.ts +59 -0
  2. package/dist/describe-resources.d.ts.map +1 -1
  3. package/dist/hcl/edges.d.ts +124 -0
  4. package/dist/hcl/edges.d.ts.map +1 -0
  5. package/dist/hcl/parse.d.ts +42 -0
  6. package/dist/hcl/parse.d.ts.map +1 -1
  7. package/dist/hcl/roots.d.ts +6 -0
  8. package/dist/hcl/roots.d.ts.map +1 -1
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/integrity.json +2 -2
  12. package/dist/manifest.json +1 -1
  13. package/dist/op/activities/terraform.d.ts +44 -8
  14. package/dist/op/activities/terraform.d.ts.map +1 -1
  15. package/dist/op/adoption.d.ts +110 -35
  16. package/dist/op/adoption.d.ts.map +1 -1
  17. package/package.json +2 -2
  18. package/src/__fixtures__/ACCEPTANCE.md +92 -58
  19. package/src/__fixtures__/graph-roots/README.md +21 -0
  20. package/src/__fixtures__/graph-roots/app/main.tf +41 -0
  21. package/src/__fixtures__/graph-roots/app/modules/cdn/main.tf +7 -0
  22. package/src/__fixtures__/graph-roots/network/main.tf +16 -0
  23. package/src/__fixtures__/live-estate/README.md +65 -19
  24. package/src/__fixtures__/live-estate/adoptable.tf +13 -0
  25. package/src/__fixtures__/live-ls.json +5 -5
  26. package/src/__fixtures__/live-plan.json +67 -35
  27. package/src/composites/terraform-adopt-op.acceptance.test.ts +36 -50
  28. package/src/composites/terraform-apply-op.acceptance.test.ts +4 -2
  29. package/src/describe-resources.live.test.ts +76 -4
  30. package/src/describe-resources.test.ts +35 -0
  31. package/src/describe-resources.ts +110 -3
  32. package/src/hcl/edges.test.ts +207 -0
  33. package/src/hcl/edges.ts +316 -0
  34. package/src/hcl/parse.ts +53 -0
  35. package/src/hcl/roots.ts +14 -0
  36. package/src/index.ts +1 -0
  37. package/src/op/activities/choudoufu.test.ts +3 -3
  38. package/src/op/activities/terraform.ts +70 -21
  39. package/src/op/adoption.test.ts +129 -11
  40. package/src/op/adoption.ts +181 -49
@@ -5,11 +5,12 @@
5
5
  * `terraform-adopt-op.test.ts` next door proves the shape chant emits.
6
6
  * `choudoufu.test.ts` proves the activities' contract against a stubbed child
7
7
  * process. This proves the shape does the thing: create an unmarked VPC
8
- * directly against choudoufu's pinned emulator, so it is a live resource this
9
- * estate does not own at an identity this root's configuration declares; run
10
- * the Ledger step and expect exactly one adoptable match with the two marker
11
- * values on it; run the Adopt step and let it write them; re-plan and expect
12
- * the estate to own the same VPC, with nothing left adoptable.
8
+ * directly against choudoufu's pinned emulator at the cidr this root declares,
9
+ * so it is a live resource this estate does not own and the sweep can match to
10
+ * a declaration by content; run the Ledger step and expect exactly one
11
+ * adoptable match with the two marker values on it; run the Adopt step and let
12
+ * it write them; re-plan and expect the estate to own the same VPC, with
13
+ * nothing left adoptable.
13
14
  *
14
15
  * The Op's phases are not run through `runOpLocally` here, deliberately.
15
16
  * `TerraformAdoptOp` always emits a gate — adoption moves the estate's
@@ -20,38 +21,39 @@
20
21
  * hand-off, which is the part a real binary can falsify. `choudoufu.acceptance.test.ts` takes the same approach for the same
21
22
  * reason.
22
23
  *
23
- * ## Why this still skips on choudoufu v0.14.0
24
+ * ## What this suite needed, and when it arrived
24
25
  *
25
- * Not #894 any more. v0.14.0 (choudoufu PR 915) made `live-plan -json`
26
- * reachable on a configuration that names its own estate, and this suite was
27
- * run against it on 2026-09-07 for the first time. It got as far as the
28
- * document and stopped there: `ledger.adoptions` came back empty, because the
29
- * #788 document carries no adoptable-by-content section at all.
26
+ * It had never passed on any binary until choudoufu v0.15.0.
30
27
  *
31
28
  * The document's `unowned[]` is the resources found at an identity the
32
- * configuration itself declares. A `aws_cloudwatch_log_group` has one (its
33
- * name is in the block), so an unmarked live one comes back in `unowned[]`
34
- * with `adopt_tofu_estate`/`adopt_tofu_address` on it, which is exactly what
35
- * `../__fixtures__/live-plan.json` recorded and what `readAdoptionLedger`
36
- * reads. An `aws_vpc` has none: EC2 assigns the id, so the document reports
37
- * `omissions[].reason = "NEEDS_DISCOVERY"` and leaves `unowned[]` empty. The
38
- * VPC is matched instead by choudoufu's content matcher during the
39
- * estate-wide unclaimed sweep, and that match is printed only in the human
40
- * render's "Adoptable" section. `views.LivePlanDocument` has no field for it,
41
- * `-adoption-only` is refused alongside `-json` ("-adoption-only and -json
42
- * cannot be combined"), and `TOFU_LIVE_COLLECT_UNCLAIMED=1` on the `-json`
43
- * run makes no difference: the text render then prints "Adoptable: 1 live
44
- * resource matches a declared resource" and the document beside it still says
45
- * `"unowned": []`.
29
+ * configuration itself declares. An `aws_cloudwatch_log_group` has one (its
30
+ * name is in the block), so an unmarked live one comes back there with
31
+ * `adopt_tofu_estate`/`adopt_tofu_address` on it. An `aws_vpc` has none: EC2
32
+ * assigns the id, so the document reports `omissions[].reason =
33
+ * "NEEDS_DISCOVERY"` and `unowned[]` stays empty. The live VPC is matched
34
+ * instead by choudoufu's content matcher during the estate-wide sweep, and
35
+ * until v0.15.0 that match was printed only in the human `-adoption-only`
36
+ * render, which choudoufu refuses alongside `-json`. chant #2168 ran this
37
+ * block against v0.14.0 on 2026-09-07, reached the document and stopped, with
38
+ * `ledger.adoptions` empty, and filed
39
+ * [choudoufu #962](https://github.com/INTENTIUS/choudoufu/issues/962).
46
40
  *
47
- * So `choudoufuLivePlan` cannot produce a ledger for a provider-assigned
48
- * identity on any binary that exists today, and this suite is what would
49
- * prove it can. Filed upstream as
50
- * [choudoufu #962](https://github.com/INTENTIUS/choudoufu/issues/962); the
51
- * measurements are there and on chant #2168.
41
+ * choudoufu PR 963 answered it: the document now carries the match as an
42
+ * `adoptable[]` section, with `swept[]` beside it, and each row carries the
43
+ * two marker values, the arguments the match rested on, and the tagging
44
+ * command. `choudoufuLivePlan` puts `TOFU_LIVE_COLLECT_UNCLAIMED=1` on the
45
+ * `-json` run under `adoptionOnly` so the section is populated at all
46
+ * (choudoufu's `-json` run asks no sweep of its own), and
47
+ * `readAdoptionLedger` reads both sections. Which is what this suite exists
48
+ * to falsify, so the gate that named #962 is gone and only the three ordinary
49
+ * dependencies remain.
52
50
  *
53
- * The gate below names that, alongside the three ordinary dependencies: a
54
- * `choudoufu`, an `aws` CLI, and the emulator's endpoint.
51
+ * The fixture stays an `aws_vpc` deliberately. A log group would make the
52
+ * block pass off `unowned[]` alone and would stop it proving the
53
+ * content-matcher path, which is the only thing it exists to prove.
54
+ *
55
+ * The gate below is those three: a `choudoufu`, an `aws` CLI, and the
56
+ * emulator's endpoint.
55
57
  *
56
58
  * Gating copied from `./terraform-apply-op.acceptance.test.ts` (`onPath`),
57
59
  * which in turn copies `lexicons/k3s/src/serializer.acceptance.test.ts`'s
@@ -59,7 +61,7 @@
59
61
  * failing when the real dependency is absent.
60
62
  *
61
63
  * `../__fixtures__/ACCEPTANCE.md` records what this block has last passed
62
- * against, which is still nothing, and why the reason changed.
64
+ * against, and the whole history of why it could not before.
63
65
  */
64
66
 
65
67
  import { execSync } from "node:child_process";
@@ -78,18 +80,6 @@ function onPath(cmd: string): boolean {
78
80
  }
79
81
  }
80
82
 
81
- /**
82
- * `true` while `live-plan -json`'s document carries no adoptable-by-content
83
- * section, so a provider-assigned identity like an `aws_vpc` never reaches
84
- * `readAdoptionLedger`. Filed upstream as
85
- * [choudoufu #962](https://github.com/INTENTIUS/choudoufu/issues/962), with
86
- * the measurements against the v0.14.0 release binary on 2026-09-07; flip to
87
- * `false` when a choudoufu release puts the content matcher's "Adoptable"
88
- * rows in the document, either in `unowned[]` or in a sibling array.
89
- * choudoufu #894, which gated this block before, is fixed and gone.
90
- */
91
- const CHOUDOUFU_ADOPTABLE_NOT_IN_DOCUMENT = true;
92
-
93
83
  const emulatorEndpoint = process.env.CHOUDOUFU_EMULATOR_ENDPOINT;
94
84
 
95
85
  const skipReason = !onPath("choudoufu")
@@ -98,11 +88,7 @@ const skipReason = !onPath("choudoufu")
98
88
  ? "no aws CLI on PATH (the unmarked resource is created with it, and adopted through it)"
99
89
  : !emulatorEndpoint
100
90
  ? "CHOUDOUFU_EMULATOR_ENDPOINT is not set (bring up choudoufu's `just smoke` emulator stack and export it)"
101
- : CHOUDOUFU_ADOPTABLE_NOT_IN_DOCUMENT
102
- ? "choudoufu#962: live-plan -json's document carries no adoptable-by-content section, so the " +
103
- "fixture's unmarked aws_vpc reaches omissions[NEEDS_DISCOVERY] and never unowned[]; " +
104
- "measured on choudoufu v0.14.0"
105
- : "";
91
+ : "";
106
92
 
107
93
  const FIXTURE = join(import.meta.dirname, "..", "__fixtures__", "live-adopt");
108
94
  const ESTATE = "chant-adopt-fixture";
@@ -160,9 +160,11 @@ describe.skipIf(skipReason !== "")(
160
160
  * the approval artifact
161
161
  * ([choudoufu #878](https://github.com/INTENTIUS/choudoufu/issues/878),
162
162
  * PR 889), before which `plan -out` was refused under a live block and
163
- * this Op could not be built the way it is built now, and v0.14.0 moved
163
+ * this Op could not be built the way it is built now; v0.14.0 moved
164
164
  * the floor again for the document the other two Ops read
165
165
  * ([choudoufu #894](https://github.com/INTENTIUS/choudoufu/issues/894)),
166
+ * and v0.15.0 moved it once more for that document's adoptable section
167
+ * ([choudoufu #962](https://github.com/INTENTIUS/choudoufu/issues/962)),
166
168
  * - `CHOUDOUFU_EMULATOR_ENDPOINT` unset (bring up choudoufu's `just smoke`
167
169
  * docker compose stack and export `http://localhost:<mapped port>`).
168
170
  *
@@ -218,7 +220,7 @@ const liveSkipReason: string = !onPath("choudoufu")
218
220
  : choudoufuVersion === undefined
219
221
  ? "the choudoufu on PATH reports no release version (a dev build), so the approval artifact cannot be assumed"
220
222
  : isOlderVersion(choudoufuVersion, MIN_CHOUDOUFU_VERSION)
221
- ? `choudoufu ${choudoufuVersion} is older than v${MIN_CHOUDOUFU_VERSION}, the lexicon's floor (choudoufu #878's approval artifact in v0.13.0, #894's -json document in v0.14.0)`
223
+ ? `choudoufu ${choudoufuVersion} is older than v${MIN_CHOUDOUFU_VERSION}, the lexicon's floor (choudoufu #878's approval artifact in v0.13.0, #894's -json document in v0.14.0, #962's adoptable section in v0.15.0)`
222
224
  : !emulatorEndpoint
223
225
  ? "CHOUDOUFU_EMULATOR_ENDPOINT is not set (bring up choudoufu's `just smoke` emulator stack and export it)"
224
226
  : "";
@@ -8,9 +8,10 @@
8
8
  *
9
9
  * The live side is real too. `src/__fixtures__/live-plan.json` and
10
10
  * `src/__fixtures__/live-ls.json` were recorded from that same configuration
11
- * by a choudoufu built from source, running against choudoufu's own pinned
12
- * floci emulator; `src/__fixtures__/live-estate/README.md` is the recording
13
- * log, including the two ways the recording could not be a chant live root.
11
+ * by the choudoufu v0.15.0 release binary, running against choudoufu's own
12
+ * pinned floci emulator; `src/__fixtures__/live-estate/README.md` is the
13
+ * recording log, including what the recording still cannot do as a chant live
14
+ * root would and what the v0.15.0 re-recording (#2241) changed.
14
15
  *
15
16
  * Nothing here runs choudoufu: the activities are injected, and the stock
16
17
  * `show` activity throws if the reader ever reaches for it.
@@ -102,6 +103,10 @@ function deps(overrides?: Partial<TerraformReadDeps>): TerraformReadDeps {
102
103
  adoptions: [],
103
104
  contested: [],
104
105
  ambiguous: 0,
106
+ // The sweep's type list (#2241), likewise a projection this reader never
107
+ // touches: `describeResources` asks for no sweep, so a real document off
108
+ // this path carries an empty one too.
109
+ swept: [],
105
110
  // Plan change counts (#2106), likewise unread here.
106
111
  adds: 0,
107
112
  changes: 0,
@@ -154,6 +159,20 @@ describe("the live fixture root is what buildRoots() calls live (#2103)", () =>
154
159
  describe("indexLivePlan (#2104)", () => {
155
160
  const index = indexLivePlan(PLAN);
156
161
 
162
+ it("indexes the adoptable section and the sweep's type list", () => {
163
+ expect([...index.adoptable.keys()]).toEqual(["aws_vpc.adoptable"]);
164
+ expect(index.adoptable.get("aws_vpc.adoptable")).toMatchObject({
165
+ type: "aws_vpc",
166
+ adoptEstate: ESTATE,
167
+ adoptAddress: "aws_vpc.adoptable",
168
+ matched: [{ attribute: "cidr_block", value: "10.88.0.0/16" }],
169
+ });
170
+ // The types the sweep listed in full. An empty `adoptable` is read against
171
+ // this: nothing found, or nothing asked.
172
+ expect(index.swept).toContain("aws_vpc");
173
+ expect(indexLivePlan({}).swept).toEqual([]);
174
+ });
175
+
157
176
  it("indexes every section of the recorded document by address", () => {
158
177
  expect(index.estate).toBe(ESTATE);
159
178
  expect([...index.bound.keys()].sort()).toEqual([
@@ -215,7 +234,7 @@ describe("terraform describeResources on a live root (#2104)", () => {
215
234
  const { resources } = normalizeObservation(await describeResources(await options(), deps()));
216
235
  expect(resources["estate/aws_vpc.main"]).toMatchObject({
217
236
  type: "Terraform::Resource",
218
- physicalId: "vpc-c1733cf7",
237
+ physicalId: "vpc-dc8685c5",
219
238
  status: "bound",
220
239
  ownership: "owned",
221
240
  marker: { stack: ESTATE },
@@ -260,6 +279,23 @@ describe("terraform describeResources on a live root (#2104)", () => {
260
279
  expect(adoptable.marker).toBeUndefined();
261
280
  });
262
281
 
282
+ it("reports a content-matched VPC adoptable, with the cidr the sweep matched on", async () => {
283
+ // The declaration carries no identity at all (EC2 assigns a VPC id), so
284
+ // the document's omission for it says NEEDS_DISCOVERY, which on its own
285
+ // reads as unsupported-kind. The `adoptable[]` section is what turns that
286
+ // into a real verdict (choudoufu #962, chant #2241).
287
+ const { resources } = normalizeObservation(await describeResources(await options(), deps()));
288
+ const vpc = resources["estate/aws_vpc.adoptable"];
289
+ expect(vpc).toMatchObject({ status: "adoptable", ownership: "unknown" });
290
+ expect(vpc.physicalId).toMatch(/^vpc-/);
291
+ expect(vpc.attributes).toMatchObject({
292
+ adoptTofuEstate: ESTATE,
293
+ adoptTofuAddress: "aws_vpc.adoptable",
294
+ matchedOn: ["cidr_block=10.88.0.0/16"],
295
+ });
296
+ expect(vpc.marker).toBeUndefined();
297
+ });
298
+
263
299
  it("reports an unowned resource at a declared identity foreign, naming who holds it", async () => {
264
300
  const { resources } = normalizeObservation(await describeResources(await options(), deps()));
265
301
  const held = resources["estate/aws_cloudwatch_log_group.held_elsewhere"];
@@ -310,6 +346,42 @@ describe("terraform describeResources on a live root (#2104)", () => {
310
346
  });
311
347
  });
312
348
 
349
+ describe("which read answered, on a live root (#2267)", () => {
350
+ it("says `live` for every declared entity of the root, whatever the verdict was", async () => {
351
+ // The sibling assertion on a stock root is in `./describe-resources.test.ts`.
352
+ // Together they are the whole of the claim: a renderer joining observations
353
+ // to nodes can tell a read of the account from a read of a state file, and
354
+ // so never paints the second one as drift.
355
+ const opts = await options();
356
+ const observed = normalizeObservation(await describeResources(opts, deps()));
357
+ expect(Object.keys(observed.sources).sort()).toEqual([...opts.entityNames].sort());
358
+ for (const value of Object.values(observed.sources)) expect(value).toBe("live");
359
+ });
360
+
361
+ it("still says `live` when live-plan failed", async () => {
362
+ const opts = await options();
363
+ const observed = normalizeObservation(
364
+ await describeResources(
365
+ opts,
366
+ deps({
367
+ livePlan: (async () => {
368
+ throw new Error("no valid credential sources found");
369
+ }) as TerraformReadDeps["livePlan"],
370
+ }),
371
+ ),
372
+ );
373
+ for (const value of Object.values(observed.sources)) expect(value).toBe("live");
374
+ });
375
+
376
+ it("is the same value the declared entity already carries on props.mode", async () => {
377
+ const opts = await options();
378
+ const { sources } = normalizeObservation(await describeResources(opts, deps()));
379
+ for (const [name, entity] of opts.entities) {
380
+ expect(sources[name]).toBe((entity.props as { mode?: string }).mode);
381
+ }
382
+ });
383
+ });
384
+
313
385
  describe("terraform describeResources live-root failures (#2104)", () => {
314
386
  it("reports every declared entity of the root not-observed, never absent", async () => {
315
387
  const opts = await options();
@@ -264,6 +264,41 @@ describe("terraform describeResources (#2087)", () => {
264
264
  });
265
265
  });
266
266
 
267
+ describe("which read answered, on a stock root (#2267)", () => {
268
+ it("says `state` for every declared entity of the root, whatever the verdict was", async () => {
269
+ const opts = await options();
270
+ const observed = normalizeObservation(await describeResources(opts, deps()));
271
+ // Present, absent and unsupported-kind all appear in this fixture, and the
272
+ // claim is about the read that was attempted, not about what it found.
273
+ expect(Object.keys(observed.sources).sort()).toEqual([...opts.entityNames].sort());
274
+ for (const value of Object.values(observed.sources)) expect(value).toBe("state");
275
+ // `null_resource.third` is declared and not in state, so it is OBSERVED-ABSENT:
276
+ // in neither map, and this is the only place it can say how it was read.
277
+ expect(observed.resources["app/null_resource.third"]).toBeUndefined();
278
+ expect(observed.unobserved["app/null_resource.third"]).toBeUndefined();
279
+ expect(observed.sources["app/null_resource.third"]).toBe("state");
280
+ });
281
+
282
+ it("still says `state` when the read failed, since a failure is a state read that failed", async () => {
283
+ const opts = await options();
284
+ const observed = normalizeObservation(
285
+ await describeResources(opts, deps({ show: failing("Error acquiring the state lock") })),
286
+ );
287
+ expect(Object.keys(observed.sources).sort()).toEqual([...opts.entityNames].sort());
288
+ for (const value of Object.values(observed.sources)) expect(value).toBe("state");
289
+ });
290
+
291
+ it("is the same value the declared entity already carries on props.mode", async () => {
292
+ // The documented join key (#2267): one fact decides both, so a consumer
293
+ // holding the declared graph and no observation can label from `mode`.
294
+ const opts = await options();
295
+ const { sources } = normalizeObservation(await describeResources(opts, deps()));
296
+ for (const [name, entity] of opts.entities) {
297
+ expect(sources[name]).toBe((entity.props as { mode?: string }).mode);
298
+ }
299
+ });
300
+ });
301
+
267
302
  describe("terraform describeResources failed reads (#2087)", () => {
268
303
  it("a failed show reports every declared entity of that root read-failed, naming the root", async () => {
269
304
  const opts = await options();
@@ -13,6 +13,34 @@
13
13
  * and the marker on the resource is the ownership answer. The two halves are
14
14
  * two adapters over the same `observeEntities` harness, picked per root.
15
15
  *
16
+ * ## Which of the two answered is on the wire (#2267)
17
+ *
18
+ * They are different claims. `terraform show -json` says what the last apply
19
+ * recorded; `choudoufu live-plan -json` says what the account holds now. A
20
+ * renderer that paints an observation as drift and cannot tell them apart
21
+ * paints a state read green, which reads as "the account matches" when all it
22
+ * says is "the apply finished". For an estate that has decided state is never
23
+ * the system of record, a state-backed observation is not a weaker answer, it
24
+ * is a different one.
25
+ *
26
+ * So the returned `ObservationResult` carries `sources`, keyed by chant entity
27
+ * name, with one of exactly two values:
28
+ *
29
+ * - `"state"` — this entity's root was read with `terraform show -json`.
30
+ * - `"live"` — this entity's root was read with `choudoufu live-plan -json`.
31
+ *
32
+ * Three guarantees a consumer may rely on. Every entity `describeResources`
33
+ * was asked about that belongs to a root has an entry, whatever verdict it
34
+ * came back with: present, absent and not-observed alike, because which read
35
+ * was attempted is a fact independent of what it found. The value equals the
36
+ * declared entity's own `props.mode` (`./hcl/parse.ts`), since one fact
37
+ * decides both, so `props.mode` is also a valid join key for a consumer
38
+ * holding the declared graph and no observation. And a project mixing stock
39
+ * and live roots gets both values in one document, which is why this is a map
40
+ * and not a field on the result as a whole.
41
+ *
42
+ * An entity with no root gets no entry, for the same reason it gets no read.
43
+ *
16
44
  * ## The state file is the ownership answer, on a stock root
17
45
  *
18
46
  * Every other lexicon in chant stamps a tag or a label at synthesis and reads
@@ -54,6 +82,7 @@
54
82
  * | `bound[]`, any other source | present, `owned` by derivation, record, or cache; noted as such, and no marker is surfaced because none was read |
55
83
  * | `unowned[]` with `adopt_*` | present, `unknown`; an adoptable match, carrying the exact two tag values #2105 would write |
56
84
  * | `unowned[]` without | present, `foreign`; a live resource is in the way at a declared identity and the plan will not touch it |
85
+ * | `adoptable[]` | present, `unknown`; the same adoptable verdict, for a live resource the sweep content-matched to a declaration that carries no identity of its own (choudoufu #962, chant #2241) |
57
86
  * | `omissions[]` | not-observed, `ABSENT` excepted (see below) |
58
87
  *
59
88
  * The ownership channel this declares is `./live-ownership.ts`'s
@@ -282,6 +311,27 @@ export interface LivePlanUnownedRow {
282
311
  adoptAddress?: string;
283
312
  }
284
313
 
314
+ /**
315
+ * One `adoptable[]` entry: a live resource the estate-wide sweep matched to a
316
+ * declared instance by content rather than by reading a declared identity
317
+ * (choudoufu #962). The declaration names no identity at all, so this is the
318
+ * only row the document has for it and the paired omission says
319
+ * `NEEDS_DISCOVERY`.
320
+ *
321
+ * Same fields as {@link LivePlanUnownedRow} minus `heldBy`, which cannot
322
+ * apply: a row here carries no marker for any estate, or the sweep would have
323
+ * called it foreign instead of unclaimed.
324
+ */
325
+ export interface LivePlanAdoptableRow {
326
+ addr: string;
327
+ type?: string;
328
+ identity?: string;
329
+ adoptEstate?: string;
330
+ adoptAddress?: string;
331
+ /** `matched[]`: the arguments the declaration and the live resource agreed on exactly. */
332
+ matched?: Array<{ attribute: string; value: string }>;
333
+ }
334
+
285
335
  /** A `live-plan -json` document, indexed by declared instance address. */
286
336
  export interface LivePlanIndex {
287
337
  /** `estate`: the estate every section was computed against. */
@@ -289,6 +339,13 @@ export interface LivePlanIndex {
289
339
  bound: Map<string, LivePlanBoundRow>;
290
340
  omissions: Map<string, LivePlanOmissionRow>;
291
341
  unowned: Map<string, LivePlanUnownedRow>;
342
+ adoptable: Map<string, LivePlanAdoptableRow>;
343
+ /**
344
+ * `swept[]`: the resource types the estate-wide sweep listed in full. Empty
345
+ * on every observation read, which asks for no sweep, so an empty
346
+ * `adoptable` beside it says nothing was asked rather than nothing found.
347
+ */
348
+ swept: string[];
292
349
  /** Every address any section named, so a block can find its own instances. */
293
350
  addresses: string[];
294
351
  /** `diagnostics[]` summaries, for the observation's run-level notes. */
@@ -307,6 +364,8 @@ export function indexLivePlan(document: unknown): LivePlanIndex {
307
364
  bound: new Map(),
308
365
  omissions: new Map(),
309
366
  unowned: new Map(),
367
+ adoptable: new Map(),
368
+ swept: asArray(doc.swept).filter((t): t is string => typeof t === "string"),
310
369
  addresses: [],
311
370
  diagnostics: [],
312
371
  };
@@ -348,6 +407,24 @@ export function indexLivePlan(document: unknown): LivePlanIndex {
348
407
  });
349
408
  }
350
409
 
410
+ for (const entry of asArray(doc.adoptable)) {
411
+ const row = asRecord(entry);
412
+ const addr = asString(row.addr);
413
+ if (!addr) continue;
414
+ const matched = asArray(row.matched)
415
+ .map((m) => asRecord(m))
416
+ .filter((m) => asString(m.attribute))
417
+ .map((m) => ({ attribute: asString(m.attribute)!, value: asString(m.value) ?? "" }));
418
+ index.adoptable.set(addr, {
419
+ addr,
420
+ ...(asString(row.type) ? { type: asString(row.type) } : {}),
421
+ ...(asString(row.identity) ? { identity: asString(row.identity) } : {}),
422
+ ...(asString(row.adopt_tofu_estate) ? { adoptEstate: asString(row.adopt_tofu_estate) } : {}),
423
+ ...(asString(row.adopt_tofu_address) ? { adoptAddress: asString(row.adopt_tofu_address) } : {}),
424
+ ...(matched.length > 0 ? { matched } : {}),
425
+ });
426
+ }
427
+
351
428
  for (const entry of asArray(doc.diagnostics)) {
352
429
  const row = asRecord(entry);
353
430
  const summary = asString(row.summary);
@@ -355,7 +432,12 @@ export function indexLivePlan(document: unknown): LivePlanIndex {
355
432
  }
356
433
 
357
434
  index.addresses = [
358
- ...new Set([...index.bound.keys(), ...index.omissions.keys(), ...index.unowned.keys()]),
435
+ ...new Set([
436
+ ...index.bound.keys(),
437
+ ...index.omissions.keys(),
438
+ ...index.unowned.keys(),
439
+ ...index.adoptable.keys(),
440
+ ]),
359
441
  ].sort();
360
442
  return index;
361
443
  }
@@ -419,7 +501,7 @@ function liveModuleMembers(address: string, index: LivePlanIndex): string[] {
419
501
  /** One instance's verdict, before a block aggregates its instances. */
420
502
  type InstanceVerdict =
421
503
  | { kind: "owned"; row: LivePlanBoundRow }
422
- | { kind: "adoptable"; row: LivePlanUnownedRow }
504
+ | { kind: "adoptable"; row: LivePlanAdoptableRow }
423
505
  | { kind: "foreign"; row: LivePlanUnownedRow }
424
506
  | { kind: "absent" }
425
507
  | { kind: "unobserved"; reason: UnobservedReason; detail: string };
@@ -428,6 +510,11 @@ type InstanceVerdict =
428
510
  * Classify one instance address against the document, `unowned[]` first: a
429
511
  * declared instance that also carries a `UNOWNED` omission is answered by the
430
512
  * section that has the verdict, not by the one that has the apology.
513
+ * `adoptable[]` sits under `bound[]` for the same reason, one rung further
514
+ * down: a content match is the answer for an instance nothing else could
515
+ * answer for, and its paired omission is always `NEEDS_DISCOVERY`, which on
516
+ * its own would read as unsupported-kind and hide a real, actionable verdict
517
+ * (choudoufu #962, chant #2241).
431
518
  */
432
519
  function classifyLiveInstance(address: string, index: LivePlanIndex): InstanceVerdict {
433
520
  const unowned = index.unowned.get(address);
@@ -440,6 +527,11 @@ function classifyLiveInstance(address: string, index: LivePlanIndex): InstanceVe
440
527
  const bound = index.bound.get(address);
441
528
  if (bound) return { kind: "owned", row: bound };
442
529
 
530
+ const matched = index.adoptable.get(address);
531
+ if (matched && (matched.adoptEstate || matched.adoptAddress)) {
532
+ return { kind: "adoptable", row: matched };
533
+ }
534
+
443
535
  const omission = index.omissions.get(address);
444
536
  if (omission) {
445
537
  const verdict = omissionVerdict(omission);
@@ -516,6 +608,13 @@ function readLiveResource(
516
608
  ...(adoptable.row.type ? { resourceType: adoptable.row.type } : {}),
517
609
  ...(adoptable.row.adoptEstate ? { adoptTofuEstate: adoptable.row.adoptEstate } : {}),
518
610
  ...(adoptable.row.adoptAddress ? { adoptTofuAddress: adoptable.row.adoptAddress } : {}),
611
+ // Present only on a content match: the arguments the sweep compared,
612
+ // which is the whole evidence for a match nobody read an identity
613
+ // for, and the thing an operator checks before letting a tag write
614
+ // claim the resource.
615
+ ...(adoptable.row.matched?.length
616
+ ? { matchedOn: adoptable.row.matched.map((m) => `${m.attribute}=${m.value}`) }
617
+ : {}),
519
618
  },
520
619
  },
521
620
  queried,
@@ -1091,12 +1190,20 @@ export async function describeResources(
1091
1190
 
1092
1191
  const parts = [];
1093
1192
  const notes: string[] = [];
1193
+ // Which of the two reads answered, per entity (#2267). Filled for every
1194
+ // entity of the root before the read runs, so the answer is on the wire
1195
+ // whatever the verdict came back as: present, absent, or not-observed. A
1196
+ // renderer joins observations to nodes, and painting `terraform show -json`
1197
+ // over state the same green as a live read of the account is the drift lie
1198
+ // an overlay exists to prevent.
1199
+ const sources: Record<string, string> = {};
1094
1200
  for (const [root, declared] of byRoot) {
1095
1201
  // One fact decides the whole read, and the parse already recorded it on
1096
1202
  // every entity of the root (#2103). A root is live when its binary is
1097
1203
  // choudoufu AND it declares an estate, so a single entity carrying
1098
1204
  // `mode: "live"` settles it for the root.
1099
1205
  const live = declared.some((entity) => entity.mode === "live");
1206
+ for (const entity of declared) sources[entity.name] = live ? "live" : "state";
1100
1207
  if (live) {
1101
1208
  notes.push(
1102
1209
  `terraform.roots.${root} is a live root: ownership came from choudoufu's tofu-estate/tofu-address markers via \`live-plan -json\`, not from a state file`,
@@ -1136,5 +1243,5 @@ export async function describeResources(
1136
1243
  }
1137
1244
  }
1138
1245
 
1139
- return observation(resources, unobserved, merged.queried, merged.notes);
1246
+ return observation(resources, unobserved, merged.queried, merged.notes, undefined, sources);
1140
1247
  }