@intentius/behold 0.17.0 → 0.19.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 (60) hide show
  1. package/AGENTS.md +757 -26
  2. package/LICENSE +190 -0
  3. package/README.md +251 -187
  4. package/THIRD_PARTY.md +75 -0
  5. package/demos.json +80 -9
  6. package/dist/cli.js +5479 -1673
  7. package/example-argo-estate/package-lock.json +6 -6
  8. package/example-carve/app/package-lock.json +3 -3
  9. package/example-choudoufu-estate/.behold.json +8 -0
  10. package/example-choudoufu-estate/README.md +48 -0
  11. package/example-choudoufu-estate/monolith/behaviour.live.json +495 -0
  12. package/example-choudoufu-estate/monolith/carve.json +12 -0
  13. package/example-choudoufu-estate/monolith/main.tf +127 -0
  14. package/example-choudoufu-estate/scripts/choudoufu-down.sh +8 -0
  15. package/example-choudoufu-estate/scripts/choudoufu-up.sh +62 -0
  16. package/example-choudoufu-estate/team-a/behaviour.live.json +179 -0
  17. package/example-choudoufu-estate/team-a/main.tf +58 -0
  18. package/example-choudoufu-estate/team-b/behaviour.live.json +179 -0
  19. package/example-choudoufu-estate/team-b/main.tf +73 -0
  20. package/example-choudoufu-estate/team-c/behaviour.live.json +179 -0
  21. package/example-choudoufu-estate/team-c/main.tf +51 -0
  22. package/example-flux-estate/package-lock.json +6 -6
  23. package/example-k8s/package-lock.json +6 -6
  24. package/example-terraform-estate/README.md +109 -0
  25. package/example-terraform-estate/backends/backend.s3.tf +10 -0
  26. package/example-terraform-estate/envs/dev/README.md +5 -0
  27. package/example-terraform-estate/envs/prod/data.tf +18 -0
  28. package/example-terraform-estate/envs/prod/network.tf +80 -0
  29. package/example-terraform-estate/envs/prod/outputs.tf +13 -0
  30. package/example-terraform-estate/envs/prod/provider.tf +10 -0
  31. package/example-terraform-estate/envs/prod/services.tf +21 -0
  32. package/example-terraform-estate/envs/prod/storage.tf +31 -0
  33. package/example-terraform-estate/envs/prod/variables.tf +17 -0
  34. package/example-terraform-estate/envs/prod/versions.tf +10 -0
  35. package/example-terraform-estate/modules/service/main.tf +58 -0
  36. package/example-terraform-estate/modules/service/outputs.tf +7 -0
  37. package/example-terraform-estate/modules/service/variables.tf +16 -0
  38. package/example-terraform-estate/modules/service/versions.tf +10 -0
  39. package/example-terraform-estate/platform/iam.tf +40 -0
  40. package/example-terraform-estate/platform/kms.tf +16 -0
  41. package/example-terraform-estate/platform/outputs.tf +7 -0
  42. package/example-terraform-estate/platform/variables.tf +10 -0
  43. package/example-terraform-estate/platform/versions.tf +10 -0
  44. package/example-writes/package-lock.json +3 -3
  45. package/licenses/cncf-artwork-LICENSE.md +13 -0
  46. package/licenses/iTerm2-Color-Schemes-LICENSE +31 -0
  47. package/licenses/kubernetes-icons-LICENSE +201 -0
  48. package/package.json +25 -7
  49. package/web/app.js +1309 -41
  50. package/web/behaviour-scale.js +380 -0
  51. package/web/behaviour-scale.test.js +307 -0
  52. package/web/cross-env.js +58 -0
  53. package/web/cross-env.test.js +61 -0
  54. package/web/index.html +62 -2
  55. package/web/pending.js +67 -0
  56. package/web/pending.test.js +62 -0
  57. package/web/read-cost.js +49 -0
  58. package/web/read-cost.test.js +76 -0
  59. package/web/refresh-queue.js +26 -0
  60. package/web/refresh-queue.test.js +54 -0
package/README.md CHANGED
@@ -1,15 +1,14 @@
1
1
  # behold
2
2
 
