@intentius/behold 0.18.0 → 0.20.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 (68) hide show
  1. package/AGENTS.md +377 -76
  2. package/README.md +238 -190
  3. package/demos.json +17 -8
  4. package/dist/cli.js +13118 -11622
  5. package/example-argo-estate/README.md +8 -8
  6. package/example-argo-estate/app-a/package.json +2 -2
  7. package/example-argo-estate/app-b/package.json +2 -2
  8. package/example-argo-estate/chant.workspace.json +27 -0
  9. package/example-argo-estate/control-plane/package.json +2 -2
  10. package/example-argo-estate/package-lock.json +139 -62
  11. package/example-carve/README.md +14 -14
  12. package/example-carve/app/package-lock.json +75 -12
  13. package/example-carve/app/package.json +2 -2
  14. package/example-carve/legacy-tf/versions.tf +1 -1
  15. package/example-choudoufu-estate/README.md +4 -4
  16. package/example-choudoufu-estate/chant.workspace.json +32 -0
  17. package/example-choudoufu-estate/scripts/choudoufu-up.sh +21 -0
  18. package/example-flux-estate/README.md +5 -5
  19. package/example-flux-estate/app-a/package.json +2 -2
  20. package/example-flux-estate/app-b/package.json +2 -2
  21. package/example-flux-estate/chant.workspace.json +27 -0
  22. package/example-flux-estate/control-plane/package.json +2 -2
  23. package/example-flux-estate/package-lock.json +139 -62
  24. package/example-k8s/README.md +20 -20
  25. package/example-k8s/chant.config.ts +1 -1
  26. package/example-k8s/ops/k3d-apply.op.ts +1 -1
  27. package/example-k8s/package-lock.json +135 -217
  28. package/example-k8s/package.json +2 -3
  29. package/example-terraform-estate/README.md +109 -0
  30. package/example-terraform-estate/backends/backend.s3.tf +10 -0
  31. package/example-terraform-estate/chant.workspace.json +22 -0
  32. package/example-terraform-estate/envs/dev/README.md +5 -0
  33. package/example-terraform-estate/envs/prod/data.tf +18 -0
  34. package/example-terraform-estate/envs/prod/network.tf +80 -0
  35. package/example-terraform-estate/envs/prod/outputs.tf +13 -0
  36. package/example-terraform-estate/envs/prod/provider.tf +10 -0
  37. package/example-terraform-estate/envs/prod/services.tf +21 -0
  38. package/example-terraform-estate/envs/prod/storage.tf +31 -0
  39. package/example-terraform-estate/envs/prod/variables.tf +17 -0
  40. package/example-terraform-estate/envs/prod/versions.tf +10 -0
  41. package/example-terraform-estate/modules/service/main.tf +58 -0
  42. package/example-terraform-estate/modules/service/outputs.tf +7 -0
  43. package/example-terraform-estate/modules/service/variables.tf +16 -0
  44. package/example-terraform-estate/modules/service/versions.tf +10 -0
  45. package/example-terraform-estate/platform/iam.tf +40 -0
  46. package/example-terraform-estate/platform/kms.tf +16 -0
  47. package/example-terraform-estate/platform/outputs.tf +7 -0
  48. package/example-terraform-estate/platform/variables.tf +10 -0
  49. package/example-terraform-estate/platform/versions.tf +10 -0
  50. package/example-writes/README.md +5 -5
  51. package/example-writes/chant.config.ts +1 -1
  52. package/example-writes/ops/apply.op.ts +1 -1
  53. package/example-writes/ops/floci.op.ts +2 -2
  54. package/example-writes/ops/reconcile.op.ts +1 -1
  55. package/example-writes/package-lock.json +65 -178
  56. package/example-writes/package.json +2 -3
  57. package/package.json +7 -6
  58. package/web/app.js +439 -34
  59. package/web/behaviour-scale.js +32 -0
  60. package/web/behaviour-scale.test.js +39 -0
  61. package/web/index.html +23 -1
  62. package/web/pending.js +67 -0
  63. package/web/pending.test.js +62 -0
  64. package/web/read-cost.js +49 -0
  65. package/web/read-cost.test.js +76 -0
  66. package/web/refresh-queue.js +26 -0
  67. package/web/refresh-queue.test.js +54 -0
  68. package/example-choudoufu-estate/.behold.json +0 -8
package/AGENTS.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Driving behold (for agents)
2
2
 
3
3
  behold is a **read-only control plane** over a chant estate, with **delegated,
4
- gated** writes. As an agent you drive it the same way a human does — and the
4
+ gated** writes. As an agent you drive it the same way a human does, and the
5
5
  mutating capabilities are chant's MCP Op tools, not behold's, so nothing here holds
6
6
  apply creds.
7
7
 
@@ -14,6 +14,23 @@ apply creds.
14
14
  - Actions (delegated writes): `op-run` (start an `ApplyOp`/`ReconcileOp`),
15
15
  `op-signal` (approve a gate), `op-status` / `op-report` (watch it).
16
16
 
