@intentius/behold 0.17.0 → 0.18.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.
package/AGENTS.md CHANGED
@@ -25,6 +25,12 @@ it against a local emulator (Docker). A directory that is not a chant project
25
25
  gets a structured `{code: "no-project"}` error from `/api/graph`, not a blank
26
26
  graph.
27
27
 
28
+ In a checkout, `behold demo --list` prints a second block under the bundled
29
+ one: the eleven workbench entries from `workbench.json`, this repo's own
30
+ catalog of the internal estates behold is developed against — the same
31
+ `behold demo <entry>` load, `just example name="<entry>"` to serve one, and
32
+ "The workbench catalog" below for what each is and what it boots.
33
+
28
34
  A running server offers the same catalog over HTTP (#268): `GET /api/demos`
29
35
  lists every bundled demo with `{name, description, requires, satisfiable,
30
36
  reason?, fetches, repo?, target, loaded}` — `satisfiable` is doctor's PATH probe
@@ -76,9 +82,126 @@ preview-locked, and one load runs at a time (409 otherwise).
76
82
  `chant operator status --json`.
77
83
  2. **focus** — narrow with chant graph options as query params: `?detail=0..3`,
78
84
  `?components=1`, `?logical=1`, `?lens=blast:<id>&down=1`, `?lens=lexicon:aws`,
79
- `?env=`, `?stack=`, `?tier=`, `?target=`.
85
+ `?env=`, `?stack=`, `?tier=`, `?target=`, `?collapse=1`.
86
+ `?collapse=1` (#393) shuts every member box holding more than 40 cards: the
87
+ box is drawn as ONE card carrying the count it was badged with (`301
88
+ resources · 84 bound · 85 unowned · 132 neutral`, in the estate's own
89
+ words), ids namespaced `box:<member>`, and the returned IR is the collapsed
90
+ one so every count and note speaks about the picture on screen. ⌘K →
91
+ "Collapse large boxes" / "Expand all". Below the limit it changes nothing.
80
92
  3. **inspect** — a node's `sourceLoc.file` is the typed source that declared it;
81
93
  edit there to change the estate (chant is the source of truth, not behold).
94
+ 4. **find** — ⌘K also takes an ADDRESS (#393). Two characters in, the palette
95
+ matches the ids and addresses of the graph the page is currently showing and
96
+ offers up to twelve `node: <address>` rows, prefix matches first, each with
97
+ its member and kind on a second line (two members of one estate can declare
98
+ the same address). Enter takes the same path a click on the card does — the
99
+ inspect pane and the highlight — and pans the graph onto it: `revealNode` in
100
+ web/app.js drives the same viewBox the wheel, the drag and "⤢ fit" drive.
101
+ Nothing here fetches, so it works in a static export too.
102
+
103
+ ### The behaviour overlay (#398, M1 of #397)
104
+
105
+ What the estate costs and where it runs out of headroom, on `/api/overlay`
106
+ only. **behold never calls a behavioural engine**, never holds its key, and
107
+ produces no figure of its own — it renders what a chant lexicon handed it, the
108
+ way it already renders drift and the carve score. The one arithmetic it does is
109
+ addition, and the result is named `sum` so nobody can read it as a bill.
110
+
111
+ The contract's source of truth is the comment titled "The behaviour block,
112
+ proposed" on INTENTIUS/behold#398 (chant#2356 is the writing half). Field names
113
+ here match it exactly; `src/behaviour.ts` is the implementation and the
114
+ validator.
115
+
116
+ Per entity, `attrs._behaviour` on the overlay IR node — the channel `_status`,
117
+ `_release` and `_carve` already ride: `{at: {traffic}, cost: {perHour,
118
+ currency}, headroom: {cpu?, latency?}, errorRate, resilience: {failure, verdict:
119
+ survives|degrades|fails, note?}, rightSize?: {suggestion, reason?}, provenance:
120
+ {engine, version, tolerance, basis: modeled|validated}}`. An entity nothing
121
+ priced carries **no `_behaviour` key at all**, never a zeroed block, and a
122
+ missing headroom axis is absent rather than 0. The block stays on the node and
123
+ there is deliberately no top-level `behaviour: {}` map: the SPA already walks
124
+ `ir.nodes` reading `attrs._status`, and a colour-by-cost mode is that same walk,
125
+ with no join.
126
+
127
+ Two sources, in this order:
128
+
129
+ 1. `attrs._behaviour` on the nodes, as `chant graph --live --overlay` painted
130
+ them, with `meta._behaviour` beside them for the graph-level half.
131
+ 2. `behaviour.<env>.json` at a member's root — `{meta, entities: {<address>:
132
+ block}}` — read **only** when no node of that member carries the attr, and
133
+ **only on the overlay** (`/api/graph` is the source graph; a prediction
134
+ about a live account has no business on it). The file's keys are the
135
+ member's own addresses; behold prefixes them to reach the composed id
136
+ `<member>/<address>`.
137
+
138
+ `meta.behaviour` carries the graph-level half: `engine`, `version`, `at`, the
139
+ engine's own `total` when it stated one, else behold's `sum` and the same per
140
+ box under `boxes[<boxKey>]` (`{perHour, currency, priced, unpriced}`). Mixed
141
+ currencies produce no sum and a diagnostic — behold converts no currency. A
142
+ malformed block is dropped whole with its reason in `diagnostics`, never
143
+ half-rendered. A member with neither source gets `{absent: "…"}` naming both
144
+ places behold looked; that is an absence, not a refusal, because nothing was
145
+ configured.
146
+
147
+ `meta.behaviour.refusal` is `{reason, remedy}` in the lexicon's own words,
148
+ printed as it came, and it is present **instead of** everything above: a
149
+ refusal emits no entity block at all and strips any that had arrived. The drift
150
+ overlay is untouched either way — nothing in this pass reads or writes
151
+ `_status`. `?logical=1` projects a different picture and carries no behaviour
152
+ block in M1.
153
+
154
+ ### Colour by drift, cost or headroom (#399, M2)
155
+
156
+ The SPA colours the graph by one of three modes. The mode is **client-side
157
+ state** — `web/app.js`'s `colourMode`, persisted under `behold.colourBy` the way
158
+ the theme is. It is deliberately NOT in `LENS_PARAMS`/`canonicalKey`: a lens
159
+ param means "a different snapshot comes back", and a mode change changes no
160
+ fetch at all. The block is already on every priced card, so a switch is a
161
+ repaint of the SVG on screen.
162
+
163
+ `drift` keeps the categorical fill and #393's vocabulary legend, unchanged. The
164
+ two behaviour modes replace the card's FILL only, so the drift bar underneath
165
+ keeps saying what chant observed. `cost` is a sequential scale over
166
+ `cost.perHour` between the estate's own min and max; `headroom` is absolute over
167
+ 0..1 and reads **the lower of the axes present**. An entity with no block draws
168
+ the drift overlay's neutral (`pinTokensFor().neutralFill`) and is marked
169
+ `data-unpriced`, never the zero end of the scale — "nothing priced this" and
170
+ "this is free" are opposite claims.
171
+
172
+ Both ramps are derived from the active palette (`rampFor` in
173
+ `web/behaviour-scale.js`), the way #229 derives the chrome: cost is a single hue
174
+ off the accent walked in lightness away from the background — expensive is not a
175
+ verdict behold gets to paint — and headroom rides the three status hues the
176
+ drift overlay already anchors, degraded → foreign → managed.
177
+
178
+ One legend per mode, in the Model tab. The Scope tab carries a totals row per
179
+ box and per estate for the active mode: `cost` quotes `meta.behaviour.boxes[key]`
180
+ and `.sum`, or `meta.behaviour.total` when the ENGINE stated one (labelled
181
+ "engine total"); `headroom` has no server-side aggregate — no engine states one —
182
+ so the SPA takes the scope's min and median itself and the row says "computed".
183
+ Every row carries `n priced · m unpriced`. In drift mode there is no row.
184
+
185
+ Everything either mode decides is pure in `web/behaviour-scale.js`, with
186
+ `web/behaviour-scale.test.js` beside it; the SPA owns the fetches and the SVG.
187
+
188
+ ### Provenance on every figure, and the refusal (#401, M4)
189
+
190
+ Every figure the SPA shows — the totals rows, the legend, and the inspect pane's
191
+ `behaviour` section — carries `{engine} {version} · {tolerance} · {basis}`
192
+ beside it, never once per page. `modeled` is spelled out on hover as "modeled,
193
+ not billed": #397's one prohibition is a prediction presented as a bill, and the
194
+ badge is where that is prevented. Provenance is per ENTITY in #398's contract, so
195
+ a badge over a set (a box, the estate) says `mixed` on any field the set
196
+ disagrees on rather than picking one.
197
+
198
+ `meta.behaviour.refusal` disables both behaviour modes in the View tab and in
199
+ ⌘K — visibly, with the lexicon's reason and remedy as their tooltip — and prints
200
+ those words verbatim where the legend would be. The drift overlay keeps
201
+ rendering; it never depended on an engine. `meta.behaviour.absent` disables the
202
+ same two modes with the absent line as their tooltip and prints **nothing**:
203
+ nothing was configured, so nothing refused. `meta.behaviour.diagnostics` render
204
+ in the Model tab under the legend.
82
205
 
83
206
  ## The carve loop (Terraform → chant, #230)
84
207
 
@@ -201,7 +324,10 @@ for another env never stands in. `just e2e-ci-github` proves the contract
201
324
  against the real forge (example-ci + `.github/workflows/behold-e2e-dispatch.yml`),
202
325
  including the `lost` verdict, which `BEHOLD_CI_FOLLOW_TIMEOUT_MS` and
203
326
  `BEHOLD_CI_POLL_FAIL_BUDGET` let a run force on purpose; a lost run is the one
204
- `POST /api/ci/readopt` re-follows.
327
+ `POST /api/ci/readopt` re-follows. The same e2e dispatches into an environment
328
+ with a required reviewer (`behold-e2e-gated`): the run holds at `waiting`, the
329
+ progress state carries `waiting: true` and the run's `url`, the now-line says
330
+ the approval is granted on the forge, and a cancellation lands as `failed`.
205
331
 
206
332
  A dispatched run is followed honestly (#165 §6, PR #350): only GitHub's own
207
333
  `completed` + conclusion paints `ok`/`failed`; a poll-failure budget or the
@@ -308,6 +434,16 @@ and it must be an address the served report already ranks — the value that
308
434
  reaches the spawn's argv comes from a closed set read off disk. No cloud write,
309
435
  no Terraform mutation, no edit to anyone's chant source.
310
436
 
437
+ A choudoufu estate member (#366) adds no exception. A move there is one tag
438
+ write through choudoufu's own `live-mv`, and behold never makes it: `GET
439
+ /api/choudoufu/moves` reads a plan (`carve.json`) from inside a served member,
440
+ previews each move with choudoufu's `live-mv -json -dry-run` (the only spelling
441
+ of `live-mv` in the tree, src/choudoufu-moves.ts `dryRunArgs`, asserted by
442
+ test), hands the lines back with copy buttons, and `?receipt=1` reads the
443
+ listing after a person ran them. **There is no `/api/choudoufu/mv`**, and
444
+ src/choudoufu-route.test.ts asserts it stays absent, for the same reason
445
+ `/api/carve/apply` does not exist.
446
+
311
447
  ## Changing behold (for agents working on this repo)
312
448
 
313
449
  The sections above are about driving a running behold. This one is about
@@ -345,3 +481,382 @@ live here, with the enforceable ones enforced.
345
481
  stays that way.
346
482
  - **chant's working rules** (its single-lane checkout, its release path) are
347
483
  chant's and live in chant's repo, not here.
484
+
485
+ ### Adding a member kind
486
+
487
+ An estate member has a kind (#368): what a directory has to look like, what
488
+ tool answers reads for it, and how it becomes a `GraphIR`. `src/member-kind.ts`
489
+ is the one table; `chant` is the first row and every member was that row
490
+ until the table existed. A new kind is:
491
+
492
+ 1. A `registerMemberKind({ kind, probe, expects, via })` call in
493
+ `src/member-kind.ts` — the `probe` is sync, read-only and runs no code (a
494
+ file's presence, a regex over a root file, or either: the choudoufu probe
495
+ takes the `estate.chdf.hcl` sidecar, choudoufu's leading form, OR a `live {`
496
+ block in a root *.tf, #387); `via.tool` stamps the binary and
497
+ version that would answer, which is the version half of `memberIr`'s
498
+ cache key; `via.read` is the one uncached read, source or live per `opts`.
499
+ The module imports nothing from the read path at runtime (src/chant.ts
500
+ imports src/project.ts, which imports it); a kind's `via` closes over its
501
+ own tool module.
502
+ Add the word to `MEMBER_KINDS`. A word in the vocabulary that no row
503
+ registers is a doctor fail with the reason, never silently chant.
504
+ 2. Nothing in `src/estate.ts`: `composeEstate`, `composeEstateOverlay` and
505
+ `estateNamespaceScopes` already dispatch through `memberKindOf(dir)`.
506
+ The two routes that render an estate — `/api/graph`'s estate branch and
507
+ `/api/overlay`'s — run the same passes in the same order and must not
508
+ fork per kind; a kind's differences live inside its `read`.
509
+ 3. A `registerPack({ lexicon, iconFor, fields })` in `src/render.ts`, or the
510
+ kind's cards lead with the alphabetically first two short attrs. A `fields`
511
+ function takes the box the card is drawn in as its second argument, so a
512
+ row the box already carries can be left off (#393 item 9,
513
+ `src/card-face.ts`).
514
+ 4. A row in `statusVocabulary` (`src/status-vocabulary.ts`) when the kind has
515
+ its own words for the four overlay colours — choudoufu's `bound` is not
516
+ chant's `managed`, and a legend that says `managed` over a card carrying no
517
+ marker is a claim behold has no right to make. The colours never move; only
518
+ the naming does, and an estate of several kinds keeps chant's words and says
519
+ so in the legend's tooltip.
520
+ 5. A `DoctorCheck` line when the kind needs a tool on PATH or a per-member
521
+ precondition (a version floor checked before the spawn, the way
522
+ `carveStatusReader` does; a PATH probe the way `src/demos.ts` does).
523
+ 6. Tests: the probe and the object form in `src/project.test.ts`; dispatch
524
+ in `src/estate.test.ts`'s "#368" block, which registers a fake kind and
525
+ asserts chant members still go through exactly the calls they did; the
526
+ kind's own reader off recorded documents in `src/__fixtures__/`, with
527
+ provenance in prose above the load.
528
+
529
+ `.behold.json` names a kind as `{ "dir": "x", "kind": "<kind>" }`; a bare
530
+ string is `chant`. Anything behold boots for a kind goes through
531
+ `assertScratch` first.
532
+
533
+ **What the first screen owes a non-chant member (#393).** Three chant-shaped
534
+ things used to be offered to every member whatever it was, and a new kind gets
535
+ all three answered for free:
536
+
537
+ - `/api/project` publishes `memberKinds` — the served members' kinds, in
538
+ composition order. The SPA opens on the `resources` zoom when none of them is
539
+ `chant`, because `components` is a projection of a chant project's own
540
+ component DAG and a member without one opens on "the components lens doesn't
541
+ apply to a composed estate yet". #182's zero-node fallback still covers a
542
+ chant project that declares no components.
543
+ - `/api/project` publishes `runtimeCapable`, and the palette and the View tab
544
+ offer `zoom: runtime` only then. The tier below the declaration boundary is
545
+ the owner-referenced children chant stamps `runtimeOwner` on, and only a
546
+ Kubernetes read has an owner chain to resolve — `RUNTIME_LEXICONS` in
547
+ `src/server.ts` is the one word (`k8s`), read off the members' DECLARED
548
+ lexicons so the stop exists or not before anyone picks it.
549
+ - A chant-only facet answers emptily rather than 500ing for a non-chant
550
+ primary: `/api/resources` returns `{byComponent: {}}`, the shape carve mode
551
+ already returns and the SPA already reads as "no resource facet here".
552
+
553
+ A kind that grows components, or a substrate with an owner chain, changes those
554
+ answers where they are decided — never per kind in a route.
555
+
556
+ ### The workbench catalog
557
+
558
+ `demos.json` is the catalog that ships. `workbench.json`, read beside it and
559
+ deliberately absent from package.json's `files`, is the catalog of internal
560
+ estates this checkout is developed against (#386, #388). Four rules, and
561
+ src/demos.ts holds them:
562
+
563
+ 1. A third source, `local`, and a second file. A local entry's `path` is
564
+ relative to the directory of the catalog file that named it — the intentius
565
+ checkouts are siblings, so the workbench writes `../choudoufu`,
566
+ `../waterpark`, `../chant`, and the file stays committed and reproducible.
567
+ An entry whose path is not checked out is unsatisfiable exactly as a
568
+ missing binary is: `--list` and `/api/demos` say so, CI stays clean.
569
+ `BEHOLD_WORKBENCH=<file>` names a catalog elsewhere; a workbench name that
570
+ collides with a bundled one is dropped with a stderr line.
571
+ 2. In place or copied, said explicitly. `inPlace: true` serves `path` where it
572
+ sits; the default copies to `behold-demos/<name>` with the bundled filter.
573
+ Anything whose setup writes into the tree — `init`, `apply`, a rendered
574
+ generator — is copied or generated into the target, never run in a
575
+ checkout, which is how #366's "behold never runs `choudoufu init` in a
576
+ served project" survives. An `inPlace` entry with a `setup` says so in its
577
+ description.
578
+ 3. A generator is a source. An entry with no `path` at all renders its estate
579
+ into an empty target through its own `setup`, which runs with cwd = the
580
+ target and two extra variables: `BEHOLD_WORKBENCH_DIR` (the catalog file's
581
+ directory, so a script can reach `../choudoufu`) and `BEHOLD_DEMO_NAME`.
582
+ The up script writes the matching down script into the target, the way the
583
+ bundled choudoufu demo ships `scripts/choudoufu-down.sh`.
584
+ 4. `CHOUDOUFU_BIN`. `choudoufuBinary()` (src/choudoufu-member.ts) names the
585
+ binary and every spawn, the doctor probe and the demo requirement check go
586
+ through it; the doctor line prints which binary answered. Workbench scripts
587
+ spell the same fallback, `${CHOUDOUFU_BIN:-choudoufu}`.
588
+
589
+ `just example name="<entry>"` serves one. Scratch discipline is unchanged: an
590
+ emulator a workbench entry boots is `behold-wb-<entry>` on its own port, and
591
+ the up scripts (`workbench/<entry>/up.sh`, helpers in `workbench/lib/`) assert
592
+ the `behold-wb-` prefix and the not-4566 rule themselves, in bash, because they
593
+ are the boot site. Each writes the matching `scripts/down.sh` into its target.
594
+
595
+ **The eleven entries**, in catalog order. The counts are measured, not
596
+ estimated — every entry the e2e below can reach on a developer machine prints
597
+ its own graph and overlay counts as it runs.
598
+
599
+ | entry | what it serves | needs |
600
+ |---|---|---|
601
+ | `chant-getting-started` | chant's own getting-started example from `../chant`, in place: the source graph, 8 nodes, no substrate. The one-second answer to "did I break plain chant reading?" | that example's own `node_modules` — nothing is installed in your chant tree |
602
+ | `chant-local-cloud-trio` | chant's local-cloud-trio, in place: one project declaring across aws, azure and gcp, 8 nodes and 2 edges of source | the same |
603
+ | `fountain-ops` | `../fountain-ops` in place with `--env local` — the mature estate on your working checkout | docker, k3d, kubectl, jq, just. **The one entry whose setup runs in your working copy**: the checkout's own `just up`, a five-minute k3d cluster, and it switches your kubectl context. `just down` there removes it; behold never does |
604
+ | `choudoufu-workbench` | the live-mv workbench's four estates copied out of `../choudoufu`, composed with `--env live`: 42 cards — 24 bound, the 12 team cards reading `owned by tlmig-sample-monolith`, 6 neutral | docker, choudoufu |
605
+ | `choudoufu-cohort-s3` | `estate-gen`'s `s3` cohort rendered into the target — a sidecar-declared estate (#387): 6 cards, all neutral, because the apply stops where floci answers S3 Control tag reads on a hostname that does not resolve | docker, choudoufu, go, terraform |
606
+ | `choudoufu-cohort-iam-ecr` | the same for `iam-ecr`, the one cohort floci implements end to end: 6 cards, all 6 bound | the same |
607
+ | `choudoufu-cohort-ec2-networking` | the same for `ec2-networking`: 49 cards, the widest roster, all neutral (floci refuses a transit gateway call and the apply stops) | the same |
608
+ | `terralith-1` | `terralith-gen` at scale 1 plus the `estate.chdf.hcl` sidecar it omits, applied by choudoufu from nothing: 79 cards, all 79 bound | docker, choudoufu, go |
609
+ | `terralith-4` | the same at scale 4: 301 cards, all 301 bound. The estate behold is sized against | the same |
610
+ | `terralith-4-adopt` | scale 4 again, but STOCK terraform applies it first, so nothing wears a marker: 301 cards — 85 UNOWNED, 84 bound by derived identity alone, 132 neutral. One `choudoufu live-import` line, which the up script prints and you run, and all 301 read bound | docker, choudoufu, go, terraform |
611
+ | `waterpark` | `../waterpark/access` in place as a bare Terraform directory (#384): five roots as boxes, 58 cards, 49 edges, nothing written under the estate | `@intentius/chant-lexicon-terraform` + `@cdktf/hcl2json` beside behold — optional peers behold declares and does not install |
612
+
613
+ **The scratch, by name and port.** Each emulator is the entry's own, booted by
614
+ its up script and removed by the `scripts/down.sh` that script wrote into the
615
+ target — never by pattern.
616
+
617
+ | entry | container | host port |
618
+ |---|---|---|
619
+ | `choudoufu-workbench` | `behold-wb-choudoufu-workbench` | 4651 |
620
+ | `choudoufu-cohort-s3` | `behold-wb-choudoufu-cohort-s3` | 4652 |
621
+ | `choudoufu-cohort-iam-ecr` | `behold-wb-choudoufu-cohort-iam-ecr` | 4653 |
622
+ | `choudoufu-cohort-ec2-networking` | `behold-wb-choudoufu-cohort-ec2-networking` | 4654 |
623
+ | `terralith-1` | `behold-wb-terralith-1` | 4655 |
624
+ | `terralith-4` | `behold-wb-terralith-4` | 4656 |
625
+ | `terralith-4-adopt` | `behold-wb-terralith-4-adopt` | 4657 |
626
+ | `chant-*`, `waterpark` | none — source reads, no substrate | — |
627
+ | `fountain-ops` | the k3d cluster `fountain-local`, which is the checkout's, not behold's | — |
628
+
629
+ The port is the floci's host binding and the entry's `serve.spawnEnv`
630
+ `AWS_ENDPOINT_URL` at once, so the estate's own applies and the choudoufu
631
+ behold spawns talk to the same emulator.
632
+
633
+ **The e2e** is `just e2e-workbench` (`e2e/workbench-e2e.sh`, #391): every entry
634
+ loaded through `behold demo <entry> <tmp target> --port <p>` on its own port
635
+ from 4720 up, `/api/graph` asserted to carry nodes and a live entry's
636
+ `/api/overlay?env=live` to answer with its bound/unowned/neutral split, timings
637
+ per entry; eight entries and 475s on the machine this was written on.
638
+ `terralith-4-adopt` is asserted twice — 85 UNOWNED, then the
639
+ `live-import` line the up script printed, run by the script itself in the
640
+ target the way a person would, then 301 bound — because that write is the
641
+ person's, never behold's (#372). `waterpark` is checked for an untouched
642
+ checkout afterwards. `missingRequirements` decides what runs: a missing binary,
643
+ an unchecked-out sibling, an uninstalled chant example or the absent Terraform
644
+ lexicon each print `skip: <entry>: needs …`, so in CI every entry skips and the
645
+ run exits 0. `fountain-ops` is skipped by name everywhere, for the reason in
646
+ the table. A `behold-wb-*` container left standing at the end fails the run.
647
+
648
+ The seeded catalog (#389) found one thing missing in src/: a LONE choudoufu
649
+ estate — every generated entry is one — served the no-project card, because the
650
+ single-project read is `chant graph <dir>` and such a directory has no lexicon
651
+ for it to read. `servesAsEstate` (src/member-kind.ts) is the predicate that
652
+ routes one directory of a non-chant kind through the estate compose path, where
653
+ the member's own kind reads it. One member composes exactly as four do, ids
654
+ namespaced under the member's short name, so the graph, the pane and the morph
655
+ agree on what a node is called.
656
+
657
+ ### Rendering a Terraform estate
658
+
659
+ A Terraform estate reaches behold through chant.
660
+ `@intentius/chant-lexicon-terraform` reads the HCL an estate already has and
661
+ emits one entity per block, so **a Terraform estate is a chant project whose
662
+ only lexicon is a reader** and `chant graph --format ir` serves it like any
663
+ other. behold parses no HCL and ships no HCL parser — the same posture
664
+ `src/carve-lens.ts` states for the carve report (#378).
665
+
666
+ Three passes turn what arrives into a picture (`src/terraform-lens.ts`), in this
667
+ order, guarded on the IR carrying terraform entities so every other estate gets
668
+ the identical object back:
669
+
670
+ 1. **`normalizeTerraformNodes`** — a node's `kind` arrives as the entity class
671
+ (`Terraform::Resource`), not the resource type, so every card would be titled
672
+ and iconed the same. The type moves out of `attrs.address` into `kind` and
673
+ the block class lands in `attrs.block`. That is the shape a carve node
674
+ already has, which is why one presentation pack serves both.
675
+ 2. **`groupTerraformByRoot`** — roots are a Terraform project's only grouping.
676
+ It retires itself when chant#2266 groups upstream. A node sits in exactly one
677
+ box, so a box every node has left is dropped rather than drawn empty (#393):
678
+ a served directory arrives composed, `composeStacks` boxes the whole member,
679
+ and the root boxes then take those same nodes — which is what put an empty
680
+ box named `access` on water park's canvas. What the member box was there to
681
+ say moves into the root box's title, which is `<member>/<root>` whenever the
682
+ estate holds more than one member and the bare root name when it does not
683
+ (two members with a root apiece named `prod` would otherwise merge silently).
684
+ 3. **`filterTerraformCards`** — what is a card, below.
685
+
686
+ **What is a card (#382).** Measured on a real estate: 247 nodes for 43
687
+ resources, four fifths of it not infrastructure.
688
+
689
+ | tier | blocks | why |
690
+ |---|---|---|
691
+ | default (detail 0-2) | `resource`, `data`, `module` | the estate: what is declared, what it reads, what it composes |
692
+ | attributes (detail 3) | + `output`, `variable` | its interface — real, but a second question |
693
+ | never | `terraform`, `provider`, `locals` | settings, not estate |
694
+
695
+ Nothing is dropped silently: `terraformElisionNote` says what is not drawn and
696
+ where to see it, the way `edgelessNote` says why a view has no edges.
697
+
698
+ **The note, at every zoom and in a 260px strip (#393).** The roots note and the
699
+ elision note are built by one function in `/api/graph` and returned by all three
700
+ of its branches, the logical lens included — it was the lens that most needed
701
+ the line and the only one that dropped it. Both routes send `note` and, when
702
+ there is a shorter true form, `noteShort` (`5 roots · 2 skipped · 189 blocks not
703
+ drawn`). The strip shows the short one with the long one on its tooltip, and the
704
+ panel's Model tab prints it whole. The server writes both: the SPA does not
705
+ author notes, and truncating this one on a sentence boundary would keep the list
706
+ of root names and drop the counts.
707
+
708
+ **Do not invent edges.** They arrive from chant or not at all: the fixtures here
709
+ were recorded when a Terraform IR carried none, and lexicon 0.61.0 (chant#2265,
710
+ which resolves a block's `"${…}"` references) draws 390 over water park's five
711
+ roots with no change on this side. The one relationship that looked derivable
712
+ without it — a cross-root read by name — was measured and refused (#381): both
713
+ ends carry the same unresolved interpolation, so a match would be a coincidence
714
+ of variable naming. A data source says what it reads as a row instead.
715
+
716
+ **Serving a directory that declares nothing (#384).** Every estate this lane
717
+ exists to draw is a directory of `.tf` files and nothing else, and #378 chose
718
+ not to ask one for a `chant.config.ts` of its own — INTENTIUS/waterpark#88 was
719
+ withdrawn because the estate is more useful untouched. So `behold serve
720
+ <terraform-dir>` generates the reader config itself, outside the estate, and
721
+ points chant at it. The Invariant's one in-project write stays
722
+ `.behold/layout.json`; nothing is written under the served directory, and
723
+ `src/terraform-member.test.ts` asserts the estate's source stamp is unchanged
724
+ across a read. Three decisions, each a trade #384 left open and each measured on
725
+ water park's `access/` before it was taken:
726
+
727
+ 1. **The lexicon is opt-in.** chant resolves the lexicon from the config file's
728
+ own location, so the generated project has to see it — and making it a
729
+ dependency would put `@cdktf/hcl2json`, a ~1.8 MB wasm blob, in the install
730
+ of every user who serves a chant project. `@intentius/chant-lexicon-terraform`
731
+ and `@cdktf/hcl2json` are therefore **optional peers**: declared in
732
+ package.json (the only place their versions are named — the refusal reads
733
+ them from there), never installed by behold, probed at serve and doctor time,
734
+ and refused with the one install line and where behold looked. The same gate
735
+ `behold demo` puts on a binary it does not ship. The lexicon's own chant peer
736
+ is what moved behold's `@intentius/chant` floor to `^0.61.0`: chant 0.54
737
+ loads no published version of it (`applyLineage is not a function`).
738
+ 2. **Roots are discovered, and the skips are reported.** A root is a directory
739
+ with a `.tf` declaring a line-start `terraform {` or `provider "` block
740
+ beside a `resource`, `data` or `module` block — a regex probe at the depth
741
+ the choudoufu probe uses, no HCL parsed. #384 proposed the first half alone
742
+ ("what a root has and a called module does not") and the estate refuted it:
743
+ water park's `modules/persona` is a shared module called by three roots and
744
+ its `versions.tf` is `baseline`'s byte for byte. So two exclusions stand
745
+ beside the probe — a directory under a `modules/` segment (Terraform's own
746
+ standard module structure; the roots that call it draw its blocks already)
747
+ and one with nothing to draw (`access/backends` is two backend fragments) —
748
+ and both are named in the graph's note with their reason, the way
749
+ `terraformElisionNote` names what a zoom left out. Measured on `access/`:
750
+ five roots (`envs/prod`, `identity`, `github`, `baseline`,
751
+ `satellites/waterpark-runner`), two skipped, `envs/dev` neither drawn nor
752
+ reported because it holds only a README.
753
+ 3. **A `terraform` member kind, after chant and choudoufu.** #378 said there is
754
+ no such kind and meant it about *reading*: behold parses no HCL and the
755
+ render goes through chant. A kind whose `read` shells `chant graph` against a
756
+ generated config is a scaffold, not a second reader, and it inherits the
757
+ probe, the cache stamp, the doctor line and estate composition (#368) for
758
+ free — so a Terraform root composes in an estate beside a chant project and a
759
+ choudoufu estate at no extra cost. `src/terraform-member.ts` is the whole of
760
+ it; `detectProjectShape` answers a directory that is its own member with
761
+ `membersFrom: "probe"`, which is what retired #384's `no chant.config.ts
762
+ here` dead end.
763
+
764
+ The scratch project is `<tmpdir>/behold-tf-<hash of the estate path>`:
765
+ `behold-*` and cleared through `assertScratch` (src/scratch.ts), one directory
766
+ per estate reused across runs, asserted to be outside the estate before a byte
767
+ is written. It holds the generated `chant.config.ts` and two symlinks —
768
+ `node_modules` to behold's own, which is how the config resolves the lexicon,
769
+ and `estate` to the served directory, which is how each root's `dir` is spelled.
770
+ The second is not decoration: the lexicon sets a root's module boundary to the
771
+ root's own directory when its `dir` resolves outside the project root, so
772
+ absolute paths cost every `../modules/x` call the estate makes — 72 nodes and 6
773
+ resources on water park, against 247 and 43 through the symlink. Nothing in the
774
+ answer mentions the scratch path (a terraform entity carries `attrs.file`
775
+ relative to its root, and no `sourceLoc`).
776
+
777
+ ### A choudoufu member's overlay is ownership; attribute drift is the plan (#404)
778
+
779
+ **Read a green choudoufu card as "this is ours", never as "nothing changed".**
780
+ The overlay is composed from `live-ls -json` and `live-plan -json`, and those
781
+ two documents answer one question — bound, unowned, omitted, or invisible to
782
+ the listing. Neither of them ever compares an attribute VALUE. A resource whose
783
+ tag, path or mutability was changed out of band still carries its markers, so
784
+ it is still bound, and it is still green.
785
+
786
+ The other half of the terminal's answer — "would a plan change anything?" — is
787
+ a **separate, opt-in read**: `choudoufu plan -input=false -out=<tmp>` then
788
+ `choudoufu show -json <tmp>`, whose `resource_changes[].change.{actions,before,
789
+ after}` name the attributes and both their values. (`choudoufu plan -json` is
790
+ NOT this: on 0.16.0 it prints choudoufu's own ownership document, the same
791
+ shape as `live-plan -json`, with no attribute values in it at all. The
792
+ measurement is in src/choudoufu-plan.ts's header.) The plan file is written to
793
+ a scratch directory of behold's own and removed after — never inside the served
794
+ member, which would be a write into someone's source.
795
+
796
+ It is opt-in because it costs differently: the ownership read is answered out
797
+ of the tagging index and is flat in the estate's size, while `plan` refreshes
798
+ every resource — one provider read per card, 301 of them on `terralith-4`. So:
799
+
800
+ - **Never on an ordinary overlay read.** `GET /api/overlay?plan=1` and
801
+ `GET /api/diff?plan=1` are the only things that spawn one, and the palette's
802
+ **"Re-check live with plan (attribute drift)"** row is the only thing in the
803
+ SPA that sends the flag. The cheap **"Re-check live (refresh drift)"** row is
804
+ deliberately left alone — it is `POST /api/refresh`, a primary-only re-observe
805
+ that captures a lanes frame and never composes the estate.
806
+ - **Cached under the member's source stamp**, the same key half `memberIr`
807
+ uses. A read that did NOT ask is served that stored answer, so a reload after
808
+ a refresh keeps showing the drift without paying for a second pass; a read
809
+ that DID ask always re-plans, because "re-check" means re-check.
810
+ - **The ownership verdict stays the card's colour.** A bound card that drifted
811
+ is still painted bound and still counted `bound`; it gains `attrs._planDrift`
812
+ and wears a dashed `--degraded` edge plus a `~ n attributes` corner glyph
813
+ (web/app.js `markDriftedCards`, the same post-render stamp as the carve and
814
+ operator marks). A card that is not bound is never marked — an unowned
815
+ object's planned `create` is an ownership fact the overlay already paints,
816
+ and `planDrift` drops creates for that reason.
817
+ - **`/api/overlay`'s meta carries `drift`**: `{read: false}` or `{read: true,
818
+ drifted: n}`. The two are different answers — "nobody looked" versus "looked,
819
+ nothing drifted" — and the legend prints a count only for the second.
820
+
821
+ ### A choudoufu estate's own references (#393)
822
+
823
+ `choudoufu live-check -json` states the roster and `references[]`, and that
824
+ second list is *cross-estate by construction* — data sources filtered on
825
+ another estate's marker tags. So a 301-resource terralith of roles, policies
826
+ and attachments drew no edge at all and the graph asserted "nothing in this
827
+ estate references anything else", which is false about every one of them.
828
+
829
+ The references were never missing; nothing was reading them. **A choudoufu
830
+ member's read now also runs the terraform kind's reader over its own
831
+ directory** — one root, the estate directory itself, through the same scratch
832
+ project machinery above (nothing written under the estate) — and joins the two
833
+ documents by address. behold still parses no HCL: `src/choudoufu-refs.ts` is
834
+ two lists of strings and the rules that match them, and its header is the
835
+ argument for each. In short: the lexicon names *blocks* in a path of module
836
+ *calls* (`estate/module.team_pod/aws_iam_role.pod_role`) and choudoufu names
837
+ *instances* in a path of module *instances*
838
+ (`module.team_pod["pod-a"].aws_iam_role.pod_role[0]`), so one lexicon edge is a
839
+ product — kept inside one module instance (Terraform's scoping, not a guess),
840
+ joined key to key when both ends expand over the same keys (266 true edges on
841
+ `terralith-4` against 666 of which ~400 would be false), landing on a module
842
+ call's whole interior, and dropped whole when either end is a `var`, a
843
+ `locals` or anything else the roster declares no card for.
844
+
845
+ Every edge is `inferred` and carries the lexicon's own attribute name (`role`,
846
+ `policy_arn`), so the card says what made the reference. **Without the
847
+ lexicon there are no edges and the note says so** — carrying the terraform
848
+ kind's own install line rather than the sentence behold has no reader to
849
+ assert — and the lexicon's version is in the member's cache stamp, so
850
+ installing it invalidates the edgeless IR rather than serving it forever.
851
+
852
+ The layout half is `packBoxComponents` (src/render.ts). src/edgeless.ts wraps a
853
+ box whose cards reference *nothing*; a box that has edges is never wrapped, so
854
+ `terralith-4` came back as a 110996 x 628 strip the day the join landed —
855
+ dagre lays 42 connected components side by side on three ranks. The pass packs
856
+ a box's components into shelves and resizes the box around them, re-laying each
857
+ component on its own first (inside a cluster dagre interleaves them: a six-card
858
+ cluster's bounding box spanned 46598 units) and wrapping a component that is a
859
+ strip either way — the DNS fan is 19564 x 400 upright and 900 x 10046 on its
860
+ side. A box under 4:1 is left exactly as it laid out. Measured: `terralith-4`
861
+ 7660 x 5162 with 266 edges, waterpark 3536 x 3138 at detail 2 and 7142 x 6974
862
+ at detail 3 (from 4.2:1 and 15.1:1, the case #393's wrap did not answer).