3
- **A live control plane on [chant](https://github.com/INTENTIUS/chant).** Behold your
4
- whole estate — every substrate (AWS, k8s, GCP, Azure, Fly) in one graph, coloured by
5
- drift — then act through delegated, gated Ops.
3
+ **A live control plane on [chant](https://github.com/INTENTIUS/chant).** Every
4
+ substrate in one graph, coloured by drift. Then act through delegated, gated Ops.
6
5
 
7
- 📖 **[Documentation](https://intentius.io/behold/)** — starts with an agent prompt that
8
- gets you to a running graph without reading anything else first.
6
+ 📖 **[Documentation](https://intentius.io/behold/)** starts with an agent prompt
7
+ that gets you to a running graph without reading anything else first.
9
8
 
10
9
  Where Argo CD shows one cluster's tree, behold shows the mixed-substrate estate:
11
- cloud drift on AWS, supply-chain drift on GitHub Actions, artifact presence and
12
- render provenance on Helm — each substrate's own kind of truth, in one picture.
10
+ cloud drift on AWS, supply-chain drift on GitHub Actions, artifact presence on
11
+ Helm. Each substrate's own kind of truth, in one picture.
13
12
 
14
13
  ```
15
14
  chant source ──build/lint──▶ graph IR ──behold──▶ live graph + drift + (delegated) actions
@@ -18,27 +17,51 @@ chant source ──build/lint──▶ graph IR ──behold──▶ live graph
18
17
 
19
18
  ## Quick start (npm)
20
19
 
21
- No chant project yet? The bundled demo is the five-minute path — an S3 bucket +
22
- policy served against a local emulator, no cloud account, no credentials
23
- (needs Docker):
20
+ No chant project yet? The bundled demo is the five-minute path: an S3 bucket and
21
+ policy against a local emulator. Needs Docker, and nothing else.
24
22
 
25
23
  ```sh
26
24
  npx @intentius/behold demo # copies the example to ./behold-demo, installs, serves
27
- # → http://localhost:4600 — blue = declared; click Deploy, watch it turn green
25
+ # → http://localhost:4600. Blue = declared; click Deploy, watch it turn green
28
26
  ```
29
27
 
30
28
  The copied project is yours: edit its source and watch the graph change live.
31
- There's a whole catalog — `behold demo --list` names the rest (`behold demo
29
+ There's a whole catalog, and `behold demo --list` names the rest (`behold demo
32
30
  k8s` stands the same loop up on a throwaway k3d cluster: runtime Pods, field
33
- ownership; `behold demo argo-estate` needs nothing at all — a three-project
31
+ ownership; `behold demo argo-estate` needs nothing at all, being a three-project
34
32
  Argo CD estate, declared only, so it runs where Docker doesn't; `behold demo
35
- carve` is the Terraform peel walkthrough, below). Every loaded demo lands in
36
- the panel's recents, so switching between them is the Scope tab.
33
+ choudoufu-estate` is four tag-owned OpenTofu estates served composed, where a
34
+ card's colour is **ownership** (bound, unowned, pending) rather than "nothing
35
+ changed": a resource whose attributes drifted out of band still carries its
36
+ markers and is still green, so attribute drift is a separate read you ask for
37
+ with ⌘K → "Re-check live with plan"; `behold demo carve` is the Terraform peel
38
+ walkthrough, below). Two more need no Docker either: `behold demo
39
+ terraform-estate` draws a multi-root Terraform estate boxed by root, and shows
40
+ what behold refuses to infer, since one root reads a key the other declares and
41
+ no edge is drawn between them; `behold demo flux-estate` is a GitOps estate on
42
+ k3d, a Flux control plane plus two app projects composed. `behold demo fountain`
43
+ clones a real self-hosted app onto a throwaway cluster. Every loaded demo lands
44
+ in the panel's recents, so switching between them is the Scope tab.
45
+
46
+ Working in a checkout rather than an npm install? There is a second catalog,
47
+ `workbench.json`, which is this checkout's and is not shipped. It is
48
+ deliberately absent from package.json's `files`, so an npm install has
49
+ `demos.json` and nothing else. It holds the eleven internal estates behold is
50
+ developed against, named by relative path from this repo's root, so an entry
51
+ whose sibling checkout you don't have says so in `--list` instead of failing
52
+ halfway: chant's own two examples and water park's `access/` Terraform roots
53
+ served in place, `../fountain-ops` on its own k3d cluster, the live-mv
54
+ workbench's four estates, three `estate-gen` cohorts, and the generated
55
+ terralith at scale 1 and 4, greenfield and adopted from a stock `terraform
56
+ apply` by a `live-import` you run by hand. `just example terralith-4` serves
57
+ one (`just example name="<entry>"`); each that boots a substrate brings up its
58
+ own scratch emulator and leaves a `scripts/down.sh` in the target that removes
59
+ it. `just e2e-workbench` loads the whole catalog and asserts each graph.
37
60
 
38
61
  The catalog is in the panel too (#268): the Scope tab's switcher lists every
39
- bundled demo under your recents, one click to copy, install and serve it —
40
- demos whose prerequisites are missing stay visible, disabled, saying what to
41
- install, and one that would clone from the network says so on the button.
62
+ bundled demo under your recents, one click from any served project. Demos
63
+ whose prerequisites are missing stay visible and disabled, saying what to
64
+ install; one that would clone from the network says so on the button.
42
65
 
43
66
  Already have a chant project?
44
67
 
@@ -51,86 +74,67 @@ npx @intentius/behold serve . --env prod --poll 30 # live drift overlay
51
74
 
52
75
  `behold doctor` is the first thing to run on a project behold hasn't seen: one
53
76
  line each for the project's kind, its own chant install and version, declared
54
- lexicons, the envs the picker will infer, the kube context chant binds versus
55
- your ambient one, substrate readiness and committed Ops — pass/warn/fail with
56
- a one-line fix. It starts nothing and changes nothing; it exits non-zero only
57
- when something would actually stop behold serving the project well, so CI can
58
- gate on it. `--json` for scripts and agents.
59
-
60
- Driving it from an agent or script? `GET /api` lists every JSON route;
77
+ lexicons, which of them can be aimed at an emulator, the envs the picker will
78
+ infer, the kube context chant binds versus your ambient one, substrate
79
+ readiness, committed Ops, and a line per non-chant member kind it found. Each is
80
+ pass, warn or fail with a one-line fix. Nothing is started and nothing changes.
81
+ The exit code is non-zero only when something would actually stop behold serving
82
+ the project well, so CI can gate on it. `--json` for scripts and agents.
83
+
84
+ Driving it from an agent or script? `GET /api` is the front door. It lists the
85
+ graph and action routes with a one-line description each, and
86
+ `GET /api/doctor?reads=1` answers what the last reads cost.
61
87
  [AGENTS.md](./AGENTS.md) (shipped in the package) is the read/act contract.
62
88
 
63
- ## Preview: your project, or the Loom-on-Floci demo (v0.1.0)
89
+ ## Preview: one project's graph, nothing set up
64
90
 
65
91
  `behold preview` is the quick way to look at a chant project's graph in a
66
- browser at one port. Plain, it just opens the project you point it at — no env,
67
- no emulator. From a repo checkout the same commands run through `npm run dev`:
68
-
69
- ```sh
70
- npm install
71
- npm run dev -- preview # → http://localhost:4600, cwd as the project
72
- npm run dev -- preview ../my-project # → someone else's project
73
- ```
74
-
75
- With no path, it opens the **current directory** — run it from inside your chant
76
- project. Pass a path to look at another one.
77
-
78
- behold also ships a turnkey **demo**: the whole live experience running Loom on a
79
- local [Floci](https://github.com/lex00/floci) emulator, no cloud account and
80
- nothing to configure. Opt in with `--emulator`:
92
+ browser at one port. No env, no emulator, no config: it opens the project you
93
+ point it at.
81
94
 
82
95
  ```sh
83
- npm run dev -- preview ../loomster --emulator # → http://localhost:4600
96
+ npx @intentius/behold preview # the current directory
97
+ npx @intentius/behold preview ../my-project # somewhere else
84
98
  ```
85
99
 
86
- `--emulator` injects the env Loom's own Floci setup expects
87
- (`AWS_ENDPOINT_URL=http://localhost:4566`, dummy AWS creds, `LOOM_ENV=local`) and
88
- locks the UI into previewMode (git/PR ops hidden, substrate strip scoped to
89
- Docker+Floci, no arbitrary-project switching). If Floci isn't up, **Bring up** on
90
- its substrate pill boots the emulator and deploys Loom. Needs Docker.
100
+ With no path it opens the **current directory**, so running it from inside your
101
+ project is the shortest thing that works. From a checkout the same commands run
102
+ through `npm run dev -- preview`.
91
103
 
92
- **What you can do (with `--emulator`):** explore the graph at every **zoom**
93
- (components → logical → composites → resources → attributes, with an optional
94
- radial layout — where _logical_ is a traditional AWS architecture diagram: nested
95
- VPC/subnet ⊃ component boxes, CIDRs as labels, one headline resource per
96
- composite), watch live per-component status, read the reconcile plan, inspect any
97
- node, and **deploy to the emulator** — the full observe → reconcile → apply,
98
- Apply all, and a one-click Reset.
104
+ To colour the graph by what is actually deployed, name an environment with
105
+ `serve . --env prod`, below.
99
106
 
100
- **Not yet** (this demo is a preview of what's coming): any real cloud, and the
101
- git/PR actions (Rollback, Sync, Adopt) — `--emulator` is Loom-on-Floci only. To
102
- look at your own real infra, `preview`/`export` without `--emulator`, or `serve`
103
- with your own `--env` and creds.
107
+ <sub>`preview`/`export` also take an `--emulator` flag, a v0.1.0-era turnkey path
108
+ for one specific demo project. `behold demo` and `serve --local` supersede it for
109
+ every purpose; it is kept for compatibility and is not documented further.</sub>
104
110
 
105
- ## Export & host — a shareable, interactive snapshot
111
+ ## Export and host: a shareable, interactive snapshot
106
112
 
107
113
  `behold export` freezes whatever estate you're looking at into a **self-contained
108
- static folder** that any static host can serve — a read-only but fully
109
- interactive snapshot. Pan/zoom, the zoom dial (components → logical → composites →
110
- resources → attributes), radial layout, the inspect pane, and the env/tier
111
- pickers all work client-side; there's no live observe or deploy.
114
+ static folder** that any static host can serve. Read-only, and fully
115
+ interactive. Pan/zoom, the zoom dial (components, logical, composites,
116
+ resources, attributes, plus the ops track and the apply order where the project
117
+ has them), radial layout, the inspect pane, and the env/tier pickers all work
118
+ client-side; there's no live observe or deploy.
112
119
 
113
120
  ```sh
114
121
  npm run dev -- export --out ./behold-export # defaults to cwd, like preview
115
122
  # or someone else's project, with its live overlay:
116
123
  # npm run dev -- export <project> --env <name> --out ./behold-export
117
- # or the turnkey Loom-on-Floci demo:
118
- # npm run dev -- export ../loomster --emulator --out ./behold-export
119
124
  npx serve ./behold-export # → open it, no backend running
120
125
  ```
121
126
 
122
127
  Like `preview`, `export` defaults to the current directory and stays plain (no
123
128
  env, no emulator) unless you ask. `--env <name>` turns on that project's live
124
- overlay for the snapshot; `--emulator` injects the same turnkey Loom-on-Floci env
125
- as `preview --emulator`, for exporting that demo.
129
+ overlay for the snapshot.
126
130
 
127
131
  It captures every read endpoint for the whole lens matrix (each env/tier × zoom ×
128
- radial) in-process — the exact same handlers the live server runs, so a snapshot
129
- is byte-identical to live. (The live app can't run on a Worker — it needs Docker +
130
- Floci + a chant subprocess — but the pre-baked export can.)
132
+ radial) in-process, through the exact same handlers the live server runs, so a
133
+ snapshot is byte-identical to live. The live app can't run on a Worker, since it
134
+ needs Docker and a chant subprocess; the pre-baked export can.
131
135
 
132
- The bundle is **deploy-ready for Cloudflare** — `behold export` writes an
133
- assets-only `wrangler.jsonc` (no server code, pure static), so:
136
+ The bundle is **deploy-ready for Cloudflare**, since `behold export` writes an
137
+ assets-only `wrangler.jsonc` (no server code, pure static):
134
138
 
135
139
  ```sh
136
140
  cd ./behold-export && npx wrangler deploy # → https://<name>.<account>.workers.dev
@@ -138,18 +142,17 @@ cd ./behold-export && npx wrangler deploy # → https://<name>.<account>.wor
138
142
 
139
143
  Set the Worker name with `--name`, or edit `wrangler.jsonc`. Auth via
140
144
  `wrangler login` or `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` (same as any
141
- Workers deploy). Any other static host works too — GitHub Pages, S3, nginx, or
142
- Cloudflare Pages (`wrangler pages deploy .`).
145
+ Workers deploy). GitHub Pages, S3, nginx and Cloudflare Pages
146
+ (`wrangler pages deploy .`) all work too.
143
147
 
144
- ## Try it — your first apply, no cloud account
148
+ ## Try it: your first apply, no cloud account
145
149
 
146
150
  The bundled `example-writes` is one S3 bucket. `serve --local` boots *that
147
151
  project's own* local emulator (Floci, via Docker, generically through
148
- `chant emulator up` — chant #920), points behold's live overlay at it, and gives
149
- you a **▶ Deploy (floci-apply)** button in the header that deploys to it — no AWS
150
- account, no creds, no cost. This is `serve`'s generic mechanism, separate from `preview`/`export`'s
151
- `--emulator` flag above, which is a Loom-specific turnkey demo path — see
152
- `behold --help` for how the two relate:
152
+ `chant emulator up`, chant #920), points behold's live overlay at it, and gives
153
+ you a **▶ Deploy (floci-apply)** button on the panel's **Deploy** tab that
154
+ deploys to it, with no AWS account and no creds. It works for any
155
+ emulator-backed lexicon rather than one demo project.
153
156
 
154
157
  ```sh
155
158
  npm install
@@ -159,30 +162,31 @@ npm run demo # installs example-writes' deps, then serves it with --local
159
162
 
160
163
  <sub>(or by hand: `npm install --prefix example-writes && npm run dev -- serve example-writes --local --env prod`)</sub>
161
164
 
162
- 1. The graph shows the bucket + its TLS policy — **blue** (declared, not yet deployed).
163
- 2. Click **▶ Deploy (floci-apply)** in the header (or ⌘K → "Deploy: Sync"). The
165
+ 1. The graph shows the bucket and its TLS policy in **blue** (declared, not yet deployed).
166
+ 2. Click **▶ Deploy (floci-apply)** on the panel's **Deploy** tab (or ⌘K → "Deploy: Sync"). The
164
167
  now-line streams Build → Apply → Verify; the bucket is created in the emulator
165
168
  via the CloudFormation API.
166
- 3. The nodes flip **green (managed)** — behold's overlay observes the live emulator.
169
+ 3. The nodes flip **green (managed)**: behold's overlay observed the live emulator.
167
170
 
168
- No Docker running? behold still serves the source graph and tells you to start it —
169
- it never dies on you.
171
+ No Docker running? behold still serves the source graph and tells you to start
172
+ it, rather than dying on you.
170
173
 
171
174
  **Real AWS.** The same project's **▶ Deploy** button starts its `ApplyOp` against a
172
175
  real account: `npm run dev -- serve example-writes --env prod` (needs AWS
173
- credentials). What the header offers depends on what the project committed: a
176
+ credentials). What the Deploy tab offers depends on what the project committed: a
174
177
  committed `ApplyOp` gets the **▶ Deploy (<op>)** button (plus **Approve** when
175
178
  gated); a project with only components gets **▶ Deploy…**, which opens the dial's
176
179
  component picker; **Adopt** appears per foreign node (`ReconcileOp`); every other
177
180
  Op runs from ⌘K (**Run: \<name\>**). Full walkthrough:
178
181
  **[example-writes/README.md](example-writes/README.md)**.
179
182
 
180
- ## The k3d demo — the Kubernetes counterpart to Loom-on-Floci
183
+ ## The k3d demo: `behold demo k8s`
181
184
 
182
- `npm run demo:k8s` is the k8s analogue of the Floci demo above: it brings up a
185
+ `behold demo k8s` (or `npm run demo:k8s` from a checkout) is the k8s analogue
186
+ of the Floci demo above: it brings up a
183
187
  local, single-node [k3d](https://k3d.io) cluster (Docker only, no cloud
184
- account), then serves the bundled `example-k8s` — an nginx Deployment +
185
- Service — with `--local`. Same mechanism, same shape: declared-not-deployed
188
+ account), then serves the bundled `example-k8s` (an nginx Deployment and
189
+ Service) with `--local`. Same mechanism, same shape: declared-not-deployed
186
190
  (blue) → click **Run** on `k3d-apply` → managed (green), server-side applied
187
191
  with chant's own field manager. Ctrl-C tears the cluster back down.
188
192
 
@@ -203,48 +207,32 @@ including the exact commands and what each state looks like over the API:
203
207
  - The vizes only read (`chant graph`, snapshots, Temporal history).
204
208
  - Actions don't mutate directly. Sync starts your `ApplyOp`; Adopt starts your
205
209
  `ReconcileOp` (opens a PR a human merges). behold *triggers* Ops you committed,
206
- running on your executor — it holds no apply creds.
210
+ running on your executor. It holds no apply creds.
207
211
  - Two write gestures, both human-confirmed: **Apply** (gate signal) and **Open PR**
208
212
  (merge). Authority stays in your source and your worker, never in behold.
209
- - The first product is **read-only, full stop**: the mixed graph + drift + source
210
- deep-links. Writes are a later, opt-in layer.
211
213
 
212
214
  ## Why a Node service (not an edge function)
213
215
 
214
216
  The live path (`chant graph --live --overlay`, `chant lifecycle plan`) shells
215
217
  `kubectl`/`aws`/`az`/the Temporal client and holds cloud creds. That needs a real
216
- process, so behold is a Node service you run where your creds live — like
217
- `argocd-server`, not like a hosted SaaS. Read-only means it only needs **read**
218
+ process, so behold is a Node service you run where your creds live. Read-only means it only needs **read**
218
219
  roles (describe/list), so it's least-privilege to run.
219
220
 
220
221
  ## Agent-drivable, on chant's MCP
221
222
 
222
223
  behold is drivable by an agent, and leans on chant's MCP rather than reinventing it:
223
224
 
224
- - **Reads** — `lifecycle-diff`, `lifecycle-snapshot`, plus behold's own read API
225
+ - **Reads.** `lifecycle-diff`, `lifecycle-snapshot`, plus behold's own read API
225
226
  (the overlay graph as JSON, blast radius, frame diffs).
226
- - **Delegated actions** — the writes *are* chant MCP Op tools: `op-run` starts an
227
+ - **Delegated actions.** The writes *are* chant MCP Op tools: `op-run` starts an
227
228
  `ApplyOp`/`ReconcileOp`, `op-signal` approves a gate, `op-status`/`op-report`
228
229
  watch it. So an agent "syncing prod" is `op-run prod-apply` then
229
- `op-signal prod-apply approve-apply` — gated, durable, no creds in behold.
230
+ `op-signal prod-apply approve-apply`: gated, durable, no creds in behold.
230
231
 
231
232
  behold's value over raw MCP is the live spatial + temporal view and the coupling
232
233
  between them; the underlying capabilities are chant's, exposed the same way to a
233
234
  human and an agent. See [AGENTS.md](./AGENTS.md).
234
235
 
235
- ## Status
236
-
237
- Bootstrap. `behold serve <project>` renders the **source** mixed-substrate graph
238
- (cross-lexicon edges are real today — verified in chant core) in a browser, with
239
- click-to-inspect and source deep-links.
240
-
241
- Both read paths work: `/api/graph` (the source mixed-substrate graph) and
242
- `/api/overlay` (the source-anchored **live drift** graph — declared topology kept,
243
- nodes coloured managed/foreign/pending; needs chant ≥ 0.18.1 and cloud creds).
244
-
245
- Not yet: the deployment-lanes timeline and the delegated actions. See the concept
246
- notes and the issue set below.
247
-
248
236
  ## Usage
249
237
 
250
238
  ```sh
@@ -259,22 +247,72 @@ Then open http://localhost:4600. With `--env`, the SPA shows the live overlay;
259
247
  without it, the source graph.
260
248
 
261
249
  **Live updates.** The server watches the served project's source and pushes a
262
- refresh over SSE (`/api/events`) when a `.ts` file changes — edit your infra, the
263
- graph updates, no reload. Add `--poll <secs>` (with `--env`) to also re-query live
250
+ refresh over SSE (`/api/events`) when a `.ts` file changes, so you edit your
251
+ infra and the graph updates with no reload. Add `--poll <secs>` (with `--env`) to also re-query live
264
252
  drift on an interval and push updates when a node's status changes:
265
253
 
266
254
  ```sh
267
- behold serve ./infra --env prod --poll 30 # watch source + poll drift every 30s
255
+ behold serve ./infra --env prod --poll 30 # wait 30s between completed drift sweeps
268
256
  ```
269
257
 
258
+ **Read budget.** HTTP observations, background polling, and frame captures share
259
+ one Chant subprocess budget: two reads at once by default (one on a one-CPU
260
+ host). `BEHOLD_ESTATE_CONCURRENCY` overrides that process-wide limit. Up to 64
261
+ distinct reads can wait; excess reads fail explicitly rather than growing an
262
+ unbounded queue. Identical in-flight reads share work only when the project,
263
+ resolved Chant, source stamp, argv, and effective environment match. Source
264
+ watcher invalidation also separates new requests from work begun before an edit.
265
+
266
+ **What is cached, and what drops it.** A member's source IR is cached under its
267
+ source stamp, resolved chant and graph options (`src/member-ir.ts`), because
268
+ ~800ms of every `chant` invocation is module load that has nothing to do with the
269
+ member's content. A member's completed live overlay document is cached too
270
+ (`src/overlay-ir.ts`), so that a re-render such as collapsing a box is a
271
+ re-render rather than a second pass over the account. That second cache is the
272
+ stated exception to "a live read is never cached", and its header names
273
+ everything that drops an entry: `POST /api/refresh` ("Re-check live"),
274
+ `GET /api/overlay?plan=1` ("Re-check live with plan"), the capture that ends an
275
+ Op run, and the member's source moving. What it cannot notice is a change made to
276
+ the account by something that is not behold, which is what the re-check rows are
277
+ for.
278
+
279
+ A running read has a 180-second deadline, configurable with
280
+ `BEHOLD_READ_TIMEOUT_MS`. A disconnected GET releases its subscription; when no
281
+ callers remain, the read is canceled. On Unix, cancellation stops the whole
282
+ npm/tsx/Node process group, escalating from TERM to KILL after one second. On
283
+ Windows only the direct child is terminated. Delegated writes retain their
284
+ existing lifecycle and are never deduplicated as reads.
285
+
286
+ The browser runs one refresh at a time and collapses intervening notifications
287
+ into one follow-up. It no longer starts additional reads at 3/8/15-second offsets.
288
+ Polling uses the same estate namespace bindings as the HTTP overlay and reuses
289
+ the primary member's observation for lanes capture. Slow sweeps extend the poll
290
+ period; they do not overlap the next sweep. These bounds control duplicate work;
291
+ they do not eliminate Chant's underlying live discovery cost.
292
+
293
+ **What a read cost.** Every scheduled chant read is filed with its running time,
294
+ its outcome and which side of the source/live split it sits on.
295
+ `GET /api/doctor` serves the same report `behold doctor` prints with that ledger
296
+ on it; `GET /api/doctor?reads=1` is the cheap half: counters, the per-member
297
+ samples, `inFlight` (running and queued), and `unmeasured`, the count of served
298
+ members the ledger is blind to because they spawn their own binary rather than
299
+ chant. The loading scrim shows the slowest recent member, since an estate read
300
+ finishes when its last member does.
301
+
302
+ **The picture can arrive in pieces.** `GET /api/overlay?progressive=1` answers at
303
+ once with the estate composed from each member's source, every card marked
304
+ pending, then streams one SSE frame per member as its own live read lands.
305
+ Opt-in and never the default: the blocking `/api/overlay` is what agents read and
306
+ what `behold export` captures, and it is unchanged.
307
+
270
308
  behold shells the **project's own** chant (resolved from the project's
271
- `node_modules` first), so the project decides the chant version — pin it to
272
- `@intentius/chant ^0.18.1` or later for the live overlay (`graph --live` observed
273
- nothing before that fix).
309
+ `node_modules` first), so the project decides the chant version. Pin it to
310
+ `@intentius/chant ^0.18.1` or later for the live overlay; `graph --live` observed
311
+ nothing before that fix.
274
312
 
275
313
  ## Terraform carve-out: `behold carve <report.json>`
276
314
 
277
- `chant carve advise` ranks a Terraform estate by **peelability** — how cleanly
315
+ `chant carve advise` ranks a Terraform estate by **peelability**: how cleanly
278
316
  each resource could be carved into native chant source. `behold carve` draws
279
317
  that ranking: one card per resource, three panels (carve now / boundary work /
280
318
  leave in Terraform) on the same `attrs._status` drift palette every other view
@@ -288,9 +326,13 @@ curl localhost:4600/api/carve # the raw report, for agents
288
326
 
289
327
  behold parses no HCL and needs no Terraform tooling: the report **is** the
290
328
  contract. A file that isn't a peelability report is refused with a structured
291
- `{error, code: "carve-report", remedy}` — in the terminal, and from the routes —
292
- never a blank graph. See `docs/using/carve` and issue #230 for the roadmap
293
- (the post-emit diff, then Terraform as an estate member).
329
+ `{error, code: "carve-report", remedy}`, in the terminal and from the routes,
330
+ never a blank graph. See [the carve lens](https://intentius.io/behold/using/carve/) and
331
+ [the Terraform estate page](https://intentius.io/behold/using/terraform/) for the
332
+ other half of that lane: Terraform is a member
333
+ kind now (`MEMBER_KINDS = ["chant", "choudoufu", "terraform"]`), so an estate of
334
+ `.tf` files draws beside a chant project, and carve state is read from chant's
335
+ own `<address>.carve.json` manifests.
294
336
 
295
337
  ### The walkthrough: `behold demo carve`
296
338
 
@@ -298,41 +340,44 @@ never a blank graph. See `docs/using/carve` and issue #230 for the roadmap
298
340
  npx @intentius/behold demo carve # no Docker, no cloud, no terraform binary
299
341
  ```
300
342
 
301
- Copies a half-migrated estate — a small chant project beside a Terraform one,
302
- both describing the same AWS account — installs the chant it will shell, runs
343
+ Copies a half-migrated estate (a small chant project beside a Terraform one,
344
+ both describing the same AWS account), installs the chant it will shell, runs
303
345
  `chant carve advise` over the copy, and opens the banded graph with a six-step
304
346
  stepper on the panel's **Carve** tab:
305
347
 
306
- 1. **Advise** — the bands, with what each one means.
307
- 2. **Pick** — click a green card. The inspect pane shows the score arithmetic;
348
+ 1. **Advise.** The bands, with what each one means.
349
+ 2. **Pick.** Click a green card. The inspect pane shows the score arithmetic;
308
350
  the step names the boundary the cut crosses.
309
- 3. **Emit** — runs `chant carve emit --state --select <addr>` into
351
+ 3. **Emit.** Runs `chant carve emit --state --select <addr>` into
310
352
  `app/carveout/` in the copy, then shows the emitted chant source and the
311
353
  `chant lint` result.
312
- 4. **Bridge** — runs `chant carve bridge` (never `--apply-rewrites`) and renders
354
+ 4. **Bridge.** Runs `chant carve bridge` (never `--apply-rewrites`) and renders
313
355
  the proposed data source, the rewired survivors and the patch.
314
- 5. **Handoff** — the runbook's commands with copy buttons, and **not** a button:
356
+ 5. **Handoff.** The runbook's commands with copy buttons, and **not** a button:
315
357
  `terraform state rm` and `terraform apply` change who owns a live resource,
316
358
  so they stay yours to run. The panel says so.
317
- 6. **Done** — the card is marked chant-owned at the observe position;
359
+ 6. **Done.** The card is marked chant-owned at the observe position;
318
360
  `terraform import` reverses all of it.
319
361
 
320
362
  The Emit step reports `chant lint`, not `chant build`: `build` fails on the
321
- emitted bucket (scored 84 by the advisor) on one rule — WAW042, a TLS-deny
322
- bucket policy the source Terraform never declared — and the panel links the
323
- reason. `example-carve/README.md` has the estate's full story, the band table,
363
+ emitted bucket (scored 84 by the advisor) on one rule: WAW042, a TLS-deny
364
+ bucket policy the source Terraform never declared. The panel links the
365
+ reason. [example-carve/README.md](example-carve/README.md) has the estate's full story, the band table,
324
366
  and the offline/`--live` split.
325
367
 
326
- behold writes only into the demo copy it made — `app/carveout/`, and nothing
327
- else. Your Terraform is never edited; see AGENTS.md, "Invariant".
368
+ behold writes only into `app/carveout/` inside the demo copy it made, and
369
+ nothing else. Your Terraform is never edited; see AGENTS.md, "Invariant".
328
370
 
329
- ## Configuration — `.behold.json`
371
+ ## Configuration: `.behold.json`
330
372
 
331
373
  An optional `.behold.json` in the served project's root is **behold's own**
332
- config — kept separate from `chant.config.ts` so behold's concerns (like the
333
- tier picker) don't leak into chant's. Today it declares one thing: the
374
+ config, kept separate from `chant.config.ts` so behold's concerns (like the
375
+ tier picker) don't leak into chant's. Three keys: `tiers` below, `executor`
376
+ (which forge deploys an environment), and `members`, spelled `[{ "dir": "x", "kind":
377
+ "chant" | "choudoufu" | "terraform" }]`, how an estate root names what it
378
+ composes, fail-closed on a kind behold has no reader for. First, the
334
379
  project's deploy-**tier** axis, a dimension orthogonal to `environment` (chant
335
- has no native tier concept — it's entirely a project convention, e.g. Loom's
380
+ has no native tier concept; it is entirely a project convention, e.g. Loom's
336
381
  components branching on an env-conditioned `namingParams.tier`):