17
+ ## A declared workspace
18
+
19
+ A directory with a `chant.workspace.json` is a chant workspace (chant ≥ 0.81.0,
20
+ INTENTIUS/chant#2524). `behold serve <root>` serves it from chant's own member
21
+ list (`chant workspace ls --json`, read contract 1; any other contract is
22
+ refused), and reads each `chant` and `terraform` member through `chant
23
+ workspace graph --member <name>`. A `choudoufu` member keeps behold's own
24
+ reader, whose vocabulary (rungs, ownership, adoptable rows) chant's graph does
25
+ not carry. Node ids are `<member>/<id>` with the declared member name, the same
26
+ form chant composes. A member chant cannot read is drawn as one
27
+ `UnreadableMember` node carrying `attrs._unreadable: {code, message}` with
28
+ chant's reason code. An `other` member is listed on startup and in doctor and
29
+ draws nothing. `.behold.json`'s `members` are ignored beside a declaration;
30
+ `behold doctor --fix` writes the declaration for an estate that has only them.
31
+ Several directories on the command line (`behold serve a b c`) stay the loose
32
+ view behold composes itself.
33
+
17
34
  ## Getting a server
18
35
 
19
36
  ```sh
@@ -26,8 +43,8 @@ gets a structured `{code: "no-project"}` error from `/api/graph`, not a blank
26
43
  graph.
27
44
 
28
45
  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
46
+ one: the eleven workbench entries from `workbench.json` (this repo's own
47
+ catalog of the internal estates behold is developed against), the same
31
48
  `behold demo <entry>` load, `just example name="<entry>"` to serve one, and
32
49
  "The workbench catalog" below for what each is and what it boots.
33
50
 
@@ -43,7 +60,7 @@ preview-locked, and one load runs at a time (409 otherwise).
43
60
 
44
61
  ## The read loop
45
62
 
46
- 0. **discover** — GET `/api` lists every route with a one-line description,
63
+ 0. **discover.** GET `/api` lists every route with a one-line description,
47
64
  plus the server's version and a link back to this guide. Before the server
48
65
  exists (or when a route answers with an error you'd have to guess at), run
49
66
  `npx @intentius/behold doctor <dir> --json`: a read-only diagnosis of the
@@ -52,7 +69,7 @@ preview-locked, and one load runs at a time (409 otherwise).
52
69
  substrate readiness and committed Ops. Each check is
53
70
  `{name, status: pass|warn|fail, detail, fix}`; the process exits non-zero
54
71
  iff something failed. It starts no server and changes nothing.
55
- 1. **observe** — GET `/api/graph` (JSON: `{ ir, svg, meta }`). The mixed graph
72
+ 1. **observe.** GET `/api/graph` (JSON: `{ ir, svg, meta }`). The mixed graph
56
73
  of the project, every node with `id`/`kind`/`lexicon`/`attrs`/`sourceLoc`. Drift
57
74
  status, when present, is `attrs._status` (`good`=managed, `warn`=foreign,
58
75
  `accent`=pending, `neutral`=unobserved, `runtime`=runtime child). With
@@ -67,7 +84,26 @@ preview-locked, and one load runs at a time (409 otherwise).
67
84
  itself carries an address (chant#2045); `originSource: "inferred"` means
68
85
  behold read the id's spelling and nothing more, and a `forge: "unknown"` id
69
86
  is never resolved to a link on behold's guess.
70
- `?ops=1` is the ops lens — the project's declared Ops as a phase track, read
87
+ `?stacks=1` is the apply-order lens (#426): the cross-stack ordering chant
88
+ computes at synthesis and prints from `chant graph --stacks --json`, which
89
+ behold read nowhere and re-derived a subset of. One card per stack (a
90
+ lexicon partition), one box per wave, edges consumer to producer;
91
+ `meta.stacks` carries `order`, `waves` and `cycles` verbatim. It is
92
+ source-only (no env, no credentials, no substrate) and asks chant for the
93
+ project ROOT, not `graphPath`, because `runStackGraph` only sees
94
+ `ops/*.op.ts` from there (measured: `example-writes` answers `[aws,
95
+ temporal]` from `.` and `[aws]` from `src`). Single project only: an estate
96
+ is refused with `422 stacks-estate`, since `composeStacks` pools members
97
+ into one bucket per bare lexicon name and two members' `aws` stacks are not
98
+ one stack. A cycle is NOT a refusal — chant exits 1 with valid JSON and the
99
+ entangled stacks absent from `order`/`waves`, so they are drawn unwaved in
100
+ their own box and the note says so; nothing invents a wave number for a
101
+ stack chant could not order. `/api/project`'s `stacksCapable` gates the
102
+ stop. Note that `chant graph --stacks --json` is malformed today on two of
103
+ chant's own examples (an edge with no `from`, a `null` in `order` and in a
104
+ wave); `sanitizeStackGraph` treats `nodes` as the roster and drops anything
105
+ named nowhere else.
106
+ `?ops=1` is the ops lens: the project's declared Ops as a phase track, read
71
107
  from each emitted `dist/ops/<name>/op.json`, with the current run painted
72
108
  over it (`meta.run`, `meta.gate`); `/api/ops/<name>/status` reads that Op's
73
109
  durable run status and pending gate (`chant run status --temporal`). A
@@ -77,29 +113,120 @@ preview-locked, and one load runs at a time (409 otherwise).
77
113
  every rule, verbatim. `then: run(<op>)` is an edge to that Op's first step
78
114
  when the Op is in the rendered set, and `attrs.dangling` when it isn't.
79
115
  `meta.operator` on that same lens names the ConvergeOps the project declares
80
- (chant's own `searchAttributes.Converge === "true"`, read from op.json — no
116
+ (chant's own `searchAttributes.Converge === "true"`, read from op.json, no
81
117
  subprocess), and `/api/operator/status` fills the strip in from
82
118
  `chant operator status --json`.
83
- 2. **focus** — narrow with chant graph options as query params: `?detail=0..3`,
119
+ 2. **focus.** Narrow with chant graph options as query params: `?detail=0..3`,
84
120
  `?components=1`, `?logical=1`, `?lens=blast:<id>&down=1`, `?lens=lexicon:aws`,
85
121
  `?env=`, `?stack=`, `?tier=`, `?target=`, `?collapse=1`.
86
122
  `?collapse=1` (#393) shuts every member box holding more than 40 cards: the
87
123
  box is drawn as ONE card carrying the count it was badged with (`301
88
124
  resources · 84 bound · 85 unowned · 132 neutral`, in the estate's own
89
125
  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.
92
- 3. **inspect** — a node's `sourceLoc.file` is the typed source that declared it;
126
+ one so every count and note speaks about the picture on screen — except its
127
+ NOTE, which is written about the expanded estate plus one collapse clause
128
+ (#396): a shut box hides what the estate references rather than changing it.
129
+ ⌘K → "Collapse large boxes" / "Expand all". Below the limit it changes
130
+ nothing. **The toggle costs no live read** (#396): a member's overlay
131
+ document is cached under its source stamp and the read's own options, the
132
+ same key half `memberIr` and #404's plan cache use, so collapse and expand
133
+ are re-renders of a document behold already holds. `src/overlay-ir.ts` is
134
+ that cache and its header is the stated exception to src/member-ir.ts's
135
+ "a live read is never cached"; it names everything that drops an entry —
136
+ `POST /api/refresh` ("Re-check live"), `GET /api/overlay?plan=1`
137
+ ("Re-check live with plan", which re-checks both halves), the capture that
138
+ ends an Op run, and the member's source moving. A change made to the account
139
+ by something that is not behold is what the re-check rows are for.
140
+ 3. **inspect.** A node's `sourceLoc.file` is the typed source that declared it;
93
141
  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
142
+ 4. **find.** ⌘K also takes an ADDRESS (#393). Two characters in, the palette
95
143
  matches the ids and addresses of the graph the page is currently showing and
96
144
  offers up to twelve `node: <address>` rows, prefix matches first, each with
97
145
  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
146
+ the same address). Enter takes the same path a click on the card does (the
147
+ inspect pane and the highlight) and pans the graph onto it: `revealNode` in
100
148
  web/app.js drives the same viewBox the wheel, the drag and "⤢ fit" drive.
101
149
  Nothing here fetches, so it works in a static export too.
102
150
 
151
+ ### The picture arrives in pieces (#422)
152
+
153
+ `GET /api/overlay?progressive=1` answers at once with the estate composed from
154
+ every member's SOURCE, each node carrying `attrs._pendingRead`, and then
155
+ broadcasts one `member` SSE frame per member as its own live read settles —
156
+ `{member, statuses: {<composed id>: <status>}, unobserved?}`, or `{error}` if
157
+ the pass itself failed. `meta.mode` is `progressive` and `meta.pending` names
158
+ the members not yet read.
159
+
160
+ **Opt-in, and never the default.** `/api/overlay` without the flag is byte-for-
161
+ byte what it was: the blocking, complete live answer this guide's read loop
162
+ names, which is what an agent GETs and what `src/export.ts` captures into a
163
+ static bundle. A route that answered with members it had not read would hand an
164
+ agent a half-picture it could not know was half, and freeze a bundle with
165
+ members permanently still-reading.
166
+
167
+ **Pending is a third claim.** `_unobserved` is "behold looked and could not
168
+ see"; `neutral` is "the plan did not mention it"; `_pendingRead` is "the read
169
+ has not finished". It rides its own attr rather than a fifth `_status` because
170
+ a fifth `_status` is not paintable — pinhole's painter takes a closed set and
171
+ falls back to `neutral` for anything else, which is `unobserved`, the exact
172
+ wrong answer. The SPA draws it dashed and dimmed, never filled: the fill is the
173
+ status channel and a pending card has no status yet.
174
+
175
+ No live result is cached to build the first picture and none is reused. A
176
+ pending member becomes a real one only when its own read completes, and the
177
+ live pass a progressive request starts is the same one per member under the
178
+ same budget: a progressive load costs the estate no extra spawn.
179
+
180
+ ### What a read cost (#420, #421)
181
+
182
+ `GET /api/doctor` serves `diagnose()`, the same report `behold doctor` prints,
183
+ which until #421 only the CLI could ask for — with two extra blocks on it:
184
+
185
+ - `reads`, the ledger from `src/read-stats.ts`. Every scheduled chant read
186
+ passes through `ReadScheduler`, so each one is filed there with its running
187
+ time, its outcome, and which side of the source/live split it sits on. A bare
188
+ `chant graph` is the member's own TypeScript evaluation; anything with
189
+ `--live`/`--overlay`, and every other verb `isScheduledRead` allows, reaches
190
+ past it. `shared` counts subscribers handed an in-flight spawn, work not
191
+ done, never a run.
192
+ - `cache`, `memberIrCacheStats()`.
193
+
194
+ `GET /api/doctor?reads=1` is the same two blocks WITHOUT the diagnosis, and it
195
+ is what the SPA asks for. The split is the one #421 states: the full report is
196
+ for a person diagnosing and the panel line is for a person waiting. It is also a
197
+ cost split too: `diagnose()` probes docker, gh, helm and k3d through
198
+ src/substrates.ts, whose `probe` has no timeout and no kill, and the SPA asks
199
+ after every load settles. A static export asks for neither; `refreshReadCost`
200
+ returns on `staticMode` rather than relying on the bundle's own 404.
201
+
202
+ `reads.inFlight` is `{running, queued}` off the scheduler itself. The ledger
203
+ cannot answer it: a read still in flight has finished nothing, so it has filed
204
+ nothing. "What is queued now" is what someone watching a slow read wants.
205
+
206
+ Three things to know before reading a number off it. `queuedMs` is contention
207
+ **in the scheduler** (the HTTP-versus-poll-versus-capture competition #367
208
+ bounded) rather than the whole of what a member waits: the estate fan-out bounds
209
+ upstream through `mapPool` (src/estate.ts), so a three-member estate measured
210
+ 3033/3035/2218 ms of running time with `queuedMs` 0 against 5.2 s of wall clock.
211
+ The gap is the upstream wait and #422 is where it gets measured.
212
+
213
+ `reads.unmeasured` counts the served members the ledger is BLIND to, and the
214
+ panel line prints it rather than quietly excluding them. Only reads that pass
215
+ `ReadScheduler` are filed, which is every chant read and therefore a terraform
216
+ member too (its `via.read` shells `chant graph`, src/terraform-member.ts). A
217
+ choudoufu member spawns its own binary through its own helper and is filed
218
+ nowhere, so on a chant+choudoufu estate the slowest card may be one that never
219
+ reached `recent` — "slowest 3s · 1 not measured" is the honest form of that, and
220
+ an unqualified "slowest 3s" is not.
221
+
222
+ And preview mode keeps the totals but drops `recent`, whose samples are keyed by
223
+ member directory.
224
+
225
+ The SPA asks once after each load settles and shows the answer on the loading
226
+ scrim the *next* time one goes up (`web/read-cost.js`, `#loading-cost`): the
227
+ slowest recent member, because an estate read finishes when its last member
228
+ does, so a sum would report a number nobody waits for.
229
+
103
230
  ### The behaviour overlay (#398, M1 of #397)
104
231
 
105
232
  What the estate costs and where it runs out of headroom, on `/api/overlay`
@@ -129,7 +256,8 @@ Two sources, in this order:
129
256
  1. `attrs._behaviour` on the nodes, as `chant graph --live --overlay` painted
130
257
  them, with `meta._behaviour` beside them for the graph-level half.
131
258
  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
259
+ block}}` — read **only** when no node of that member carries the attr and
260
+ the member's read carried no `meta._behaviour` either (#402), and
133
261
  **only on the overlay** (`/api/graph` is the source graph; a prediction
134
262
  about a live account has no business on it). The file's keys are the
135
263
  member's own addresses; behold prefixes them to reach the composed id
@@ -147,10 +275,45 @@ configured.
147
275
  `meta.behaviour.refusal` is `{reason, remedy}` in the lexicon's own words,
148
276
  printed as it came, and it is present **instead of** everything above: a
149
277
  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
278
+ overlay is untouched either way: nothing in this pass reads or writes
151
279
  `_status`. `?logical=1` projects a different picture and carries no behaviour
152
280
  block in M1.
153
281
 
282
+ ### Live versus declared (#402, M5)
283
+
284
+ With a traffic level, `/api/overlay` predicts each member twice and carries
285
+ both figures and their difference. The level is `?traffic=` on the request,
286
+ else `.behold.json`'s `{"behaviour": {"traffic": "100 rps, p50"}}`, else none;
287
+ with none nothing is asked for and the payload is exactly M1's. behold never
288
+ defaults a level, because every figure is *at* one.
289
+
290
+ `meta.behaviour` gains `live`, `declared` (each `{perHour, currency, basis}`,
291
+ where `basis` is `total` for a figure the engine stated and `sum` for behold
292
+ adding the engine's per-entity figures) and `delta` (`{perHour, currency}`,
293
+ live minus declared), plus the same three per member under `members[<name>]`.
294
+ A delta is only ever like with like: one currency, and two totals or two sums.
295
+ Anything else yields no delta and a line in `diagnostics` saying why. A node
296
+ also carries the file's block under `attrs._behaviourDeclared`, including a
297
+ node the account no longer holds, which has no `_behaviour` at all.
298
+
299
+ Who reads what is `src/behaviour-delta.ts`'s header. A chant member's live
300
+ half is its own overlay read with `--traffic` added (sent only to a chant at
301
+ 0.75.0 or newer; an older one rejects the flag). A choudoufu member is not
302
+ read by chant, so behold predicts it through a generated reader project with
303
+ `binary: "choudoufu"` (the Terraform member's scratch project, under
304
+ `behold-chdf-*`), which needs the optional terraform lexicon. A terraform
305
+ member has no live half and so no delta.
306
+
307
+ The declared half goes through `memberIr` and is cached under the member's
308
+ source stamp plus the traffic level. The live half goes through `overlayIr`
309
+ and is dropped by whatever drops the overlay. With a level set, the
310
+ `behaviour.<env>.json` documents are not read at all: a fixture's figures
311
+ beside a real prediction would be two answers to one question.
312
+
313
+ `/api/graph?traffic=` (or the config default) paints the declared side alone
314
+ as `attrs._behaviour` with `meta.behaviour.declared`. The file is the only
315
+ estate that route has, so it carries no live half and no delta.
316
+
154
317
  ### Colour by drift, cost or headroom (#399, M2)
155
318
 
156
319
  The SPA colours the graph by one of three modes. The mode is **client-side
@@ -172,13 +335,13 @@ the drift overlay's neutral (`pinTokensFor().neutralFill`) and is marked
172
335
  Both ramps are derived from the active palette (`rampFor` in
173
336
  `web/behaviour-scale.js`), the way #229 derives the chrome: cost is a single hue
174
337
  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
338
+ verdict behold gets to paint, and headroom rides the three status hues the
176
339
  drift overlay already anchors, degraded → foreign → managed.
177
340
 
178
341
  One legend per mode, in the Model tab. The Scope tab carries a totals row per
179
342
  box and per estate for the active mode: `cost` quotes `meta.behaviour.boxes[key]`
180
343
  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 —
344
+ "engine total"); `headroom` has no server-side aggregate, since no engine states one —
182
345
  so the SPA takes the scope's min and median itself and the row says "computed".
183
346
  Every row carries `n priced · m unpriced`. In drift mode there is no row.
184
347
 
@@ -187,7 +350,7 @@ Everything either mode decides is pure in `web/behaviour-scale.js`, with
187
350
 
188
351
  ### Provenance on every figure, and the refusal (#401, M4)
189
352
 
190
- Every figure the SPA shows — the totals rows, the legend, and the inspect pane's
353
+ Every figure the SPA shows, the totals rows, the legend, and the inspect pane's
191
354
  `behaviour` section — carries `{engine} {version} · {tolerance} · {basis}`
192
355
  beside it, never once per page. `modeled` is spelled out on hover as "modeled,
193
356
  not billed": #397's one prohibition is a prediction presented as a bill, and the
@@ -196,7 +359,7 @@ a badge over a set (a box, the estate) says `mixed` on any field the set
196
359
  disagrees on rather than picking one.
197
360
 
198
361
  `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
362
+ ⌘K (visibly, with the lexicon's reason and remedy as their tooltip) and prints
200
363
  those words verbatim where the legend would be. The drift overlay keeps
201
364
  rendering; it never depended on an engine. `meta.behaviour.absent` disables the
202
365
  same two modes with the absent line as their tooltip and prints **nothing**:
@@ -229,7 +392,7 @@ session.
229
392
  {human, note}}`. Each entry carries `target`, `stage`
230
393
  (`emitted`/`bridged`/`applied`), `graduated`, `note` and a retypeable
231
394
  `applyCommand`. The same field appears on an ordinary `behold serve` whose
232
- project directory carries a carveout — no report and no demo needed — and is
395
+ project directory carries a carveout (no report and no demo needed) and is
233
396
  absent entirely when nothing has been carved.
234
397
 
235
398
  In the graph, an `applied` address draws inside the chant member box (keeping
@@ -281,7 +444,7 @@ behold does not apply. To change the estate:
281
444
  - `op-run <name>` — start the project's `ApplyOp` (code→cloud) or `ReconcileOp`
282
445
  (cloud→code PR).
283
446
  - `op-signal <name> <gate>` — approve a gate (e.g. a destructive apply).
284
- - `op-status <name>` — watch phases; `op-report <name>` — the run report.
447
+ - `op-status <name>` — watch phases; `op-report <name>`, the run report.
285
448
 
286
449
  Every mutation is a gated, durable Temporal workflow with a human-confirmable gate
287
450
  and saga rollback. There is no behold endpoint that mutates the cloud.
@@ -289,7 +452,7 @@ and saga rollback. There is no behold endpoint that mutates the cloud.
289
452
  A run behold triggered is asked for structured per-step records (chant#1676:
290
453
  `--progress-json` on the durable path, `--json` on the local one), so the ops
291
454
  lens paints the run over the declared track and a pending gate renders as a card
292
- with an Approve button. That button is `op-signal` — the same delegated write —
455
+ with an Approve button. That button is `op-signal`, the same delegated write —
293
456
  and nothing else about the run is behold's to decide: a stream that dies leaves
294
457
  the playhead at the last settled step and says so, and a gate paints as pending
295
458
  only when chant's `gateState` query named it.
@@ -333,7 +496,7 @@ A dispatched run is followed honestly (#165 §6, PR #350): only GitHub's own
333
496
  `completed` + conclusion paints `ok`/`failed`; a poll-failure budget or the
334
497
  follow deadline promotes the run to `lost` (chips frozen at last-observed,
335
498
  the run possibly still live at its own page), never to a verdict. The adopted
336
- run id is persisted as one JSON per project under `~/.behold/ci-runs/` — the
499
+ run id is persisted as one JSON per project under `~/.behold/ci-runs/`, the
337
500
  operator's machine-state, outside the project tree, so the Invariant below
338
501
  still names the whole in-project write surface. `GET /api/ci/run` reads the
339
502
  record; `POST /api/ci/readopt` (also attempted once at boot) re-follows an
@@ -345,14 +508,14 @@ reader instead of nothing.
345
508
  A project that declares a `ConvergeOp` gets an operator strip on the ops lens:
346
509
  per loop, the last tick's own log line (verbatim, with its instant), the lease,
347
510
  and how many gates are pending. **One** tick — `chant operator status` keeps only
348
- `records.at(-1)` — so the strip is never a timeline, and it says so.
511
+ `records.at(-1)`, so the strip is never a timeline, and it says so.
349
512
 
350
513
  The timeline is its own read: `GET /api/operator/log` → `chant operator log
351
514
  --json` (chant#2029), the tick records and the gate resolutions against them
352
515
  merged oldest-first. It lives in a panel that grows from the strip, and two rules
353
516
  hold it there. It is **pulled**, on the click that opens it and never on a timer.
354
517
  And it is **bounded** — `--limit` on every invocation, 50 by default and 200 at
355
- most, `--since` when asked — because the ledger grows one line per tick forever;
518
+ most, `--since` when asked, because the ledger grows one line per tick forever;
356
519
  the answer reports the window it used, so a full one can't read as the end of
357
520
  history. The route checks the project's chant version *before* it spawns: below
358
521
  0.53.1, `chant operator log` resolves to `chant operator`, the tick daemon, and
@@ -361,12 +524,12 @@ behold reading a history must never become behold running an operator.
361
524
  Since chant 0.53.1 (chant#2027) a tick record carries an `id` and the
362
525
  per-component verdicts it derived, and both ride through to
363
526
  `[].lastTick.{id,components}`. The strip line names the tick (truncated), and
364
- the verdicts join onto the component DAG by component name — the same key the
527
+ the verdicts join onto the component DAG by component name, the same key the
365
528
  live `chant components status` read is joined by. They join **under** that read:
366
529
  a tick only ever feeds the last tier of `componentStatusColor` (the
367
530
  reconciliation verdict), only on a node the live read left unpainted, and only
368
531
  while the tick is younger than fifteen of chant's own operator rounds. Past
369
- that it is named on the node, dated, and painted nothing — a graph fill has
532
+ that it is named on the node, dated, and painted nothing: a graph fill has
370
533
  nowhere to put "as of an hour ago". A chant older than 0.53.1 sends neither
371
534
  field and every one of these paths is a no-op.
372
535
 
@@ -386,13 +549,13 @@ design (a local tick with no PR behind it has none) and never synthesized, so a
386
549
  fact without one renders no link and no placeholder for one, and a url behold
387
550
  would not put behind an `href` reads as no address at all.
388
551
 
389
- A gate someone already resolved simply leaves `pendingGates` — the status read
390
- carries no `resolvedBy`/`when` — so the strip shows no resolved-by line rather
552
+ A gate someone already resolved simply leaves `pendingGates`, the status read
553
+ carries no `resolvedBy`/`when`, so the strip shows no resolved-by line rather
391
554
  than attributing an approval to nobody. The timeline is where resolutions are
392
555
  named, because there they are records rather than an inference.
393
556
 
394
557
  An `OperatorStack` (chant#1940) declares the loop as a k8s estate, so it already
395
- appears in the entity graph. behold names it there — the Namespace as the loop's
558
+ appears in the entity graph. behold names it there, the Namespace as the loop's
396
559
  home, each CronJob as a converge tick — off chant's own
397
560
  `app.kubernetes.io/component: converge-tick` labels, never a naming convention.
398
561
 
@@ -405,7 +568,7 @@ executor.
405
568
  ### The exceptions, and their exact size
406
569
 
407
570
  `POST /api/layout` (#228) writes **one** file in the served project:
408
- `.behold/layout.json` — the hand-layout sidecar, `{version, lenses: {<lens>:
571
+ `.behold/layout.json`, the hand-layout sidecar, `{version, lenses: {<lens>:
409
572
  {<node id>: {dx,dy,dw,dh}}}}`. That is the whole of behold's write surface
410
573
  inside a project, and it does not weaken the invariant above:
411
574
 
@@ -414,7 +577,7 @@ inside a project, and it does not weaken the invariant above:
414
577
  chant's, and a delta for a node that left the estate is dropped on read.
415
578
  - It **never touches the cloud and never touches your source**. No `.ts`, no
416
579
  `chant.config.ts`, no `.behold.json`. The path is `cfg.projectDir` + two
417
- constants — nothing from the request reaches the filesystem.
580
+ constants; nothing from the request reaches the filesystem.
418
581
  - It refuses politely when it shouldn't write: preview mode, a static-export
419
582
  capture, a read-only project directory, an oversized or malformed body.
420
583
  - It is **per-user state**, unlike `.behold.json` (config, meant to be tracked).
@@ -424,13 +587,23 @@ inside a project, and it does not weaken the invariant above:
424
587
  render with the deltas baked into the SVG, which is how `behold export` and
425
588
  static snapshots honour a hand layout.
426
589
 
427
- The second exception is the carve walkthrough's two steps above (#254), and it
590
+ A declared workspace (#464) keeps that file in the workspace root, the
591
+ directory holding `chant.workspace.json`, rather than in its first member.
592
+
593
+ `behold doctor --fix` (#464, ws-015) is the third exception, and it runs only
594
+ when you type the flag: for an estate root whose members are listed in
595
+ `.behold.json`, it writes `chant.workspace.json` in that root (never over an
596
+ existing one) and moves the saved `.behold/layout.json` from the first member to
597
+ the root with its node ids rewritten. It then runs `chant workspace check`. The
598
+ old layout file and `.behold.json` are left as they were.
599
+
600
+ The fourth exception is the carve walkthrough's two steps above (#254), and it
428
601
  is narrower still: they exist only when behold booted a `behold demo carve`
429
602
  copy, they write only into `<copy>/app/carveout/`, and the directory they write
430
603
  into is a scratch dir behold created inside a directory it copied for you a
431
604
  minute earlier. `carve bridge` runs without `--apply-rewrites`, so the demo's
432
605
  own Terraform is not edited either. The only request-derived value is `select`,
433
- and it must be an address the served report already ranks — the value that
606
+ and it must be an address the served report already ranks, the value that
434
607
  reaches the spawn's argv comes from a closed set read off disk. No cloud write,
435
608
  no Terraform mutation, no edit to anyone's chant source.
436
609
 
@@ -447,7 +620,7 @@ src/choudoufu-route.test.ts asserts it stays absent, for the same reason
447
620
  ## Changing behold (for agents working on this repo)
448
621
 
449
622
  The sections above are about driving a running behold. This one is about
450
- changing it — the working rules that used to live in a handoff note and now
623
+ changing it, the working rules that used to live in a handoff note and now
451
624
  live here, with the enforceable ones enforced.
452
625
 
453
626
  - **Branches and merges.** Work on a branch off `main` (an isolated worktree
@@ -458,7 +631,7 @@ live here, with the enforceable ones enforced.
458
631
  next onto what landed.
459
632
  - **The local gate** is `just check` (tsc, tests, build). Every vitest run
460
633
  also writes `.vitest-last.json` (the json reporter, PR #351), and `just
461
- test` keeps the reporter's full output in `vitest.log` — both gitignored —
634
+ test` keeps the reporter's full output in `vitest.log`, both gitignored —
462
635
  because #334's one-shot first-run failures kept losing the failing file's
463
636
  name. If the suite fails once and passes on rerun, those two files from the
464
637
  first run are the evidence; attach them to #334 (`jq '.testResults[] |
@@ -466,7 +639,7 @@ live here, with the enforceable ones enforced.
466
639
  - **Releasing** is `just release`, after the version-bump PR merged: it tags
467
640
  `behold-v<version>`, pushes the tag (which is what runs `release.yml`), and
468
641
  waits for npm to show the version. It refuses in every state where a tag
469
- push would be wrong. Never re-push a `behold-v*` tag by hand — a tag push
642
+ push would be wrong. Never re-push a `behold-v*` tag by hand: a tag push
470
643
  re-fires publish. Tags absent from origin (behold-v0.10.0, 0.10.1) stay
471
644
  absent for that reason.
472
645
  - **Scratch infrastructure** is governed by `src/scratch.ts`, asserted by
@@ -489,8 +662,8 @@ tool answers reads for it, and how it becomes a `GraphIR`. `src/member-kind.ts`
489
662
  is the one table; `chant` is the first row and every member was that row
490
663
  until the table existed. A new kind is:
491
664
 
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
665
+ 1. A `registerMemberKind({ kind, probe, expects, via, passes })` call in
666
+ `src/member-kind.ts`, the `probe` is sync, read-only and runs no code (a
494
667
  file's presence, a regex over a root file, or either: the choudoufu probe
495
668
  takes the `estate.chdf.hcl` sidecar, choudoufu's leading form, OR a `live {`
496
669
  block in a root *.tf, #387); `via.tool` stamps the binary and
@@ -505,7 +678,24 @@ until the table existed. A new kind is:
505
678
  `estateNamespaceScopes` already dispatch through `memberKindOf(dir)`.
506
679
  The two routes that render an estate — `/api/graph`'s estate branch and
507
680
  `/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`.
681
+ fork per kind; a kind's differences live inside its `read` or its `passes`.
682
+
683
+ `passes` (#427) is the render-time half, and it exists because the read-time
684
+ one could not hold it: the Terraform kind's third pass takes the request's
685
+ zoom, so it cannot run where the reader runs. `applyMemberPasses(ir,
686
+ {detail})` is the one call the six render sites make, and `memberPassNote(run,
687
+ dirs)` the line they return, each kind handed only the served dirs that are
688
+ its own, so a route never names a kind. Three rules a `passes` holds. It
689
+ mutates IN PLACE, because the existing passes do and their callers depend on
690
+ it. It self-guards on the IR's own CONTENT and never on the served members'
691
+ kinds. A chant project may declare a kind's lexicon; a chant project
692
+ declaring `terraform` is what `src/terraform-route.test.ts`'s first block
693
+ serves, and `memberKindOf` says `chant` there, so membership dispatch would
694
+ silently stop rendering it. And a run is carried forward, never re-run: a
695
+ pass that already elided its cards returns an empty elision the second time,
696
+ which drops the note on every composed estate. A test that re-registers a
697
+ kind to stub its reader spreads the real spec (`{ ...terraformSpec, via: … }`)
698
+ or it drops the passes with it.
509
699
  3. A `registerPack({ lexicon, iconFor, fields })` in `src/render.ts`, or the
510
700
  kind's cards lead with the alphabetically first two short attrs. A `fields`
511
701
  function takes the box the card is drawn in as its second argument, so a
@@ -522,7 +712,8 @@ until the table existed. A new kind is:
522
712
  `carveStatusReader` does; a PATH probe the way `src/demos.ts` does).
523
713
  6. Tests: the probe and the object form in `src/project.test.ts`; dispatch
524
714
  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
715
+ asserts chant members still go through exactly the calls they did, and its
716
+ "#427" block for the render passes; the
526
717
  kind's own reader off recorded documents in `src/__fixtures__/`, with
527
718
  provenance in prose above the load.
528
719
 
@@ -534,7 +725,7 @@ string is `chant`. Anything behold boots for a kind goes through
534
725
  things used to be offered to every member whatever it was, and a new kind gets
535
726
  all three answered for free:
536
727
 
537
- - `/api/project` publishes `memberKinds` — the served members' kinds, in
728
+ - `/api/project` publishes `memberKinds`, the served members' kinds, in
538
729
  composition order. The SPA opens on the `resources` zoom when none of them is
539
730
  `chant`, because `components` is a projection of a chant project's own
540
731
  component DAG and a member without one opens on "the components lens doesn't
@@ -551,7 +742,7 @@ all three answered for free:
551
742
  already returns and the SPA already reads as "no resource facet here".
552
743
 
553
744
  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.
745
+ answers where they are decided rather than per kind in a route.
555
746
 
556
747
  ### The workbench catalog
557
748
 
@@ -561,7 +752,7 @@ estates this checkout is developed against (#386, #388). Four rules, and
561
752
  src/demos.ts holds them:
562
753
 
563
754
  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
755
+ relative to the directory of the catalog file that named it, the intentius
565
756
  checkouts are siblings, so the workbench writes `../choudoufu`,
566
757
  `../waterpark`, `../chant`, and the file stays committed and reproducible.
567
758
  An entry whose path is not checked out is unsatisfiable exactly as a
@@ -598,21 +789,21 @@ its own graph and overlay counts as it runs.
598
789
 
599
790
  | entry | what it serves | needs |
600
791
  |---|---|---|
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 |
792
+ | `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
793
  | `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 |
794
+ | `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
795
  | `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 |
796
+ | `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
797
  | `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
798
  | `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
799
  | `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
800
  | `terralith-4` | the same at scale 4: 301 cards, all 301 bound. The estate behold is sized against | the same |
610
801
  | `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 |
802
+ | `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` + `@cdktn/hcl2json` beside behold — optional peers behold declares and does not install |
612
803
 
613
804
  **The scratch, by name and port.** Each emulator is the entry's own, booted by
614
805
  its up script and removed by the `scripts/down.sh` that script wrote into the
615
- target — never by pattern.
806
+ target rather than by pattern.
616
807
 
617
808
  | entry | container | host port |
618
809
  |---|---|---|
@@ -623,7 +814,7 @@ target — never by pattern.
623
814
  | `terralith-1` | `behold-wb-terralith-1` | 4655 |
624
815
  | `terralith-4` | `behold-wb-terralith-4` | 4656 |
625
816
  | `terralith-4-adopt` | `behold-wb-terralith-4-adopt` | 4657 |
626
- | `chant-*`, `waterpark` | none — source reads, no substrate | — |
817
+ | `chant-*`, `waterpark` | none (source reads, no substrate |) |
627
818
  | `fountain-ops` | the k3d cluster `fountain-local`, which is the checkout's, not behold's | — |
628
819
 
629
820
  The port is the floci's host binding and the entry's `serve.spawnEnv`
@@ -634,10 +825,22 @@ behold spawns talk to the same emulator.
634
825
  loaded through `behold demo <entry> <tmp target> --port <p>` on its own port
635
826
  from 4720 up, `/api/graph` asserted to carry nodes and a live entry's
636
827
  `/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.
828
+ per entry; eight entries and 553s on the machine this was written on.
829
+
830
+ Time is asserted, not only printed (#423). Each entry carries a ceiling at
831
+ roughly twice its own measured value, and the run carries one too, because
832
+ #419's whole subject was a number nobody was watching: the ratio could not
833
+ regress silently and the duration could. The measured figures the ceilings come
834
+ from are in the script beside them; `BEHOLD_E2E_SECS_<entry>` and
835
+ `BEHOLD_E2E_RUN_SECS` override. A live entry also asserts #422's shape: the
836
+ progressive overlay answers with every member marked pending and does so before
837
+ the blocking read returns, so the estate cannot quietly regress to
838
+ all-or-nothing. Where the blocking read is already sub-second there is nothing
839
+ to beat, and the run reports the two numbers rather than claiming a pass.
840
+
638
841
  `terralith-4-adopt` is asserted twice — 85 UNOWNED, then the
639
842
  `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
843
+ target the way a person would, then 301 bound, because that write is the
641
844
  person's, never behold's (#372). `waterpark` is checked for an untouched
642
845
  checkout afterwards. `missingRequirements` decides what runs: a missing binary,
643
846
  an unchecked-out sibling, an uninstalled chant example or the absent Terraform
@@ -646,7 +849,7 @@ run exits 0. `fountain-ops` is skipped by name everywhere, for the reason in
646
849
  the table. A `behold-wb-*` container left standing at the end fails the run.
647
850
 
648
851
  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
852
+ estate (every generated entry is one) served the no-project card, because the
650
853
  single-project read is `chant graph <dir>` and such a directory has no lexicon
651
854
  for it to read. `servesAsEstate` (src/member-kind.ts) is the predicate that
652
855
  routes one directory of a non-chant kind through the estate compose path, where
@@ -660,28 +863,28 @@ A Terraform estate reaches behold through chant.
660
863
  `@intentius/chant-lexicon-terraform` reads the HCL an estate already has and
661
864
  emits one entity per block, so **a Terraform estate is a chant project whose
662
865
  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
866
+ other. behold parses no HCL and ships no HCL parser, the same posture
664
867
  `src/carve-lens.ts` states for the carve report (#378).
665
868
 
666
869
  Three passes turn what arrives into a picture (`src/terraform-lens.ts`), in this
667
870
  order, guarded on the IR carrying terraform entities so every other estate gets
668
871
  the identical object back:
669
872
 
670
- 1. **`normalizeTerraformNodes`** — a node's `kind` arrives as the entity class
873
+ 1. **`normalizeTerraformNodes`.** A node's `kind` arrives as the entity class
671
874
  (`Terraform::Resource`), not the resource type, so every card would be titled
672
875
  and iconed the same. The type moves out of `attrs.address` into `kind` and
673
876
  the block class lands in `attrs.block`. That is the shape a carve node
674
877
  already has, which is why one presentation pack serves both.
675
- 2. **`groupTerraformByRoot`** — roots are a Terraform project's only grouping.
878
+ 2. **`groupTerraformByRoot`.** Roots are a Terraform project's only grouping.
676
879
  It retires itself when chant#2266 groups upstream. A node sits in exactly one
677
880
  box, so a box every node has left is dropped rather than drawn empty (#393):
678
881
  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
882
+ and the root boxes then take those same nodes, which is what put an empty
680
883
  box named `access` on water park's canvas. What the member box was there to
681
884
  say moves into the root box's title, which is `<member>/<root>` whenever the
682
885
  estate holds more than one member and the bare root name when it does not
683
886
  (two members with a root apiece named `prod` would otherwise merge silently).
684
- 3. **`filterTerraformCards`** — what is a card, below.
887
+ 3. **`filterTerraformCards`.** What is a card, below.
685
888
 
686
889
  **What is a card (#382).** Measured on a real estate: 247 nodes for 43
687
890
  resources, four fifths of it not infrastructure.
@@ -709,7 +912,7 @@ of root names and drop the counts.
709
912
  were recorded when a Terraform IR carried none, and lexicon 0.61.0 (chant#2265,
710
913
  which resolves a block's `"${…}"` references) draws 390 over water park's five
711
914
  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
915
+ without it (a cross-root read by name) was measured and refused (#381): both
713
916
  ends carry the same unresolved interpolation, so a match would be a coincidence
714
917
  of variable naming. A data source says what it reads as a row instead.
715
918
 
@@ -725,11 +928,11 @@ across a read. Three decisions, each a trade #384 left open and each measured on
725
928
  water park's `access/` before it was taken:
726
929
 
727
930
  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
931
+ own location, so the generated project has to see it, and making it a
932
+ dependency would put `@cdktn/hcl2json`, a ~1.8 MB wasm blob, in the install
730
933
  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
934
+ and `@cdktn/hcl2json` are therefore **optional peers**: declared in
935
+ package.json (the only place their versions are named, the refusal reads
733
936
  them from there), never installed by behold, probed at serve and doctor time,
734
937
  and refused with the one install line and where behold looked. The same gate
735
938
  `behold demo` puts on a binary it does not ship. The lexicon's own chant peer
@@ -737,12 +940,12 @@ water park's `access/` before it was taken:
737
940
  loads no published version of it (`applyLineage is not a function`).
738
941
  2. **Roots are discovered, and the skips are reported.** A root is a directory
739
942
  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
943
+ beside a `resource`, `data` or `module` block, a regex probe at the depth
741
944
  the choudoufu probe uses, no HCL parsed. #384 proposed the first half alone
742
945
  ("what a root has and a called module does not") and the estate refuted it:
743
946
  water park's `modules/persona` is a shared module called by three roots and
744
947
  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
948
+ beside the probe, a directory under a `modules/` segment (Terraform's own
746
949
  standard module structure; the roots that call it draw its blocks already)
747
950
  and one with nothing to draw (`access/backends` is two backend fragments) —
748
951
  and both are named in the graph's note with their reason, the way
@@ -755,7 +958,7 @@ water park's `access/` before it was taken:
755
958
  render goes through chant. A kind whose `read` shells `chant graph` against a
756
959
  generated config is a scaffold, not a second reader, and it inherits the
757
960
  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
961
+ free, so a Terraform root composes in an estate beside a chant project and a
759
962
  choudoufu estate at no extra cost. `src/terraform-member.ts` is the whole of
760
963
  it; `detectProjectShape` answers a directory that is its own member with
761
964
  `membersFrom: "probe"`, which is what retired #384's `no chant.config.ts
@@ -783,19 +986,19 @@ the listing. Neither of them ever compares an attribute VALUE. A resource whose
783
986
  tag, path or mutability was changed out of band still carries its markers, so
784
987
  it is still bound, and it is still green.
785
988
 
786
- The other half of the terminal's answer — "would a plan change anything?" — is
989
+ The other half of the terminal's answer ("would a plan change anything?") is
787
990
  a **separate, opt-in read**: `choudoufu plan -input=false -out=<tmp>` then
788
991
  `choudoufu show -json <tmp>`, whose `resource_changes[].change.{actions,before,
789
992
  after}` name the attributes and both their values. (`choudoufu plan -json` is
790
993
  NOT this: on 0.16.0 it prints choudoufu's own ownership document, the same
791
994
  shape as `live-plan -json`, with no attribute values in it at all. The
792
995
  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
996
+ a scratch directory of behold's own and removed after rather than inside the served
794
997
  member, which would be a write into someone's source.
795
998
 
796
999
  It is opt-in because it costs differently: the ownership read is answered out
797
1000
  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:
1001
+ every resource, one provider read per card, 301 of them on `terralith-4`. So:
799
1002
 
800
1003
  - **Never on an ordinary overlay read.** `GET /api/overlay?plan=1` and
801
1004
  `GET /api/diff?plan=1` are the only things that spawn one, and the palette's
@@ -811,12 +1014,110 @@ every resource — one provider read per card, 301 of them on `terralith-4`. So:
811
1014
  is still painted bound and still counted `bound`; it gains `attrs._planDrift`
812
1015
  and wears a dashed `--degraded` edge plus a `~ n attributes` corner glyph
813
1016
  (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
1017
+ operator marks). A card that is not bound is never marked: an unowned
815
1018
  object's planned `create` is an ownership fact the overlay already paints,
816
1019
  and `planDrift` drops creates for that reason.
817
1020
  - **`/api/overlay`'s meta carries `drift`**: `{read: false}` or `{read: true,
818
1021
  drifted: n}`. The two are different answers — "nobody looked" versus "looked,
819
- nothing drifted" — and the legend prints a count only for the second.
1022
+ nothing drifted", and the legend prints a count only for the second.
1023
+
1024
+ ### A lexicon behold draws but cannot aim (#430)
1025
+
1026
+ `behold doctor`'s **targets** line. `SUBSTRATE_TARGET_VARS` (src/targets.ts)
1027
+ names four lexicons against the nine behold draws, deliberately — it withholds
1028
+ one until chant can actually be pointed at an emulator for it. That module also
1029
+ names the cost: a landed chant change leaves a stale omission "until someone
1030
+ notices", which happened once (#125) and left behold showing a floci-gcp pill it
1031
+ could not aim a read at. This is the line someone reads instead.
1032
+
1033
+ `NO_AMBIENT_TARGET` (same module) is what stops it being noise: k8s and temporal
1034
+ are not withheld, they are finished — they bind from `chant.config` rather than
1035
+ an ambient variable, so there is nothing to override and nothing for a picker.
1036
+ Those report as settled, not as a gap.
1037
+
1038
+ What the line CANNOT say, stated because the gap is the point: the honest
1039
+ question is "chant can route this and behold does not list it", and behold
1040
+ cannot ask it. Since chant 0.61 the endpoint variable is declared by the LEXICON
1041
+ PLUGIN (`endpointEnvVarsFor`), not by a table in core, and behold installs no
1042
+ lexicon plugins; the served project does. No chant CLI reports them either
1043
+ (`lifecycle whoami --json` answers identity and says nothing about endpoints).
1044
+ So the check warns on the weaker question it can answer and says it may be
1045
+ over-reporting. The real fix is upstream, and #430 records the ask.
1046
+
1047
+ ### A card with no live half is not an unobserved one (#429)
1048
+
1049
+ pinhole's painter takes a closed `_status` set and falls back to `neutral` for
1050
+ anything else, `undefined` included. So on a mixed overlay a member with no live
1051
+ half, a Terraform root, whose `live`/`overlay`/`env` are stripped in
1052
+ src/terraform-member.ts — painted exactly like a card behold looked at and could
1053
+ not see, while the legend counted only the latter. Measured on
1054
+ `serve example-writes example-terraform-estate --env prod`: 37 Terraform cards
1055
+ carrying no `_status`, 2 chant cards carrying `neutral`, one fill between them.
1056
+
1057
+ Those cards now carry `data-no-live` and the legend gives them their own row.
1058
+ Named rather than folded into `unobserved`, because "I looked and could not see"
1059
+ and "there is nothing here to look at" are different claims, the same argument
1060
+ #399 makes for an unpriced card, and the same answer: mark it, do not merely
1061
+ colour it. The mark is overlay-only; on the source graph no card has a status
1062
+ and marking every one of them would say nothing.
1063
+
1064
+ This is presentation, not vocabulary. `statusVocabulary` gets no terraform row:
1065
+ there is no status to name. See #429 for the measurement that established it.
1066
+
1067
+ ### An adoptable row is painted like an unowned one, plus its evidence (#413)
1068
+
1069
+ A `NEEDS_DISCOVERY` omission paired with an `adoptable[]` row at the same
1070
+ address paints `warn` with `ownership: "foreign"`, the same verdict the UNOWNED
1071
+ branch gives and for the same reason: the live object carries no marker for
1072
+ this estate. On its own, `NEEDS_DISCOVERY` stays neutral — the declaration
1073
+ names no identity, and with nothing found that is a "could not answer" rather
1074
+ than an action.
1075
+
1076
+ The card leads with `attrs.adopt`, which is choudoufu's own `adopt_command`
1077
+ here rather than the marker pair an unowned row carries. `adoptLine`'s
1078
+ composed-command branch had no real caller until this. behold composes no
1079
+ command of its own: the API that writes a tag differs per resource type.
1080
+
1081
+ `attrs.matchedOn` is the difference in kind. An unowned row needs no evidence
1082
+ because the identity IS the match; here choudoufu asserts that a live object is
1083
+ a declaration because their identity-bearing arguments agree, and a person
1084
+ about to stamp two tags onto somebody's resource is owed the list.
1085
+
1086
+ `web/app.js` renders both through the one copyable row, and its tooltip says
1087
+ whether the line is a command or a pair of tags.
1088
+
1089
+ ### The adoption sweep is off unless a serve asks (#412)
1090
+
1091
+ `live-plan -json` carries `adoptable[]` (live resources the estate-wide sweep
1092
+ matched to a declared instance by content) and `swept` (the types it listed in
1093
+ full). Both are empty on a run that did not ask the account-inventory question,
1094
+ and behold does not ask by default.
1095
+
1096
+ The opt-in is `TOFU_LIVE_COLLECT_UNCLAIMED=1` (`ADOPTION_SWEEP_VAR`,
1097
+ src/choudoufu-live.ts), carried through `serve.spawnEnv`, the seam
1098
+ `setChoudoufuSpawnEnv` already feeds every choudoufu spawn. The env var rather
1099
+ than `-adoption-only`, because that flag suppresses the resource diff the
1100
+ overlay needs. Off by default because the sweep is bounded by the ACCOUNT rather
1101
+ than the estate, and behold polls. `0` means no, which is choudoufu's own
1102
+ spelling, not a truthiness test.
1103
+
1104
+ **Content matching is a closed per-type table**, and that is what decides
1105
+ whether a declaration can ever produce an `adoptable[]` row. `matchTable`
1106
+ (choudoufu's internal/live/foreign/classify.go) lists eleven types against the
1107
+ identity-bearing arguments a match may use — `aws_vpc` on `cidr_block`,
1108
+ `aws_security_group`/`aws_lb`/`aws_sns_topic` and friends on `name`,
1109
+ `aws_subnet` on `cidr_block` + `availability_zone`, `aws_acm_certificate` on
1110
+ `domain_name`. A type absent from it, in the table's own words, "can never
1111
+ produce a bind candidate". `aws_iam_role` is absent, which is why #412's first
1112
+ attempt measured a shape that cannot match rather than a gap. A resource with a
1113
+ `count`/`for_each` key is skipped too, so a fixture wants a scalar.
1114
+
1115
+ `src/__fixtures__/choudoufu-live-plan-adoptable.json` is the real thing,
1116
+ recorded off floci: one scalar `aws_vpc` declaring a static `cidr_block`, its
1117
+ unmarked live twin created out of band, and the run asked with the opt-in. It
1118
+ carries the pairing M2 is about, a `NEEDS_DISCOVERY` omission and an
1119
+ `adoptable[]` row at the same address — plus `matched[]` naming what was
1120
+ compared and choudoufu's own `adopt_command`.
820
1121
 
821
1122
  ### A choudoufu estate's own references (#393)
822
1123
 
@@ -828,8 +1129,8 @@ estate references anything else", which is false about every one of them.
828
1129
 
829
1130
  The references were never missing; nothing was reading them. **A choudoufu
830
1131
  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
1132
+ directory**, one root, the estate directory itself, through the same scratch
1133
+ project machinery above (nothing written under the estate), and joins the two
833
1134
  documents by address. behold still parses no HCL: `src/choudoufu-refs.ts` is
834
1135
  two lists of strings and the rules that match them, and its header is the
835
1136
  argument for each. In short: the lexicon names *blocks* in a path of module
@@ -846,7 +1147,7 @@ Every edge is `inferred` and carries the lexicon's own attribute name (`role`,
846
1147
  `policy_arn`), so the card says what made the reference. **Without the
847
1148
  lexicon there are no edges and the note says so** — carrying the terraform
848
1149
  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
1150
+ assert, and the lexicon's version is in the member's cache stamp, so
850
1151
  installing it invalidates the edgeless IR rather than serving it forever.
851
1152
 
852
1153
  The layout half is `packBoxComponents` (src/render.ts). src/edgeless.ts wraps a
@@ -856,7 +1157,7 @@ dagre lays 42 connected components side by side on three ranks. The pass packs
856
1157
  a box's components into shelves and resizes the box around them, re-laying each
857
1158
  component on its own first (inside a cluster dagre interleaves them: a six-card
858
1159
  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
1160
+ strip either way: the DNS fan is 19564 x 400 upright and 900 x 10046 on its
860
1161
  side. A box under 4:1 is left exactly as it laid out. Measured: `terralith-4`
861
1162
  7660 x 5162 with 266 edges, waterpark 3536 x 3138 at detail 2 and 7142 x 6974
862
1163
  at detail 3 (from 4.2:1 and 15.1:1, the case #393's wrap did not answer).