behold is a read-only control plane over a chant estate, with delegated, gated writes. As an agent you drive it the same way a human does, and the mutating capabilities are chant's MCP Op tools, not behold's, so nothing here holds apply creds.
- behold serves the live, mixed-substrate graph (and, later, the deployment-lanes timeline). It reads; it never mutates.
- chant's MCP is where the real capabilities live. Prefer it over shelling.
- Reads:
lifecycle-diff,lifecycle-snapshot,build,lint. - Actions (delegated writes):
op-run(start anApplyOp/ReconcileOp),op-signal(approve a gate),op-status/op-report(watch it).
- Reads:
npx @intentius/behold serve <chant-project-dir> --port 4600 # or: preview / demo / doctorbehold demo needs no project at all — it copies the bundled example and serves
it against a local emulator (Docker). A directory that is not a chant project
gets a structured {code: "no-project"} error from /api/graph, not a blank
graph.
In a checkout, behold demo --list prints a second block under the bundled
one: the eleven workbench entries from workbench.json (this repo's own
catalog of the internal estates behold is developed against), the same
behold demo <entry> load, just example name="<entry>" to serve one, and
"The workbench catalog" below for what each is and what it boots.
A running server offers the same catalog over HTTP (#268): GET /api/demos
lists every bundled demo with {name, description, requires, satisfiable, reason?, fetches, repo?, target, loaded} — satisfiable is doctor's PATH probe
for that demo's requires, and fetches marks the one kind of entry that
reaches the network (a git demo is cloned). POST /api/demos/open with
{name} loads it and switches the served project to it: the same copy/clone →
install → setup the CLI runs, then an in-place switch. It takes a catalog
name and never a path, so it cannot be aimed outside the install; it is
preview-locked, and one load runs at a time (409 otherwise).
- discover. GET
/apilists every route with a one-line description, plus the server's version and a link back to this guide. Before the server exists (or when a route answers with an error you'd have to guess at), runnpx @intentius/behold doctor <dir> --json: a read-only diagnosis of the project's kind, its own chant install and version, declared lexicons, the envs the picker will infer, the bound kube context versus the ambient one, substrate readiness and committed Ops. Each check is{name, status: pass|warn|fail, detail, fix}; the process exits non-zero iff something failed. It starts no server and changes nothing. - observe. GET
/api/graph(JSON:{ ir, svg, meta }). The mixed graph of the project, every node withid/kind/lexicon/attrs/sourceLoc. Drift status, when present, isattrs._status(good=managed,warn=foreign,accent=pending,neutral=unobserved,runtime=runtime child). With?env=,/api/overlayis the live entity overlay;/api/diff?env=slices per-node observed state, drift and field ownership;/api/reconcile?env=summarizes the pending change set;/api/substratesreports substrate readiness;/api/events(SSE) pusheschanged/op/apply/run/pr. With?components=1&env=, each component node carries_liveStatus(thechant components statusverdict) and, when the release ledger recorded one,_release—{runId, forge, originSource, url?, gitSha, digest, timestamp, actor, approver?}(#165).urlis present only when the record itself carries an address (chant#2045);originSource: "inferred"means behold read the id's spelling and nothing more, and aforge: "unknown"id is never resolved to a link on behold's guess.?stacks=1is the apply-order lens (#426): the cross-stack ordering chant computes at synthesis and prints fromchant graph --stacks --json, which behold read nowhere and re-derived a subset of. One card per stack (a lexicon partition), one box per wave, edges consumer to producer;meta.stackscarriesorder,wavesandcyclesverbatim. It is source-only (no env, no credentials, no substrate) and asks chant for the project ROOT, notgraphPath, becauserunStackGraphonly seesops/*.op.tsfrom there (measured:example-writesanswers[aws, temporal]from.and[aws]fromsrc). Single project only: an estate is refused with422 stacks-estate, sincecomposeStackspools members into one bucket per bare lexicon name and two members'awsstacks are not one stack. A cycle is NOT a refusal — chant exits 1 with valid JSON and the entangled stacks absent fromorder/waves, so they are drawn unwaved in their own box and the note says so; nothing invents a wave number for a stack chant could not order./api/project'sstacksCapablegates the stop. Note thatchant graph --stacks --jsonis malformed today on two of chant's own examples (an edge with nofrom, anullinorderand in a wave);sanitizeStackGraphtreatsnodesas the roster and drops anything named nowhere else.?ops=1is the ops lens: the project's declared Ops as a phase track, read from each emitteddist/ops/<name>/op.json, with the current run painted over it (meta.run,meta.gate);/api/ops/<name>/statusreads that Op's durable run status and pending gate (chant run status --temporal). AConvergeOpalso gets one card per rule from itsconvergeTickstep'sargs.rules(attrs._step: "rule"), carryingwhen(chant's JSON predicate, rendered as the condition it states),then, and thewhychant requires of every rule, verbatim.then: run(<op>)is an edge to that Op's first step when the Op is in the rendered set, andattrs.danglingwhen it isn't.meta.operatoron that same lens names the ConvergeOps the project declares (chant's ownsearchAttributes.Converge === "true", read from op.json, no subprocess), and/api/operator/statusfills the strip in fromchant operator status --json. - focus. Narrow with chant graph options as query params:
?detail=0..3,?components=1,?logical=1,?lens=blast:<id>&down=1,?lens=lexicon:aws,?env=,?stack=,?tier=,?target=,?collapse=1.?collapse=1(#393) shuts every member box holding more than 40 cards: the box is drawn as ONE card carrying the count it was badged with (301 resources · 84 bound · 85 unowned · 132 neutral, in the estate's own words), ids namespacedbox:<member>, and the returned IR is the collapsed one so every count and note speaks about the picture on screen — except its NOTE, which is written about the expanded estate plus one collapse clause (#396): a shut box hides what the estate references rather than changing it. ⌘K → "Collapse large boxes" / "Expand all". Below the limit it changes nothing. The toggle costs no live read (#396): a member's overlay document is cached under its source stamp and the read's own options, the same key halfmemberIrand #404's plan cache use, so collapse and expand are re-renders of a document behold already holds.src/overlay-ir.tsis that cache and its header is the stated exception to src/member-ir.ts's "a live read is never cached"; it names everything that drops an entry —POST /api/refresh("Re-check live"),GET /api/overlay?plan=1("Re-check live with plan", which re-checks both halves), the capture that ends an Op run, and the member's source moving. A change made to the account by something that is not behold is what the re-check rows are for. - inspect. A node's
sourceLoc.fileis the typed source that declared it; edit there to change the estate (chant is the source of truth, not behold). - find. ⌘K also takes an ADDRESS (#393). Two characters in, the palette
matches the ids and addresses of the graph the page is currently showing and
offers up to twelve
node: <address>rows, prefix matches first, each with its member and kind on a second line (two members of one estate can declare the same address). Enter takes the same path a click on the card does (the inspect pane and the highlight) and pans the graph onto it:revealNodein web/app.js drives the same viewBox the wheel, the drag and "⤢ fit" drive. Nothing here fetches, so it works in a static export too.
GET /api/overlay?progressive=1 answers at once with the estate composed from
every member's SOURCE, each node carrying attrs._pendingRead, and then
broadcasts one member SSE frame per member as its own live read settles —
{member, statuses: {<composed id>: <status>}, unobserved?}, or {error} if
the pass itself failed. meta.mode is progressive and meta.pending names
the members not yet read.
Opt-in, and never the default. /api/overlay without the flag is byte-for-
byte what it was: the blocking, complete live answer this guide's read loop
names, which is what an agent GETs and what src/export.ts captures into a
static bundle. A route that answered with members it had not read would hand an
agent a half-picture it could not know was half, and freeze a bundle with
members permanently still-reading.
Pending is a third claim. _unobserved is "behold looked and could not
see"; neutral is "the plan did not mention it"; _pendingRead is "the read
has not finished". It rides its own attr rather than a fifth _status because
a fifth _status is not paintable — pinhole's painter takes a closed set and
falls back to neutral for anything else, which is unobserved, the exact
wrong answer. The SPA draws it dashed and dimmed, never filled: the fill is the
status channel and a pending card has no status yet.
No live result is cached to build the first picture and none is reused. A pending member becomes a real one only when its own read completes, and the live pass a progressive request starts is the same one per member under the same budget: a progressive load costs the estate no extra spawn.
GET /api/doctor serves diagnose(), the same report behold doctor prints,
which until #421 only the CLI could ask for — with two extra blocks on it:
reads, the ledger fromsrc/read-stats.ts. Every scheduled chant read passes throughReadScheduler, so each one is filed there with its running time, its outcome, and which side of the source/live split it sits on. A barechant graphis the member's own TypeScript evaluation; anything with--live/--overlay, and every other verbisScheduledReadallows, reaches past it.sharedcounts subscribers handed an in-flight spawn, work not done, never a run.cache,memberIrCacheStats().
GET /api/doctor?reads=1 is the same two blocks WITHOUT the diagnosis, and it
is what the SPA asks for. The split is the one #421 states: the full report is
for a person diagnosing and the panel line is for a person waiting. It is also a
cost split too: diagnose() probes docker, gh, helm and k3d through
src/substrates.ts, whose probe has no timeout and no kill, and the SPA asks
after every load settles. A static export asks for neither; refreshReadCost
returns on staticMode rather than relying on the bundle's own 404.
reads.inFlight is {running, queued} off the scheduler itself. The ledger
cannot answer it: a read still in flight has finished nothing, so it has filed
nothing. "What is queued now" is what someone watching a slow read wants.
Three things to know before reading a number off it. queuedMs is contention
in the scheduler (the HTTP-versus-poll-versus-capture competition #367
bounded) rather than the whole of what a member waits: the estate fan-out bounds
upstream through mapPool (src/estate.ts), so a three-member estate measured
3033/3035/2218 ms of running time with queuedMs 0 against 5.2 s of wall clock.
The gap is the upstream wait and #422 is where it gets measured.
reads.unmeasured counts the served members the ledger is BLIND to, and the
panel line prints it rather than quietly excluding them. Only reads that pass
ReadScheduler are filed, which is every chant read and therefore a terraform
member too (its via.read shells chant graph, src/terraform-member.ts). A
choudoufu member spawns its own binary through its own helper and is filed
nowhere, so on a chant+choudoufu estate the slowest card may be one that never
reached recent — "slowest 3s · 1 not measured" is the honest form of that, and
an unqualified "slowest 3s" is not.
And preview mode keeps the totals but drops recent, whose samples are keyed by
member directory.
The SPA asks once after each load settles and shows the answer on the loading
scrim the next time one goes up (web/read-cost.js, #loading-cost): the
slowest recent member, because an estate read finishes when its last member
does, so a sum would report a number nobody waits for.
What the estate costs and where it runs out of headroom, on /api/overlay
only. behold never calls a behavioural engine, never holds its key, and
produces no figure of its own — it renders what a chant lexicon handed it, the
way it already renders drift and the carve score. The one arithmetic it does is
addition, and the result is named sum so nobody can read it as a bill.
The contract's source of truth is the comment titled "The behaviour block,
proposed" on #398 (chant#2356 is the writing half). Field names
here match it exactly; src/behaviour.ts is the implementation and the
validator.
Per entity, attrs._behaviour on the overlay IR node — the channel _status,
_release and _carve already ride: {at: {traffic}, cost: {perHour, currency}, headroom: {cpu?, latency?}, errorRate, resilience: {failure, verdict: survives|degrades|fails, note?}, rightSize?: {suggestion, reason?}, provenance: {engine, version, tolerance, basis: modeled|validated}}. An entity nothing
priced carries no _behaviour key at all, never a zeroed block, and a
missing headroom axis is absent rather than 0. The block stays on the node and
there is deliberately no top-level behaviour: {} map: the SPA already walks
ir.nodes reading attrs._status, and a colour-by-cost mode is that same walk,
with no join.
Two sources, in this order:
attrs._behaviouron the nodes, aschant graph --live --overlaypainted them, withmeta._behaviourbeside them for the graph-level half.behaviour.<env>.jsonat a member's root —{meta, entities: {<address>: block}}— read only when no node of that member carries the attr, and only on the overlay (/api/graphis the source graph; a prediction about a live account has no business on it). The file's keys are the member's own addresses; behold prefixes them to reach the composed id<member>/<address>.
meta.behaviour carries the graph-level half: engine, version, at, the
engine's own total when it stated one, else behold's sum and the same per
box under boxes[<boxKey>] ({perHour, currency, priced, unpriced}). Mixed
currencies produce no sum and a diagnostic — behold converts no currency. A
malformed block is dropped whole with its reason in diagnostics, never
half-rendered. A member with neither source gets {absent: "…"} naming both
places behold looked; that is an absence, not a refusal, because nothing was
configured.
meta.behaviour.refusal is {reason, remedy} in the lexicon's own words,
printed as it came, and it is present instead of everything above: a
refusal emits no entity block at all and strips any that had arrived. The drift
overlay is untouched either way: nothing in this pass reads or writes
_status. ?logical=1 projects a different picture and carries no behaviour
block in M1.
The SPA colours the graph by one of three modes. The mode is client-side
state — web/app.js's colourMode, persisted under behold.colourBy the way
the theme is. It is deliberately NOT in LENS_PARAMS/canonicalKey: a lens
param means "a different snapshot comes back", and a mode change changes no
fetch at all. The block is already on every priced card, so a switch is a
repaint of the SVG on screen.
drift keeps the categorical fill and #393's vocabulary legend, unchanged. The
two behaviour modes replace the card's FILL only, so the drift bar underneath
keeps saying what chant observed. cost is a sequential scale over
cost.perHour between the estate's own min and max; headroom is absolute over
0..1 and reads the lower of the axes present. An entity with no block draws
the drift overlay's neutral (pinTokensFor().neutralFill) and is marked
data-unpriced, never the zero end of the scale — "nothing priced this" and
"this is free" are opposite claims.
Both ramps are derived from the active palette (rampFor in
web/behaviour-scale.js), the way #229 derives the chrome: cost is a single hue
off the accent walked in lightness away from the background — expensive is not a
verdict behold gets to paint, and headroom rides the three status hues the
drift overlay already anchors, degraded → foreign → managed.
One legend per mode, in the Model tab. The Scope tab carries a totals row per
box and per estate for the active mode: cost quotes meta.behaviour.boxes[key]
and .sum, or meta.behaviour.total when the ENGINE stated one (labelled
"engine total"); headroom has no server-side aggregate, since no engine states one —
so the SPA takes the scope's min and median itself and the row says "computed".
Every row carries n priced · m unpriced. In drift mode there is no row.
Everything either mode decides is pure in web/behaviour-scale.js, with
web/behaviour-scale.test.js beside it; the SPA owns the fetches and the SVG.
Every figure the SPA shows, the totals rows, the legend, and the inspect pane's
behaviour section — carries {engine} {version} · {tolerance} · {basis}
beside it, never once per page. modeled is spelled out on hover as "modeled,
not billed": #397's one prohibition is a prediction presented as a bill, and the
badge is where that is prevented. Provenance is per ENTITY in #398's contract, so
a badge over a set (a box, the estate) says mixed on any field the set
disagrees on rather than picking one.
meta.behaviour.refusal disables both behaviour modes in the View tab and in
⌘K (visibly, with the lexicon's reason and remedy as their tooltip) and prints
those words verbatim where the legend would be. The drift overlay keeps
rendering; it never depended on an engine. meta.behaviour.absent disables the
same two modes with the absent line as their tooltip and prints nothing:
nothing was configured, so nothing refused. meta.behaviour.diagnostics render
in the Model tab under the legend.
behold carve <report.json> serves a chant carve advise --json peelability
report instead of a chant project. Same SPA, same /api/graph shape; attrs. _status carries the band (good = carve now, warn = has boundary work,
neutral = leave in Terraform) and attrs carries the score arithmetic
(score, arithmetic, inbound, outbound, tier, mapsTo).
GET /api/carve returns the raw report verbatim.
To move a resource: confirm its band on /api/carve, then run chant's own
carve emit --state and carve bridge in the project. terraform state rm and
applying the generated survivor rewrites stay a human gate — behold has no
endpoint that writes Terraform, and adding one would break the invariant below.
chant ≥ 0.52.2 writes <address>.carve.json into carve emit --output; carve bridge and carve apply add their own records to the same file. behold reads
those and never writes one, so the progression is on disk rather than in a
session.
GET /api/project's carve.state publishes it: {manifests, progress: {applied, bridged, emitted, inFlight, total, label, detail}, states[], apply: {human, note}}. Each entry carries target, stage
(emitted/bridged/applied), graduated, note and a retypeable
applyCommand. The same field appears on an ordinary behold serve whose
project directory carries a carveout (no report and no demo needed) and is
absent entirely when nothing has been carved.
In the graph, an applied address draws inside the chant member box (keeping
its Terraform address as its node id) instead of in a band; emitted and
bridged stay banded with attrs._status: "accent" and the stage in
attrs.carve. Only applied means ownership moved.
There is no /api/carve/apply. chant carve apply graduates ownership;
behold renders what the manifest records and echoes the command. Do not add the
endpoint — src/carve-manifest.ts APPLY_IS_HUMAN is the statement of it, and
src/carve-actions.ts and src/server.ts both carry the refusal where the route
would go.
behold demo carve copies a bundled half-migrated estate (a chant project
beside a Terraform one), runs the advisor over the copy, and serves the same
carve view plus a six-step stepper on the panel's Carve tab: advise → pick →
emit → bridge → handoff → done.
Two of those steps are POST routes, and they exist only in a demo copy:
POST /api/carve/emit— body{select}; runschant carve emit --state --select <addr> --output <copy>/app/carveoutand thenchant linton the result. Answers{select, command, output, artifacts[], boundary, lint, buildCaveat}.POST /api/carve/bridge— body{select}; runschant carve bridgewithout--apply-rewrites. Answers{select, command, output, runbook, proposals[]}.
GET /api/project's carve.demo says whether they can act (runnable, plus a
reason when not). select must name a resource the served report ranks;
anything else is a 400. Outside a demo copy both routes answer 403
{code: "read-only"}, and on an ordinary project serve they don't exist at all.
The gate the Emit step reports is chant lint, not chant build — chant#1637
means build fails on the emitted bucket. Don't read a lint pass as a build
pass.
There is still no endpoint that runs terraform. The handoff step hands
back the runbook's commands as text.
behold does not apply. To change the estate:
- Edit the chant
.tssource (the node'ssourceLoc), or - Trigger a committed Op via chant's MCP:
op-run <name>— start the project'sApplyOp(code→cloud) orReconcileOp(cloud→code PR).op-signal <name> <gate>— approve a gate (e.g. a destructive apply).op-status <name>— watch phases;op-report <name>, the run report.
Every mutation is a gated, durable Temporal workflow with a human-confirmable gate and saga rollback. There is no behold endpoint that mutates the cloud.
A run behold triggered is asked for structured per-step records (chant#1676:
--progress-json on the durable path, --json on the local one), so the ops
lens paints the run over the declared track and a pending gate renders as a card
with an Approve button. That button is op-signal, the same delegated write —
and nothing else about the run is behold's to decide: a stream that dies leaves
the playhead at the last settled step and says so, and a gate paints as pending
only when chant's gateState query named it.
.behold.json may designate which forge deploys an environment:
{ "executor": { "prod": { "forge": "github", "workflow": "deploy-prod.yml" } } }It designates the committed WORKFLOW, not just the forge: two environments'
generated pipelines carry identical job ids, so a picker cannot tell them
apart, and a contract that names prod must not sit on a guess. For a
designated env, POST /api/apply and a committed ApplyOp's /api/ops/:name/run
answer 409 {code: "executor-forge"}, auto-sync declines out loud, and the
only deploy is POST /api/ci/dispatch?env=<env>, which runs the designated
workflow through the operator's own gh and follows it on the dial. Any
approval the workflow's environment requires is granted on the forge by a
GitHub identity behold does not have; the dial links the run's page and offers
nothing else. Fail-closed: a designation behold cannot honour (a typo'd forge,
a missing or non-dispatchable workflow, a forge with no trigger here) disables
Deploy for that env with the reason on /api/project's executor block, and
never falls back to running it here. /api/project reports {forge, workflow, ok, reason?} per designated env.
Without a designation, dispatch picks the committed workflow named for the env
(chant-components-<env>, chant ≥ 0.54.0); on the older job-overlap match it
refuses a tie rather than letting directory order choose, and a workflow named
for another env never stands in. just e2e-ci-github proves the contract
against the real forge (example-ci + .github/workflows/behold-e2e-dispatch.yml),
including the lost verdict, which BEHOLD_CI_FOLLOW_TIMEOUT_MS and
BEHOLD_CI_POLL_FAIL_BUDGET let a run force on purpose; a lost run is the one
POST /api/ci/readopt re-follows. The same e2e dispatches into an environment
with a required reviewer (behold-e2e-gated): the run holds at waiting, the
progress state carries waiting: true and the run's url, the now-line says
the approval is granted on the forge, and a cancellation lands as failed.
A dispatched run is followed honestly (#165 §6, PR #350): only GitHub's own
completed + conclusion paints ok/failed; a poll-failure budget or the
follow deadline promotes the run to lost (chips frozen at last-observed,
the run possibly still live at its own page), never to a verdict. The adopted
run id is persisted as one JSON per project under ~/.behold/ci-runs/, the
operator's machine-state, outside the project tree, so the Invariant below
still names the whole in-project write surface. GET /api/ci/run reads the
record; POST /api/ci/readopt (also attempted once at boot) re-follows an
unconcluded run by its saved id, so a restart mid-deploy leaves the run a
reader instead of nothing.
A project that declares a ConvergeOp gets an operator strip on the ops lens:
per loop, the last tick's own log line (verbatim, with its instant), the lease,
and how many gates are pending. One tick — chant operator status keeps only
records.at(-1), so the strip is never a timeline, and it says so.
The timeline is its own read: GET /api/operator/log → chant operator log --json (chant#2029), the tick records and the gate resolutions against them
merged oldest-first. It lives in a panel that grows from the strip, and two rules
hold it there. It is pulled, on the click that opens it and never on a timer.
And it is bounded — --limit on every invocation, 50 by default and 200 at
most, --since when asked, because the ledger grows one line per tick forever;
the answer reports the window it used, so a full one can't read as the end of
history. The route checks the project's chant version before it spawns: below
0.53.1, chant operator log resolves to chant operator, the tick daemon, and
behold reading a history must never become behold running an operator.
Since chant 0.53.1 (chant#2027) a tick record carries an id and the
per-component verdicts it derived, and both ride through to
[].lastTick.{id,components}. The strip line names the tick (truncated), and
the verdicts join onto the component DAG by component name, the same key the
live chant components status read is joined by. They join under that read:
a tick only ever feeds the last tier of componentStatusColor (the
reconciliation verdict), only on a node the live read left unpainted, and only
while the tick is younger than fifteen of chant's own operator rounds. Past
that it is named on the node, dated, and painted nothing: a graph fill has
nowhere to put "as of an hour ago". A chant older than 0.53.1 sends neither
field and every one of these paths is a no-op.
Two gate cards exist and they are different acts:
- The run gate —
POST /api/ops/:name/signal/:gate→chant run signal. Releases the waiting Temporal workflow. - The converge gate —
POST /api/operator/approve/:op/:gate→chant approve <op> <gate>. Records a fact and unblocks nothing. chant's gate ledger says it outright: the local executor still refuses a gated op, resolution or not. The next tick reads the fact. The card says this in its body, and the toast after the click says "recorded; the next tick acts on it".
A gate fact carries its address since chant#2028, on both halves, and the card
links it under chant's own words — approve at: <url>. The field is optional by
design (a local tick with no PR behind it has none) and never synthesized, so a
fact without one renders no link and no placeholder for one, and a url behold
would not put behind an href reads as no address at all.
A gate someone already resolved simply leaves pendingGates, the status read
carries no resolvedBy/when, so the strip shows no resolved-by line rather
than attributing an approval to nobody. The timeline is where resolutions are
named, because there they are records rather than an inference.
An OperatorStack (chant#1940) declares the loop as a k8s estate, so it already
appears in the entity graph. behold names it there, the Namespace as the loop's
home, each CronJob as a converge tick — off chant's own
app.kubernetes.io/component: converge-tick labels, never a naming convention.
If a request would have behold write to a cloud or to source directly, it's wrong. behold shows truth and triggers Ops. Authority stays in the committed source and the executor.
POST /api/layout (#228) writes one file in the served project:
.behold/layout.json, the hand-layout sidecar, {version, lenses: {<lens>: {<node id>: {dx,dy,dw,dh}}}}. That is the whole of behold's write surface
inside a project, and it does not weaken the invariant above:
- It is workspace metadata, not estate truth. Deltas describe how you want the picture arranged on top of dagre's layout; the graph underneath stays chant's, and a delta for a node that left the estate is dropped on read.
- It never touches the cloud and never touches your source. No
.ts, nochant.config.ts, no.behold.json. The path iscfg.projectDir+ two constants; nothing from the request reaches the filesystem. - It refuses politely when it shouldn't write: preview mode, a static-export capture, a read-only project directory, an oversized or malformed body.
- It is per-user state, unlike
.behold.json(config, meant to be tracked). Projects should gitignore.behold/.
GET /api/layout reads it back; GET /api/graph?layout=1 (and /api/overlay)
render with the deltas baked into the SVG, which is how behold export and
static snapshots honour a hand layout.
The second exception is the carve walkthrough's two steps above (#254), and it
is narrower still: they exist only when behold booted a behold demo carve
copy, they write only into <copy>/app/carveout/, and the directory they write
into is a scratch dir behold created inside a directory it copied for you a
minute earlier. carve bridge runs without --apply-rewrites, so the demo's
own Terraform is not edited either. The only request-derived value is select,
and it must be an address the served report already ranks, the value that
reaches the spawn's argv comes from a closed set read off disk. No cloud write,
no Terraform mutation, no edit to anyone's chant source.
A choudoufu estate member (#366) adds no exception. A move there is one tag
write through choudoufu's own live-mv, and behold never makes it: GET /api/choudoufu/moves reads a plan (carve.json) from inside a served member,
previews each move with choudoufu's live-mv -json -dry-run (the only spelling
of live-mv in the tree, src/choudoufu-moves.ts dryRunArgs, asserted by
test), hands the lines back with copy buttons, and ?receipt=1 reads the
listing after a person ran them. There is no /api/choudoufu/mv, and
src/choudoufu-route.test.ts asserts it stays absent, for the same reason
/api/carve/apply does not exist.
The sections above are about driving a running behold. This one is about changing it, the working rules that used to live in a handoff note and now live here, with the enforceable ones enforced.
- Branches and merges. Work on a branch off
main(an isolated worktree when more than one agent is active).mainis protected by a ruleset: every change arrives as a PR, and thecheckjob inci.ymlmust be green before it merges. A check that has merely settled is not a pass — read the lines. Nogh pr merge --auto; merge one PR at a time, in order, and rebase the next onto what landed. - The local gate is
just check(tsc, tests, build). Every vitest run also writes.vitest-last.json(the json reporter, PR #351), andjust testkeeps the reporter's full output invitest.log, both gitignored — because #334's one-shot first-run failures kept losing the failing file's name. If the suite fails once and passes on rerun, those two files from the first run are the evidence; attach them to #334 (jq '.testResults[] | select(.status != "passed") | .name' .vitest-last.jsonnames the file). - Releasing is
just release, after the version-bump PR merged: it tagsbehold-v<version>, pushes the tag (which is what runsrelease.yml), and waits for npm to show the version. It refuses in every state where a tag push would be wrong. Never re-push abehold-v*tag by hand: a tag push re-fires publish. Tags absent from origin (behold-v0.10.0, 0.10.1) stay absent for that reason. - Scratch infrastructure is governed by
src/scratch.ts, asserted byscratch.test.tsacross every boot site: anything behold boots is namedbehold-*, refuses if the name is taken, tears down only what it created, and never binds the shared emulator ports. The protected names —floci*,chant-floci*, the kubemicrovm and fountain k3d clusters, :4566 — are listed there, not in prose. A new boot site callsassertScratchbefore its spawn and gets a row in the test. - The write boundary is the Invariant section above. Apply is never a
button;
/api/carve/applydoes not exist and the route test asserts it stays that way. - chant's working rules (its single-lane checkout, its release path) are chant's and live in chant's repo, not here.
An estate member has a kind (#368): what a directory has to look like, what
tool answers reads for it, and how it becomes a GraphIR. src/member-kind.ts
is the one table; chant is the first row and every member was that row
until the table existed. A new kind is:
-
A
registerMemberKind({ kind, probe, expects, via, passes })call insrc/member-kind.ts, theprobeis sync, read-only and runs no code (a file's presence, a regex over a root file, or either: the choudoufu probe takes theestate.chdf.hclsidecar, choudoufu's leading form, OR alive {block in a root *.tf, #387);via.toolstamps the binary and version that would answer, which is the version half ofmemberIr's cache key;via.readis the one uncached read, source or live peropts. The module imports nothing from the read path at runtime (src/chant.ts imports src/project.ts, which imports it); a kind'sviacloses over its own tool module. Add the word toMEMBER_KINDS. A word in the vocabulary that no row registers is a doctor fail with the reason, never silently chant. -
Nothing in
src/estate.ts:composeEstate,composeEstateOverlayandestateNamespaceScopesalready dispatch throughmemberKindOf(dir). The two routes that render an estate —/api/graph's estate branch and/api/overlay's — run the same passes in the same order and must not fork per kind; a kind's differences live inside itsreador itspasses.passes(#427) is the render-time half, and it exists because the read-time one could not hold it: the Terraform kind's third pass takes the request's zoom, so it cannot run where the reader runs.applyMemberPasses(ir, {detail})is the one call the six render sites make, andmemberPassNote(run, dirs)the line they return, each kind handed only the served dirs that are its own, so a route never names a kind. Three rules apassesholds. It mutates IN PLACE, because the existing passes do and their callers depend on it. It self-guards on the IR's own CONTENT and never on the served members' kinds. A chant project may declare a kind's lexicon; a chant project declaringterraformis whatsrc/terraform-route.test.ts's first block serves, andmemberKindOfsayschantthere, so membership dispatch would silently stop rendering it. And a run is carried forward, never re-run: a pass that already elided its cards returns an empty elision the second time, which drops the note on every composed estate. A test that re-registers a kind to stub its reader spreads the real spec ({ ...terraformSpec, via: … }) or it drops the passes with it. -
A
registerPack({ lexicon, iconFor, fields })insrc/render.ts, or the kind's cards lead with the alphabetically first two short attrs. Afieldsfunction takes the box the card is drawn in as its second argument, so a row the box already carries can be left off (#393 item 9,src/card-face.ts). -
A row in
statusVocabulary(src/status-vocabulary.ts) when the kind has its own words for the four overlay colours — choudoufu'sboundis not chant'smanaged, and a legend that saysmanagedover a card carrying no marker is a claim behold has no right to make. The colours never move; only the naming does, and an estate of several kinds keeps chant's words and says so in the legend's tooltip. -
A
DoctorCheckline when the kind needs a tool on PATH or a per-member precondition (a version floor checked before the spawn, the waycarveStatusReaderdoes; a PATH probe the waysrc/demos.tsdoes). -
Tests: the probe and the object form in
src/project.test.ts; dispatch insrc/estate.test.ts's "#368" block, which registers a fake kind and asserts chant members still go through exactly the calls they did, and its "#427" block for the render passes; the kind's own reader off recorded documents insrc/__fixtures__/, with provenance in prose above the load.
.behold.json names a kind as { "dir": "x", "kind": "<kind>" }; a bare
string is chant. Anything behold boots for a kind goes through
assertScratch first.
What the first screen owes a non-chant member (#393). Three chant-shaped things used to be offered to every member whatever it was, and a new kind gets all three answered for free:
/api/projectpublishesmemberKinds, the served members' kinds, in composition order. The SPA opens on theresourceszoom when none of them ischant, becausecomponentsis a projection of a chant project's own component DAG and a member without one opens on "the components lens doesn't apply to a composed estate yet". #182's zero-node fallback still covers a chant project that declares no components./api/projectpublishesruntimeCapable, and the palette and the View tab offerzoom: runtimeonly then. The tier below the declaration boundary is the owner-referenced children chant stampsruntimeOwneron, and only a Kubernetes read has an owner chain to resolve —RUNTIME_LEXICONSinsrc/server.tsis the one word (k8s), read off the members' DECLARED lexicons so the stop exists or not before anyone picks it.- A chant-only facet answers emptily rather than 500ing for a non-chant
primary:
/api/resourcesreturns{byComponent: {}}, the shape carve mode already returns and the SPA already reads as "no resource facet here".
A kind that grows components, or a substrate with an owner chain, changes those answers where they are decided rather than per kind in a route.
demos.json is the catalog that ships. workbench.json, read beside it and
deliberately absent from package.json's files, is the catalog of internal
estates this checkout is developed against (#386, #388). Four rules, and
src/demos.ts holds them:
- A third source,
local, and a second file. A local entry'spathis relative to the directory of the catalog file that named it, the intentius checkouts are siblings, so the workbench writes../choudoufu,../waterpark,../chant, and the file stays committed and reproducible. An entry whose path is not checked out is unsatisfiable exactly as a missing binary is:--listand/api/demossay so, CI stays clean.BEHOLD_WORKBENCH=<file>names a catalog elsewhere; a workbench name that collides with a bundled one is dropped with a stderr line. - In place or copied, said explicitly.
inPlace: trueservespathwhere it sits; the default copies tobehold-demos/<name>with the bundled filter. Anything whose setup writes into the tree —init,apply, a rendered generator — is copied or generated into the target, never run in a checkout, which is how #366's "behold never runschoudoufu initin a served project" survives. AninPlaceentry with asetupsays so in its description. - A generator is a source. An entry with no
pathat all renders its estate into an empty target through its ownsetup, which runs with cwd = the target and two extra variables:BEHOLD_WORKBENCH_DIR(the catalog file's directory, so a script can reach../choudoufu) andBEHOLD_DEMO_NAME. The up script writes the matching down script into the target, the way the bundled choudoufu demo shipsscripts/choudoufu-down.sh. CHOUDOUFU_BIN.choudoufuBinary()(src/choudoufu-member.ts) names the binary and every spawn, the doctor probe and the demo requirement check go through it; the doctor line prints which binary answered. Workbench scripts spell the same fallback,${CHOUDOUFU_BIN:-choudoufu}.
just example name="<entry>" serves one. Scratch discipline is unchanged: an
emulator a workbench entry boots is behold-wb-<entry> on its own port, and
the up scripts (workbench/<entry>/up.sh, helpers in workbench/lib/) assert
the behold-wb- prefix and the not-4566 rule themselves, in bash, because they
are the boot site. Each writes the matching scripts/down.sh into its target.
The eleven entries, in catalog order. The counts are measured, not estimated — every entry the e2e below can reach on a developer machine prints its own graph and overlay counts as it runs.
| entry | what it serves | needs |
|---|---|---|
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 |
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 |
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 |
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 |
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 |
choudoufu-cohort-iam-ecr |
the same for iam-ecr, the one cohort floci implements end to end: 6 cards, all 6 bound |
the same |
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 |
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 |
terralith-4 |
the same at scale 4: 301 cards, all 301 bound. The estate behold is sized against | the same |
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 |
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 |
The scratch, by name and port. Each emulator is the entry's own, booted by
its up script and removed by the scripts/down.sh that script wrote into the
target rather than by pattern.
| entry | container | host port |
|---|---|---|
choudoufu-workbench |
behold-wb-choudoufu-workbench |
4651 |
choudoufu-cohort-s3 |
behold-wb-choudoufu-cohort-s3 |
4652 |
choudoufu-cohort-iam-ecr |
behold-wb-choudoufu-cohort-iam-ecr |
4653 |
choudoufu-cohort-ec2-networking |
behold-wb-choudoufu-cohort-ec2-networking |
4654 |
terralith-1 |
behold-wb-terralith-1 |
4655 |
terralith-4 |
behold-wb-terralith-4 |
4656 |
terralith-4-adopt |
behold-wb-terralith-4-adopt |
4657 |
chant-*, waterpark |
none (source reads, no substrate | ) |
fountain-ops |
the k3d cluster fountain-local, which is the checkout's, not behold's |
— |
The port is the floci's host binding and the entry's serve.spawnEnv
AWS_ENDPOINT_URL at once, so the estate's own applies and the choudoufu
behold spawns talk to the same emulator.
The e2e is just e2e-workbench (e2e/workbench-e2e.sh, #391): every entry
loaded through behold demo <entry> <tmp target> --port <p> on its own port
from 4720 up, /api/graph asserted to carry nodes and a live entry's
/api/overlay?env=live to answer with its bound/unowned/neutral split, timings
per entry; eight entries and 553s on the machine this was written on.
Time is asserted, not only printed (#423). Each entry carries a ceiling at
roughly twice its own measured value, and the run carries one too, because
#419's whole subject was a number nobody was watching: the ratio could not
regress silently and the duration could. The measured figures the ceilings come
from are in the script beside them; BEHOLD_E2E_SECS_<entry> and
BEHOLD_E2E_RUN_SECS override. A live entry also asserts #422's shape: the
progressive overlay answers with every member marked pending and does so before
the blocking read returns, so the estate cannot quietly regress to
all-or-nothing. Where the blocking read is already sub-second there is nothing
to beat, and the run reports the two numbers rather than claiming a pass.
terralith-4-adopt is asserted twice — 85 UNOWNED, then the
live-import line the up script printed, run by the script itself in the
target the way a person would, then 301 bound, because that write is the
person's, never behold's (#372). waterpark is checked for an untouched
checkout afterwards. missingRequirements decides what runs: a missing binary,
an unchecked-out sibling, an uninstalled chant example or the absent Terraform
lexicon each print skip: <entry>: needs …, so in CI every entry skips and the
run exits 0. fountain-ops is skipped by name everywhere, for the reason in
the table. A behold-wb-* container left standing at the end fails the run.
The seeded catalog (#389) found one thing missing in src/: a LONE choudoufu
estate (every generated entry is one) served the no-project card, because the
single-project read is chant graph <dir> and such a directory has no lexicon
for it to read. servesAsEstate (src/member-kind.ts) is the predicate that
routes one directory of a non-chant kind through the estate compose path, where
the member's own kind reads it. One member composes exactly as four do, ids
namespaced under the member's short name, so the graph, the pane and the morph
agree on what a node is called.
A Terraform estate reaches behold through chant.
@intentius/chant-lexicon-terraform reads the HCL an estate already has and
emits one entity per block, so a Terraform estate is a chant project whose
only lexicon is a reader and chant graph --format ir serves it like any
other. behold parses no HCL and ships no HCL parser, the same posture
src/carve-lens.ts states for the carve report (#378).
Three passes turn what arrives into a picture (src/terraform-lens.ts), in this
order, guarded on the IR carrying terraform entities so every other estate gets
the identical object back:
normalizeTerraformNodes. A node'skindarrives as the entity class (Terraform::Resource), not the resource type, so every card would be titled and iconed the same. The type moves out ofattrs.addressintokindand the block class lands inattrs.block. That is the shape a carve node already has, which is why one presentation pack serves both.groupTerraformByRoot. Roots are a Terraform project's only grouping. It retires itself when chant#2266 groups upstream. A node sits in exactly one box, so a box every node has left is dropped rather than drawn empty (#393): a served directory arrives composed,composeStacksboxes the whole member, and the root boxes then take those same nodes, which is what put an empty box namedaccesson water park's canvas. What the member box was there to say moves into the root box's title, which is<member>/<root>whenever the estate holds more than one member and the bare root name when it does not (two members with a root apiece namedprodwould otherwise merge silently).filterTerraformCards. What is a card, below.
What is a card (#382). Measured on a real estate: 247 nodes for 43 resources, four fifths of it not infrastructure.
| tier | blocks | why |
|---|---|---|
| default (detail 0-2) | resource, data, module |
the estate: what is declared, what it reads, what it composes |
| attributes (detail 3) | + output, variable |
its interface — real, but a second question |
| never | terraform, provider, locals |
settings, not estate |
Nothing is dropped silently: terraformElisionNote says what is not drawn and
where to see it, the way edgelessNote says why a view has no edges.
The note, at every zoom and in a 260px strip (#393). The roots note and the
elision note are built by one function in /api/graph and returned by all three
of its branches, the logical lens included — it was the lens that most needed
the line and the only one that dropped it. Both routes send note and, when
there is a shorter true form, noteShort (5 roots · 2 skipped · 189 blocks not drawn). The strip shows the short one with the long one on its tooltip, and the
panel's Model tab prints it whole. The server writes both: the SPA does not
author notes, and truncating this one on a sentence boundary would keep the list
of root names and drop the counts.
Do not invent edges. They arrive from chant or not at all: the fixtures here
were recorded when a Terraform IR carried none, and lexicon 0.61.0 (chant#2265,
which resolves a block's "${…}" references) draws 390 over water park's five
roots with no change on this side. The one relationship that looked derivable
without it (a cross-root read by name) was measured and refused (#381): both
ends carry the same unresolved interpolation, so a match would be a coincidence
of variable naming. A data source says what it reads as a row instead.
Serving a directory that declares nothing (#384). Every estate this lane
exists to draw is a directory of .tf files and nothing else, and #378 chose
not to ask one for a chant.config.ts of its own — INTENTIUS/waterpark#88 was
withdrawn because the estate is more useful untouched. So behold serve <terraform-dir> generates the reader config itself, outside the estate, and
points chant at it. The Invariant's one in-project write stays
.behold/layout.json; nothing is written under the served directory, and
src/terraform-member.test.ts asserts the estate's source stamp is unchanged
across a read. Three decisions, each a trade #384 left open and each measured on
water park's access/ before it was taken:
- The lexicon is opt-in. chant resolves the lexicon from the config file's
own location, so the generated project has to see it, and making it a
dependency would put
@cdktf/hcl2json, a ~1.8 MB wasm blob, in the install of every user who serves a chant project.@intentius/chant-lexicon-terraformand@cdktf/hcl2jsonare therefore optional peers: declared in package.json (the only place their versions are named, the refusal reads them from there), never installed by behold, probed at serve and doctor time, and refused with the one install line and where behold looked. The same gatebehold demoputs on a binary it does not ship. The lexicon's own chant peer is what moved behold's@intentius/chantfloor to^0.61.0: chant 0.54 loads no published version of it (applyLineage is not a function). - Roots are discovered, and the skips are reported. A root is a directory
with a
.tfdeclaring a line-startterraform {orprovider "block beside aresource,dataormoduleblock, a regex probe at the depth the choudoufu probe uses, no HCL parsed. #384 proposed the first half alone ("what a root has and a called module does not") and the estate refuted it: water park'smodules/personais a shared module called by three roots and itsversions.tfisbaseline's byte for byte. So two exclusions stand beside the probe, a directory under amodules/segment (Terraform's own standard module structure; the roots that call it draw its blocks already) and one with nothing to draw (access/backendsis two backend fragments) — and both are named in the graph's note with their reason, the wayterraformElisionNotenames what a zoom left out. Measured onaccess/: five roots (envs/prod,identity,github,baseline,satellites/waterpark-runner), two skipped,envs/devneither drawn nor reported because it holds only a README. - A
terraformmember kind, after chant and choudoufu. #378 said there is no such kind and meant it about reading: behold parses no HCL and the render goes through chant. A kind whosereadshellschant graphagainst a generated config is a scaffold, not a second reader, and it inherits the probe, the cache stamp, the doctor line and estate composition (#368) for free, so a Terraform root composes in an estate beside a chant project and a choudoufu estate at no extra cost.src/terraform-member.tsis the whole of it;detectProjectShapeanswers a directory that is its own member withmembersFrom: "probe", which is what retired #384'sno chant.config.ts heredead end.
The scratch project is <tmpdir>/behold-tf-<hash of the estate path>:
behold-* and cleared through assertScratch (src/scratch.ts), one directory
per estate reused across runs, asserted to be outside the estate before a byte
is written. It holds the generated chant.config.ts and two symlinks —
node_modules to behold's own, which is how the config resolves the lexicon,
and estate to the served directory, which is how each root's dir is spelled.
The second is not decoration: the lexicon sets a root's module boundary to the
root's own directory when its dir resolves outside the project root, so
absolute paths cost every ../modules/x call the estate makes — 72 nodes and 6
resources on water park, against 247 and 43 through the symlink. Nothing in the
answer mentions the scratch path (a terraform entity carries attrs.file
relative to its root, and no sourceLoc).
Read a green choudoufu card as "this is ours", never as "nothing changed".
The overlay is composed from live-ls -json and live-plan -json, and those
two documents answer one question — bound, unowned, omitted, or invisible to
the listing. Neither of them ever compares an attribute VALUE. A resource whose
tag, path or mutability was changed out of band still carries its markers, so
it is still bound, and it is still green.
The other half of the terminal's answer ("would a plan change anything?") is
a separate, opt-in read: choudoufu plan -input=false -out=<tmp> then
choudoufu show -json <tmp>, whose resource_changes[].change.{actions,before, after} name the attributes and both their values. (choudoufu plan -json is
NOT this: on 0.16.0 it prints choudoufu's own ownership document, the same
shape as live-plan -json, with no attribute values in it at all. The
measurement is in src/choudoufu-plan.ts's header.) The plan file is written to
a scratch directory of behold's own and removed after rather than inside the served
member, which would be a write into someone's source.
It is opt-in because it costs differently: the ownership read is answered out
of the tagging index and is flat in the estate's size, while plan refreshes
every resource, one provider read per card, 301 of them on terralith-4. So:
- Never on an ordinary overlay read.
GET /api/overlay?plan=1andGET /api/diff?plan=1are the only things that spawn one, and the palette's "Re-check live with plan (attribute drift)" row is the only thing in the SPA that sends the flag. The cheap "Re-check live (refresh drift)" row is deliberately left alone — it isPOST /api/refresh, a primary-only re-observe that captures a lanes frame and never composes the estate. - Cached under the member's source stamp, the same key half
memberIruses. A read that did NOT ask is served that stored answer, so a reload after a refresh keeps showing the drift without paying for a second pass; a read that DID ask always re-plans, because "re-check" means re-check. - The ownership verdict stays the card's colour. A bound card that drifted
is still painted bound and still counted
bound; it gainsattrs._planDriftand wears a dashed--degradededge plus a~ n attributescorner glyph (web/app.jsmarkDriftedCards, the same post-render stamp as the carve and operator marks). A card that is not bound is never marked: an unowned object's plannedcreateis an ownership fact the overlay already paints, andplanDriftdrops creates for that reason. /api/overlay's meta carriesdrift:{read: false}or{read: true, drifted: n}. The two are different answers — "nobody looked" versus "looked, nothing drifted", and the legend prints a count only for the second.
behold doctor's targets line. SUBSTRATE_TARGET_VARS (src/targets.ts)
names four lexicons against the nine behold draws, deliberately — it withholds
one until chant can actually be pointed at an emulator for it. That module also
names the cost: a landed chant change leaves a stale omission "until someone
notices", which happened once (#125) and left behold showing a floci-gcp pill it
could not aim a read at. This is the line someone reads instead.
NO_AMBIENT_TARGET (same module) is what stops it being noise: k8s and temporal
are not withheld, they are finished — they bind from chant.config rather than
an ambient variable, so there is nothing to override and nothing for a picker.
Those report as settled, not as a gap.
What the line CANNOT say, stated because the gap is the point: the honest
question is "chant can route this and behold does not list it", and behold
cannot ask it. Since chant 0.61 the endpoint variable is declared by the LEXICON
PLUGIN (endpointEnvVarsFor), not by a table in core, and behold installs no
lexicon plugins; the served project does. No chant CLI reports them either
(lifecycle whoami --json answers identity and says nothing about endpoints).
So the check warns on the weaker question it can answer and says it may be
over-reporting. The real fix is upstream, and #430 records the ask.
pinhole's painter takes a closed _status set and falls back to neutral for
anything else, undefined included. So on a mixed overlay a member with no live
half, a Terraform root, whose live/overlay/env are stripped in
src/terraform-member.ts — painted exactly like a card behold looked at and could
not see, while the legend counted only the latter. Measured on
serve example-writes example-terraform-estate --env prod: 37 Terraform cards
carrying no _status, 2 chant cards carrying neutral, one fill between them.
Those cards now carry data-no-live and the legend gives them their own row.
Named rather than folded into unobserved, because "I looked and could not see"
and "there is nothing here to look at" are different claims, the same argument
#399 makes for an unpriced card, and the same answer: mark it, do not merely
colour it. The mark is overlay-only; on the source graph no card has a status
and marking every one of them would say nothing.
This is presentation, not vocabulary. statusVocabulary gets no terraform row:
there is no status to name. See #429 for the measurement that established it.
A NEEDS_DISCOVERY omission paired with an adoptable[] row at the same
address paints warn with ownership: "foreign", the same verdict the UNOWNED
branch gives and for the same reason: the live object carries no marker for
this estate. On its own, NEEDS_DISCOVERY stays neutral — the declaration
names no identity, and with nothing found that is a "could not answer" rather
than an action.
The card leads with attrs.adopt, which is choudoufu's own adopt_command
here rather than the marker pair an unowned row carries. adoptLine's
composed-command branch had no real caller until this. behold composes no
command of its own: the API that writes a tag differs per resource type.
attrs.matchedOn is the difference in kind. An unowned row needs no evidence
because the identity IS the match; here choudoufu asserts that a live object is
a declaration because their identity-bearing arguments agree, and a person
about to stamp two tags onto somebody's resource is owed the list.
web/app.js renders both through the one copyable row, and its tooltip says
whether the line is a command or a pair of tags.
live-plan -json carries adoptable[] (live resources the estate-wide sweep
matched to a declared instance by content) and swept (the types it listed in
full). Both are empty on a run that did not ask the account-inventory question,
and behold does not ask by default.
The opt-in is TOFU_LIVE_COLLECT_UNCLAIMED=1 (ADOPTION_SWEEP_VAR,
src/choudoufu-live.ts), carried through serve.spawnEnv, the seam
setChoudoufuSpawnEnv already feeds every choudoufu spawn. The env var rather
than -adoption-only, because that flag suppresses the resource diff the
overlay needs. Off by default because the sweep is bounded by the ACCOUNT rather
than the estate, and behold polls. 0 means no, which is choudoufu's own
spelling, not a truthiness test.
Content matching is a closed per-type table, and that is what decides
whether a declaration can ever produce an adoptable[] row. matchTable
(choudoufu's internal/live/foreign/classify.go) lists eleven types against the
identity-bearing arguments a match may use — aws_vpc on cidr_block,
aws_security_group/aws_lb/aws_sns_topic and friends on name,
aws_subnet on cidr_block + availability_zone, aws_acm_certificate on
domain_name. A type absent from it, in the table's own words, "can never
produce a bind candidate". aws_iam_role is absent, which is why #412's first
attempt measured a shape that cannot match rather than a gap. A resource with a
count/for_each key is skipped too, so a fixture wants a scalar.
src/__fixtures__/choudoufu-live-plan-adoptable.json is the real thing,
recorded off floci: one scalar aws_vpc declaring a static cidr_block, its
unmarked live twin created out of band, and the run asked with the opt-in. It
carries the pairing M2 is about, a NEEDS_DISCOVERY omission and an
adoptable[] row at the same address — plus matched[] naming what was
compared and choudoufu's own adopt_command.
choudoufu live-check -json states the roster and references[], and that
second list is cross-estate by construction — data sources filtered on
another estate's marker tags. So a 301-resource terralith of roles, policies
and attachments drew no edge at all and the graph asserted "nothing in this
estate references anything else", which is false about every one of them.
The references were never missing; nothing was reading them. A choudoufu
member's read now also runs the terraform kind's reader over its own
directory, one root, the estate directory itself, through the same scratch
project machinery above (nothing written under the estate), and joins the two
documents by address. behold still parses no HCL: src/choudoufu-refs.ts is
two lists of strings and the rules that match them, and its header is the
argument for each. In short: the lexicon names blocks in a path of module
calls (estate/module.team_pod/aws_iam_role.pod_role) and choudoufu names
instances in a path of module instances
(module.team_pod["pod-a"].aws_iam_role.pod_role[0]), so one lexicon edge is a
product — kept inside one module instance (Terraform's scoping, not a guess),
joined key to key when both ends expand over the same keys (266 true edges on
terralith-4 against 666 of which ~400 would be false), landing on a module
call's whole interior, and dropped whole when either end is a var, a
locals or anything else the roster declares no card for.
Every edge is inferred and carries the lexicon's own attribute name (role,
policy_arn), so the card says what made the reference. Without the
lexicon there are no edges and the note says so — carrying the terraform
kind's own install line rather than the sentence behold has no reader to
assert, and the lexicon's version is in the member's cache stamp, so
installing it invalidates the edgeless IR rather than serving it forever.
The layout half is packBoxComponents (src/render.ts). src/edgeless.ts wraps a
box whose cards reference nothing; a box that has edges is never wrapped, so
terralith-4 came back as a 110996 x 628 strip the day the join landed —
dagre lays 42 connected components side by side on three ranks. The pass packs
a box's components into shelves and resizes the box around them, re-laying each
component on its own first (inside a cluster dagre interleaves them: a six-card
cluster's bounding box spanned 46598 units) and wrapping a component that is a
strip either way: the DNS fan is 19564 x 400 upright and 900 x 10046 on its
side. A box under 4:1 is left exactly as it laid out. Measured: terralith-4
7660 x 5162 with 266 edges, waterpark 3536 x 3138 at detail 2 and 7142 x 6974
at detail 3 (from 4.2:1 and 15.1:1, the case #393's wrap did not answer).