337
382
 
338
383
  ```json
@@ -344,17 +389,17 @@ components branching on an env-conditioned `namingParams.tier`):
344
389
  }
345
390
  ```
346
391
 
347
- - `envVar` — the env var name the project's source branches on; behold sets it
392
+ - `envVar` is the env var name the project's source branches on; behold sets it
348
393
  for the chant shell-out whenever a tier is picked (`?tier=` → this var, never
349
394
  a chant CLI flag).
350
- - `values` — the tier picker's options.
395
+ - `values` is the tier picker's options.
351
396
 
352
397
  **No `.behold.json` (or no `tiers` key) → no tier axis:** the picker doesn't
353
- render and the graph loads with no tier selected — the default for any project
354
- that doesn't opt in. There's no other tier config surface (not
398
+ render and the graph loads with no tier selected, which is the default for any
399
+ project that doesn't opt in. There's no other tier config surface (not
355
400
  `chant.config.ts`, not an env var behold guesses the name of).
356
401
 
357
- ### Deploy executors — `executor`
402
+ ### Deploy executors: `executor`
358
403
 
359
404
  The same file can designate which forge deploys an environment (#165):
360
405
 
@@ -372,7 +417,7 @@ tell them apart. For a designated environment the Deploy button dispatches that
372
417
  workflow through your own `gh` login and follows the run on the dial; a local
373
418
  apply, a committed ApplyOp for that environment, and auto-sync are all refused
374
419
  (`409 executor-forge`), and rollback is withheld. Any approval the workflow's
375
- GitHub environment requires is granted on GitHub, by your identity there — the
420
+ GitHub environment requires is granted on GitHub by your identity there. The
376
421
  dial links the run's page and offers nothing else, because behold holds no
377
422
  identity that could clear a forge gate. A designation behold cannot honour (a
378
423
  typo'd forge, a missing workflow, one without `workflow_dispatch`) disables
@@ -381,15 +426,15 @@ it on your machine. The workflow is read from the `.github/workflows` of the
381
426
  repository the project belongs to, so a project that is one directory of a
382
427
  monorepo works as-is.
383
428
 
384
- A dispatched run's id is kept under `~/.behold/ci-runs/` — the operator's own
385
- state, outside the project — so a behold restarted mid-deploy re-adopts the run
429
+ A dispatched run's id is kept under `~/.behold/ci-runs/`, the operator's own
430
+ state outside the project, so a behold restarted mid-deploy re-adopts the run
386
431
  and keeps following it. A follow whose stream dies is reported as `lost`, never
387
432
  as a verdict; `just e2e-ci-github` proves the whole contract against GitHub.
388
433
 
389
- ### The hand-layout sidecar — `.behold/layout.json`
434
+ ### The hand-layout sidecar: `.behold/layout.json`
390
435
 
391
436
  dagre places your nodes; you can move them. Drag a card, resize a containment
392
- box, and the offsets are remembered per project + lens — in `localStorage`
437
+ box, and the offsets are remembered per project and lens: in `localStorage`
393
438
  first, and (when the served project is writable) in a `.behold/layout.json`
394
439
  sidecar beside it, so a layout is shareable, reviewable in a diff, and honoured
395
440
  by `behold export`:
@@ -399,14 +444,14 @@ by `behold export`:
399
444
  ```
400
445
 
401
446
  This is the **only** file behold writes inside a served project. It stores
402
- deltas, never absolute positions — the graph stays chant's and your layout sits
403
- on top of it — and a delta whose node has left the estate is dropped silently.
447
+ deltas rather than absolute positions, so the graph stays chant's and your
448
+ layout sits on top of it. A delta whose node has left the estate is dropped.
404
449
  `POST /api/layout` refuses politely in preview mode, during a static-export
405
450
  capture, on a read-only directory, and above its size caps. `↺ layout` in the
406
451
  graph clears the current lens on both tiers.
407
452
 
408
453
  **Gitignore it.** `.behold.json` (above) is config and belongs in the repo;
409
- `.behold/` is per-user state — one person's arrangement of the picture — so add
454
+ `.behold/` is per-user state, one person's arrangement of the picture, so add
410
455
  it to the served project's `.gitignore` unless you actually want to share and
411
456
  review a layout:
412
457
 
@@ -416,30 +461,46 @@ review a layout:
416
461
 
417
462
  ## Layout
418
463
 
464
+ A shape rather than a manifest. `src/` is ~85 modules with one concern each, and
465
+ [AGENTS.md](./AGENTS.md) is the map that stays current.
466
+
419
467
  ```
420
468
  src/
421
- cli.ts serve verb + arg parsing
422
- server.ts Hono read-only API (/api/graph, /api/overlay) + static SPA
423
- chant.ts shell-out to the chant bin (graph IR, live/overlay) — reads, never mutates
424
- render.ts pinhole painter (layoutIr + renderSvg) — IR → SVG
425
- overlay.ts _status → drift semantics (managed/foreign/pending)
426
- web/
427
- index.html SPA shell
428
- app.js inlines pinhole's SVG + click-inspect by data-node-id
429
- example/ a tiny AWS chant project for local dev + e2e
430
- e2e/run.sh end-to-end runner (install example chant → serve → assert the API)
469
+ cli.ts the verbs: serve, preview, demo, doctor, export, carve
470
+ server.ts the Hono API + static SPA, every route read and delegated
471
+
472
+ the read path
473
+ chant.ts shells the PROJECT's own chant; every read is scheduled
474
+ read-scheduler.ts one process-wide budget, in-flight sharing, cancellation
475
+ read-stats.ts what each read cost (/api/doctor)
476
+ member-ir.ts per-member source cache; overlay-ir.ts, the live one
477
+ estate.ts N members composed into one graph (pinhole's composeStacks)
478
+ member-kind.ts what a member can be: chant, choudoufu, terraform
479
+
480
+ the lenses (pure IR -> IR, no subprocess)
481
+ logical*.ts one topology projection per substrate
482
+ ops-lens.ts declared Ops as a phase track; run-playhead.ts paints a run
483
+ carve-lens.ts a peelability report; stack-order.ts, chant's apply order
484
+ terraform-lens.ts collapse-lens.ts, edgeless.ts
485
+
486
+ render.ts pinhole painter, IR -> SVG, one pack per lexicon
487
+ web/ the SPA, unbundled ES modules (app.js + ~20 siblings)
488
+ docs/ the published site (Astro/Starlight)
489
+ demos.json the shipped demo catalog; workbench.json, this checkout's
490
+ example-*/ nine bundled projects the demos and e2e serve
491
+ e2e/ twelve acceptance runs, one per substrate lane
431
492
  ```
432
493
 
433
494
  ## The painter
434
495
 
435
496
  behold reuses [pinhole](https://github.com/INTENTIUS/pinhole)'s SVG painter as a
436
- library — a mature renderer (themes, icons, `_status` drift colouring that already
437
- speaks the overlay vocabulary managed/foreign/pending). The server lays the IR out
497
+ library: a mature renderer with themes, icons, and `_status` drift colouring
498
+ that already speaks the overlay vocabulary managed/foreign/pending. The server lays the IR out
438
499
  and paints it with `layoutIr` + `renderSvg` (`src/render.ts`); the SPA inlines the
439
500
  SVG and wires click-inspect by `data-node-id` against the IR. pinhole's layout is
440
- dagre — pure JS, no native dependency.
501
+ dagre, which is pure JS with no native dependency.
441
502
 
442
- Where an official mark exists, a node paints it instead of a generic glyph — a
503
+ Where an official mark exists, a node paints it instead of a generic glyph. A
443
504
  Deployment gets the Kubernetes wheel-and-helm heptagon, a Kustomization the
444
505
  Flux mark, a `Helm::Release` the Helm wheel. The corpus is vendored under
445
506
  `web/icons/` (30 kubernetes/community SVGs, 3 cncf/artwork marks for
@@ -449,8 +510,8 @@ keyword heuristic rather than a wrong picture.
449
510
 
450
511
  dagre's layout is a good first draft, not a final one: drag a card to move
451
512
  it, grab a containment box's corner to resize it, and both survive a reload.
452
- What persists is a delta — `{dx,dy}` for a card, `{dw,dh}` for a box, never
453
- an absolute position — keyed by `behold.layout.<project>.<lens>`
513
+ What persists is a delta (`{dx,dy}` for a card, `{dw,dh}` for a box) rather
514
+ than an absolute position, keyed by `behold.layout.<project>.<lens>`
454
515
  (`web/layout-store.js`), so the graph stays chant's and the arrangement on
455
516
  top of it is yours. `↺ layout` sits beside `⤢ fit` and shows up only once
456
517
  something on the current lens is hand-placed.
@@ -465,11 +526,11 @@ the inspect pane's declared attributes, observed live state, drift pairs and
465
526
  field ownership, the op log's JSON lines, and the payload behind an `/api`
466
527
  error card.
467
528
 
468
- Type splits by purpose: mono (system stacks — ui-monospace, SF Mono,
469
- Cascadia, JetBrains, IBM Plex) carries node ids, ARNs, statuses and counts;
470
- sans carries labels only. Colour comes from 552 Ghostty terminal palettes run
471
- through an OKLCH-derived token pipeline (`src/theme.ts`), so a theme switch
472
- re-derives the whole chrome, not just the graph — and a node whose drift
529
+ Type splits by purpose: a system mono stack carries node ids, ARNs and
530
+ statuses, and sans carries labels only. Colour comes from 552 Ghostty terminal palettes run
531
+ through an OKLCH-derived token pipeline (`web/theme.js`, the palettes in
532
+ `web/themes.js`), so a theme switch
533
+ re-derives the whole chrome rather than only the graph. A node whose drift
473
534
  status just changed pulses once in the colour it became, off under
474
535
  `prefers-reduced-motion`.
475
536
 
@@ -489,8 +550,8 @@ One server, one SPA: passing an env turns on the live overlay (`/api/overlay`),
489
550
  omitting it shows the source graph (`/api/graph`). `serve` runs via `tsx` (no build
490
551
  step); for the built binary, `just build` then `./bin/behold.js serve <project>`.
491
552
 
492
- **Which chant runs.** behold does not bundle chant — it *shells* the chant binary
493
- resolved from the served project's `node_modules` (falling back to behold's own dep).
553
+ **Which chant runs.** behold does not bundle chant. It *shells* the chant binary
554
+ resolved from the served project's `node_modules`, falling back to its own dep.
494
555
  So local testing means installing chant into a project, not into behold. The bundled
495
556
  `example/` does exactly that; point `serve` at any real chant project the same way.
496
557
 
@@ -505,9 +566,9 @@ behold, serves the example, and asserts the read-only API against a live server.
505
566
  auto-detects AWS credentials:
506
567
 
507
568
  - **no creds** → asserts `/api/graph` (the source mixed-substrate graph, offline).
508
- - **AWS creds** → asserts `/api/overlay` (the source-anchored live overlay — queries
509
- CloudFormation and checks every node carries a drift status; all `pending` when
510
- nothing is deployed is a valid pass, since the point is the live path).
569
+ - **AWS creds** → asserts `/api/overlay`, the source-anchored live overlay: it queries
570
+ CloudFormation and checks every node carries a drift status. All `pending` when
571
+ nothing is deployed is a valid pass, since the point is the live path.
511
572
 
512
573
  It's hermetic apart from the chant install and (optionally) the cloud read; the
513
574
  server is torn down on exit. `BEHOLD_E2E_PORT` overrides the port.
@@ -515,16 +576,19 @@ server is torn down on exit. `BEHOLD_E2E_PORT` overrides the port.
515
576
  ## Unit tests
516
577
 
517
578
  ```sh
518
- npm test # vitest — pure units (graphFlags, _status mapping); no server, no cloud
579
+ npm test # vitest, 106 files, including in-process route tests against
580
+ # the real Hono app. No cloud, no cluster.
519
581
  ```
520
582
 
521
- ## Related issues
583
+ ## Upstream
584
+
585
+ behold is a viewer over a compiler, so some of what it cannot do is not its to
586
+ fix. The open asks:
587
+
588
+ - chant **#822**, diff two historical snapshots, which is what a timeline of
589
+ more than the frames behold captures itself would need.
590
+ - chant **#2481**, `chant` costs ~800ms per invocation before it does any work,
591
+ and behold shells it once per member per read.
592
+ - pinhole **#79/#80/#81**, morph-over-time and first-class drift rendering.
522
593
 
523
- - chant **#821** — source-anchored overlay (the linchpin: cross-substrate topology + live
524
- status) — done, shipped chant 0.18.31; behold adopted it in M4 (see `src/overlay.ts`).
525
- - chant **#822** — diff two historical snapshots (feeds the timeline).
526
- - chant **#513** — compose separate stacks into one IR — done; behold's `composeEstate`
527
- (`src/estate.ts`, #31) consumes pinhole's `composeStacks` built on it.
528
- - pinhole **#82** — ship the painter as a library (done; behold consumes it).
529
- - pinhole **#79/#80/#81** — drive `--live`/`--overlay`, first-class drift rendering, morph-over-time.
530
- - Concept notes: `~/Documents/research/chant-live-control-plane.md`.
594
+ behold's own work is on the [issue tracker](https://github.com/INTENTIUS/behold/issues).