@intentius/behold 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -9,7 +9,8 @@ apply creds.
9
9
 
10
10
  - **behold** serves the live, mixed-substrate graph (and, later, the deployment-lanes
11
11
  timeline). It reads; every write it offers is a command it starts (an Op, a
12
- `chant run`, a forge dispatch), and on a terragucci repo it starts none.
12
+ `chant run`, a forge dispatch), and on a terragucci or SQL Yodeler repo it
13
+ starts none.
13
14
  - **chant's MCP** is where the real capabilities live. Prefer it over shelling.
14
15
  - Reads: `lifecycle-diff`, `lifecycle-snapshot`, `build`, `lint`.
15
16
  - Actions (delegated writes): `op-run` (start an `ApplyOp`/`ReconcileOp`),
@@ -88,6 +89,12 @@ and none is the checkout's git remote) adds two reads, in
88
89
  report of its wave's other entries (same gate and digest). No
89
90
  `audit.jsonl` is `present: false` plus an `absent` sentence, never an
90
91
  empty history; a bad line is the `422 terragucci-report` refusal naming it.
92
+ #523: an S3 403 on an optional read (`audit.jsonl` here, `run.json` for
93
+ `/api/terragucci/progress` and the estate's run views) is not a refusal:
94
+ the source throws `TerragucciDenied` (`src/terragucci-source.ts`), and the
95
+ answer carries `present: false`, `unreadable: true` and an `absent`
96
+ sentence naming the key and `s3:GetObject` (or `s3:ListBucket`). A 403 on
97
+ `index.json`, a report or `estate.json` still fails.
91
98
  `src/terragucci-timeline.ts` holds the read and the check (terragucci's
92
99
  `dist/audit.schema.json` when it resolves, structural otherwise). The SPA
93
100
  draws it in the Terragucci tab and, filtered to the card's root, in the
@@ -95,11 +102,79 @@ and none is the checkout's git remote) adds two reads, in
95
102
  snapshot and does not fail on it.
96
103
  - `GET /api/terragucci/lifecycle` (#505): the repo's `chant/lifecycle` lane,
97
104
  `{branch, found, via, origin?, fetch: {ok, error?}, commit?: {sha,
98
- committed}, read: {at}, gates[], locks[], note}`. `gates[]` is one entry per
99
- `wave-<k>` in `_gates/tf-apply.jsonl` and `_gates/tf-apply/applied.jsonl`:
100
- `state` (`waiting | expired | approved | applied | failed`), `digest`,
101
- `pending`, `approval` (by, at, signed), `applied`, the kept reports under
102
- `_gates/tf-apply/wave-<k>/`, and `command` when it waits. `locks[]` is
105
+ committed}, read: {at}, approval?, gates[], locks[], note}`. `gates[]` is one
106
+ entry per wave gate (`wave-<k>`, chant op-waves' `<gate>-wave-<k>` or a
107
+ product's `<name>-wave-<k>`: `WAVE_GATE`, #521) in `_gates/tf-apply.jsonl` and
108
+ `_gates/tf-apply/applied.jsonl`, decided by chant's `decideGateApproval`
109
+ (`@intentius/chant/gate-decision`, #520) with spent approvals left out, on
110
+ lines parsed by chant's `parseGateLedger` and `parseAppliedGateLines`. The
111
+ states:
112
+ - `waiting`: no approval of the wave's digest counts; `command` is the
113
+ approve line (`--sign` under `sealed`), or `chant workspace points answer
114
+ <id>` when the pending fact carries `answer: {point, id}` (#521).
115
+ - `expired`: the pending fact's `expiresAt` passed with none.
116
+ - `refused`: an unspent approval stands for another digest
117
+ (`refused: {digest, by, at}`); no approve line.
118
+ - `approved`: an unspent approval of the digest counts, not applied yet.
119
+ - `applied` / `failed`: a wave applied under that approval, and how the
120
+ apply finished when terragucci recorded it. chant's op waves write no
121
+ `finished` line: then `applied.result` is absent and the page says
122
+ "applied (outcome not recorded)".
123
+ - `refused-by-policy` (#521): a denial on `_gates/policy-override.jsonl`
124
+ (chant's `recordedDenials`) newer than anything on the wave's gate, that
125
+ no override counts for (chant's `decideOverride`, with the overriders at
126
+ base: `.chant/overriders`, or terragucci's `policy.override`) and no run
127
+ applied an override of since. `denials[]` carries each run, its plan and
128
+ rules, and chant's `overrideCommand` line; no approve line. The wave is
129
+ the one the denial's pending fact names (`wave: {number, name, op}`,
130
+ typed on chant's `PendingGateRecord`, whose parser drops a wave of the
131
+ wrong shape, chant#3887), else, for a fact an older chant wrote, the one
132
+ whose pending fact lists the run.
133
+ - `held` (#521): a pending fact on another gate (any `_gates/<op>.jsonl`)
134
+ that names this op's wave (`wave: {number, name, op}`), with no approval
135
+ of its digest that counts under the mode the fact records. `heldOn: {op,
136
+ gate, digest, since, heldBy, description}`; `heldBy: {hook, moment,
137
+ target?}` is the hook that holds the wave (chant#3886), and the card
138
+ (`holderWords` in `web/terragucci-lifecycle.js`) names it from there,
139
+ or from `description` for a fact an older chant wrote. `command`
140
+ approves that gate (`chant approve <op> <gate> --plan`) or answers its
141
+ decision point. A hold fact that names no wave is not read as a hold.
142
+ Each entry also carries `digest`, `pending` (with `members` and
143
+ `neverOverMcp` when recorded, `description`, `url`, `answer`), `approval`
144
+ (by, at, `signed` when the seal has a signature, via (`review` for chant's
145
+ `review: {pr, head, approvers}` line), pr, head, reviewers, relayedBy, note), `uncounted` (an
146
+ approval of this digest the mode did not count, with chant's reason, such
147
+ as an unsealed one under `sealed`), `spent` (an approval of another digest
148
+ an apply used), `applied` (with `wave` and `review` when present), the kept
149
+ reports under `_gates/tf-apply/wave-<k>/`, and `command`. `approval` on the
150
+ answer is the rule read at base, the tip of origin's default branch fetched
151
+ depth 1 into the same cache (`refs/behold/base`): `mode` from
152
+ terragucci.yml's `approval:` key, else `sealed` when chant.workspace.json
153
+ lists `identity.gates`, else `ledger`, and the signers read with chant's
154
+ `gateSignersAt`. Each wave is decided under the approval mode its newest
155
+ pending fact records (`approvalMode`, `withRecordedModes`), else the mode at
156
+ base; the entry carries `approvalMode: {mode, from: "pending" | "base"}`,
157
+ and `approval.inferred` is set only when some wave's fact records none. A
158
+ gate `identity.gates` names at base is decided with `refuses` from chant's
159
+ `identityGateAdmissionAt`, which checks the seal against the signers at
160
+ base and, when the gate names a class, the role grants in
161
+ `.chant/trust.json` at base, whatever the mode; the entry carries
162
+ `identity: {class}`. A class a pinned package supplies is read by chant
163
+ from `./workspace-principals` (chant#3888): a path pin's file at base, a
164
+ package pin's from the package installed in the served checkout's
165
+ node_modules (behold passes the checkout as `packages`, since the bare
166
+ cache has none), at the pinned version. When that package is missing or
167
+ at another version, a gate naming a domain class or `human` refuses every
168
+ approval and chant's reason names the pin. Under `pr-review`, a spec's `headPlans` (the op-waves spec name)
169
+ has the newest 50 `_wave-plans/<name>/<head>.json` records read with
170
+ chant's `readHeadPlans` (`@intentius/chant/lifecycle/wave-plans`) through
171
+ `ledgerPort`, a `LedgerReadPort` over the fetched commit. The op, ledger paths, gate names, approve line and
172
+ mode reader are a `GateLaneSpec` (`TERRAGUCCI_LANE` is terragucci's), for
173
+ another product's lane: `opWavesLane(op, over?)` gives one with chant's
174
+ approve line, mode rule and overriders, and `readLifecycle(checkout, {spec})`
175
+ reads it. `terragucciApproveCommand` is the one approve line the reports,
176
+ the estate and the lane share, and `gatePolicyName` reads `on-destroy` as
177
+ `on-destructive` (terragucci#863). `locks[]` is
103
178
  `_locks/tf-apply.json`, one per root (`pr, by, at, head, via?, stage?`).
104
179
  Every gate and lock carries `source: {commit, committed, path}`, the newest
105
180
  commit of the branch that changed its file. The branch is fetched from the
@@ -111,6 +186,27 @@ and none is the checkout's git remote) adds two reads, in
111
186
  `fetch.error`); a remote without the branch is `found: false, via:
112
187
  "fetch"`, not an error. 30 s cache, `?fresh=1` skips it. Reader:
113
188
  `src/terragucci-lifecycle.ts`.
189
+ - `GET /api/lifecycle/history?op=<op>&gate=<gate>&limit=<n>` (#522): the
190
+ gate ledgers' history on `chant/lifecycle`, for any product, registered
191
+ whether or not `--terragucci` is given. Same bare cache and fallback as
192
+ the lifecycle lane; per `_gates/<op>.jsonl` it runs `git log -p
193
+ --unified=0 --no-renames` and hands the text to chant's
194
+ `gateLedgerLinesFromLog`, then all ledgers to `deriveGateAudit`
195
+ (`@intentius/chant/lifecycle/gate-audit`). behold parses no ledger line
196
+ itself. Answer: `{branch, source: "ledger", found, via, origin?, fetch,
197
+ commit?, read, ledgers[], op?, gate?, total, limit, entries[], gaps[],
198
+ note}`, newest first, 200 by default and 1000 at most. An entry is `{id,
199
+ kind, at, op, gate, wave?, digest?, actor?, seal?, note?, rules?, subject?,
200
+ source: {path, line?, commit, committer, committed, change}, audit}`;
201
+ `kind` is chant's (`approval-requested | approved | override-requested |
202
+ override-recorded | revoked`), `audit` is chant's whole entry. A revocation
203
+ is a line a commit removed, and its `actor` is that commit's author. The
204
+ ledger holds no applies, so `applied` and `override-spent` appear only in
205
+ a product's own record (terragucci's `audit.jsonl`). The Terragucci tab's
206
+ Timeline draws it under the audit record, labelled "ledger"; when the
207
+ source has no `audit.jsonl` the record's lane says so and the ledger is
208
+ the history. An export carries it as one snapshot. Reader:
209
+ `src/lifecycle-history.ts`.
114
210
  - `GET /api/terragucci/events` (#505): SSE. While at least one page holds it
115
211
  open, a timer (`--terragucci-poll <secs>`, default 30, 0 off) asks
116
212
  whether `index.json` changed (`If-None-Match` with the last ETag for S3 and
@@ -173,6 +269,112 @@ checkout declares no Terraform root (`controlRepo` in
173
269
  structural check otherwise. The page (`web/terragucci-estate.js`) draws it
174
270
  in a layer over the graph area.
175
271
 
272
+ ## Releases per environment, from the release ledger (#525)
273
+
274
+ `GET /api/lifecycle/releases[?fresh=1]` (src/lifecycle-releases.ts) reads
275
+ `<env>/releases.jsonl` on the served checkout's `chant/lifecycle` with chant's
276
+ own reader (`listReleaseEnvironments`, `readReleaseLedger`,
277
+ `latestPerComponent`, `recordsForDigest`). The branch is fetched into the
278
+ same bare cache the terragucci lane uses (`fetchIntoCache` in
279
+ src/terragucci-lifecycle.ts). When that fetch fails, the checkout's
280
+ `refs/remotes/origin/chant/lifecycle` is fetched into a second cache of
281
+ behold's and read there (`via: "checkout-ref"`); the checkout is never
282
+ written. Reads are reused for 30s.
283
+
284
+ The answer: `environments` (wave order from `yodel-waves.json` when present,
285
+ `order: "waves"`, else by name), `components` (a tenant is
286
+ `<component>/<tenant>`, as SQL Yodeler records it), and `cells[]`, one per
287
+ component and environment that has a release: `digest`, `timestamp`,
288
+ `actor`, `approver?`, `gitSha`, `runId`, `runUrl?`, `source` (the newest
289
+ commit that changed that environment's ledger), and `differs: {from,
290
+ digest?, everHad}` when the environment before it holds another digest or
291
+ none. `/api/project` says `releaseLedger: true` for a checkout with a
292
+ `yodel.config.ts` or `yodel-waves.json`; the page then opens a Releases tab
293
+ (`web/releases.js`), and `behold export` captures the route as a snapshot.
294
+
295
+ ## SQL Yodeler's report and drift, from files (#526)
296
+
297
+ `behold serve <dir> --yodel-report <file|dir>` adds `GET /api/yodel-report`
298
+ (`src/yodel-report.ts`). It reads one `yodel report --json` output and any
299
+ `yodel drift <env> --json` output in the same directory, written by the
300
+ person or their pipeline. behold never runs `yodel` and never connects to
301
+ the database.
302
+
303
+ - The answer is `{source, validation, report, environments[], drift[],
304
+ marks[], ignored[]}`. Each environment carries its applied migrations, the
305
+ rest with their state, its gaps and notes, dated by the report's
306
+ `generatedAt`. Each mark is one object a drift file names (`changed` or
307
+ `missing`), dated by the file's mtime (drift JSON has no time of its own)
308
+ and named by its file.
309
+ - Every file is checked against SQL Yodeler's schemas: from
310
+ `@intentius/sql-yodeler/schemas/` when the package resolves beside the
311
+ served project or behold, otherwise from the copy pinned in
312
+ `src/sql-yodeler-schemas/` (sql-yodeler 19ebbff). A file that fails is a
313
+ 422 with `code: "yodel-report"` naming the file, and nothing is drawn.
314
+ Update the pinned copy by copying the three files from sql-yodeler's
315
+ `schemas/` and changing `PINNED_COMMIT`.
316
+ - The page (`web/yodel-report.js`) joins a mark to the card whose id is the
317
+ object's name, or ends in `/<name>` in a composed estate, and draws a
318
+ corner tag with its age; never a fill. Objects no card is are listed in
319
+ the SQL Yodeler tab, which also holds the environments and gaps.
320
+ - `audit[]` (#537) carries the report's audit entries with the report file
321
+ they came from. The tab lists them under their environment, each dated by
322
+ its own `at` and named by its source (a ledger's op and gate, or a history
323
+ table's row and run).
324
+ - `behold export --yodel-report <file|dir>` (#537) captures `/api/yodel-report`
325
+ as a snapshot, and a refusal fails the export, as `--terragucci` does. A
326
+ SQL Yodeler repo's export also captures `/api/sql-yodeler/lifecycle` and
327
+ `/api/lifecycle/history`, so the Pipeline lane draws in static mode,
328
+ without its Read again button.
329
+
330
+ ## A SQL Yodeler project: kinds and a lifecycle lane (#524)
331
+
332
+ A SQL Yodeler project is a chant project with `lexicons: ["sql"]` and a
333
+ `yodel.config.ts` (#517's detection, `productRepo` in src/terragucci-repo.ts).
334
+ Its graph draws from `chant graph` as any project's does. src/sql-kinds.ts
335
+ registers a pinhole pack for the `sql` lexicon: a line glyph per
336
+ `Postgres::*` and `ClickHouse::*` kind, and cards that lead with the kind in
337
+ words and the qualified name. A `REFERENCES ${other}` foreign key is a `ref`
338
+ edge; e2e/fixtures/sql-yodeler-fk holds a project with one, and
339
+ src/sql-yodeler-lane.test.ts graphs it. `@intentius/chant-lexicon-sql` is a
340
+ devDependency for that test; from 0.124.13 its lint rules use chant's own
341
+ TypeScript (chant#3864), so they run beside behold's TypeScript 7.
342
+
343
+ `GET /api/sql-yodeler/lifecycle[?fresh=1]` (src/sql-yodeler-lane.ts) reads
344
+ `chant/lifecycle` with `readLifecycle` and a spec per op, sharing one fetch:
345
+
346
+ - `opWavesLane("yodel-apply")` for the apply pipeline's
347
+ `yodel-apply-wave-<k>` gates, with the mode at base from `yodel-waves.json`
348
+ when every wave names the same `approval`. When they differ (#537) the
349
+ rule carries `waves[]` and each wave is decided under its own mode
350
+ (`waveRule`; a gate decided so carries `mode`). Under `pr-review` a gate not
351
+ yet approved carries `headPlans[]`: the heads whose
352
+ `_wave-plans/yodel-apply/<head>.json` (chant's HeadPlans, newest 50 read)
353
+ name its digest.
354
+ - `migrateOpLane(env)` for each `migrate-<env>` op that `ops/` or
355
+ `yodel-waves.json` names: its one gate `approve-migrate-<env>`, drawn as
356
+ wave 1 of that op.
357
+
358
+ The answer is the lane's header (branch, via, commit, rule, note) with
359
+ `environments[]` in the order of applies (`yodel-waves.json`, then any
360
+ environment only `ops/` names), each with its wave (`k`, targets, gate
361
+ policy, approval) and `gates[]` (`kind: "wave" | "migrate"`, the GateEntry
362
+ of #520/#521). A waiting gate carries `npx yodel approve <env> --plan
363
+ <digest>` (`--sign` under sealed). Waves the spec does not name go in
364
+ `unplaced`. A served directory that is not SQL Yodeler gets 404 with
365
+ `code: "sql-yodeler"`. The page's Pipeline tab (`web/sql-yodeler-lane.js`)
366
+ draws it, with `/api/lifecycle/history` (#522) below. Reads are reused 30s.
367
+
368
+ A SQL Yodeler project that is a chant workspace member (#537) keeps its
369
+ records under `_members/<member>/` on `chant/lifecycle`. src/member-ledger.ts
370
+ asks chant's `resolveMemberLedger` (`@intentius/chant/lifecycle/member-ledger`)
371
+ for the prefix; the lane passes it as `GateLaneSpec.ledgerPrefix`, which
372
+ `readLifecycle` puts before every branch path, and `/api/lifecycle/releases`
373
+ hands it to chant's `listReleaseEnvironments` and `readReleaseLedger` as
374
+ `ledgerPrefix` (chant#3865), which list and read the environments under it. Both answers then carry
375
+ `member: { members, prefix, error? }`, and `yodel-waves.json` at base is read
376
+ in the project's own directory.
377
+
176
378
  ## Getting a server
177
379
 
178
380
  ```sh
@@ -726,8 +928,42 @@ entry is a registered route. `TERRAGUCCI_REFUSED_WRITES` only words the
726
928
  refusal for known routes. The remedy names `npx terragucci approve wave-<k>
727
929
  --plan <digest>`, the digest from the wave's gate card.
728
930
 
931
+ A SQL Yodeler repo (a `yodel.config.ts` in the served directory or above it,
932
+ up to the git root) is guarded the same way (#517): the same allowlist, the
933
+ same refused routes, auto-sync declined, and `/api/project` carries
934
+ `sqlYodeler` so the page offers no write. The refusal is `409` with `code:
935
+ "sql-yodeler"`, and its remedy names `npx yodel approve <env> --plan <digest>`
936
+ (with `--sign` when the environment's gate is sealed) and `yodel apply <env>`
937
+ in the project's pipeline. `productRepo()` in `src/terragucci-repo.ts` looks
938
+ for terragucci's config along the whole walk first, so a yodel project nested
939
+ inside a terragucci repo is refused as terragucci.
940
+
941
+ Op discovery (`src/ops.ts`) reads a gate from `signalName: "<name>"` or from
942
+ `gate("<name>", ...)`, the form chant's op builder and SQL Yodeler write, so
943
+ a gated Op is never listed or run as ungated on any repo (#517).
944
+
945
+ A policy denial is never approved from behold (#519, `src/policy-override.ts`).
946
+ `GET /api/workspace/gates` gives a gate of op `policy-override` an `override`
947
+ field `{subject, rules, plan, digest, state, spentAt, product, command,
948
+ source}`: the rules and denied plan come from `chant/lifecycle:_gates/
949
+ policy-override.jsonl` (read with `git show`, the local branch then
950
+ `origin/chant/lifecycle`), `state` is `waiting`, `overridden` (status says an
951
+ override counts) or `spent` (a line in `_gates/policy-override/applied.jsonl`
952
+ for its digest), and `command` is the product's (`npx terragucci override`,
953
+ `npx yodel override`) or chant's `chant approve policy-override <subject>
954
+ --rule <id> --reason "<why>" --plan <digest>`. The card shows that command and
955
+ no button, and `POST /api/workspace/gates/approve` answers such a gate `422`
956
+ with `code: "override"`. The ledger lines are parsed by chant's
957
+ `@intentius/chant/override-gate` (`recordedDenials`, `parseAppliedOverrides`,
958
+ `overrideCommand`) and `parseGateLedger`, and the product is `productRepo()`
959
+ from `src/terragucci-repo.ts` (#537).
960
+
729
961
  ### The exceptions, and their exact size
730
962
 
963
+ These are also the only writes a guarded product repo answers: terragucci
964
+ (`terragucci.yml` and its siblings) and SQL Yodeler (`yodel.config.ts`), both
965
+ listed in `TERRAGUCCI_ALLOWED_WRITES`.
966
+
731
967
  `POST /api/layout` (#228) writes **one** file in the served project:
732
968
  `.behold/layout.json`, the hand-layout sidecar, `{version, lenses: {<lens>:
733
969
  {<node id>: {dx,dy,dw,dh}}}}`. That is the whole of behold's write surface
@@ -1158,7 +1394,10 @@ water park's `access/` before it was taken:
1158
1394
  The scratch project is `<tmpdir>/behold-tf-<hash of the estate path>`:
1159
1395
  `behold-*` and cleared through `assertScratch` (src/scratch.ts), one directory
1160
1396
  per estate reused across runs, asserted to be outside the estate before a byte
1161
- is written. It holds the generated `chant.config.ts` and two symlinks —
1397
+ is written. It holds the generated `chant.config.ts`, a `package.json` that
1398
+ declares it an ES module (chant's compiled CLI, 0.124.14 on, otherwise reads a
1399
+ config tsx compiled as CommonJS as `{default: config}` and finds no lexicon:
1400
+ INTENTIUS/chant#3897), and two symlinks —
1162
1401
  `node_modules` to behold's own, which is how the config resolves the lexicon,
1163
1402
  and `estate` to the served directory, which is how each root's `dir` is spelled.
1164
1403
  The second is not decoration: the lexicon sets a root's module boundary to the
package/demos.json CHANGED
@@ -65,7 +65,7 @@
65
65
  },
66
66
  {
67
67
  "name": "terraform-estate",
68
- "description": "A multi-root Terraform estate, declared only: two roots applied separately (envs/prod and platform) plus the shared module both are built on — 37 cards boxed by root, module calls expanded into their own contents, and the two look-alike versions.tf files told apart. Shows what behold does NOT infer as clearly as what it draws: platform declares the KMS key and prod reads it back by alias, and no edge is drawn between them, because both ends carry the same unresolved interpolation (#381). Needs chant's terraform lexicon beside behold, which behold does not install: npm install --no-save @intentius/chant-lexicon-terraform@^0.121.0 @cdktn/hcl2json@^0.24.0 (without them the demo refuses with that line); the copy is linked to the lexicon behold resolved, since chant reads a workspace's pinned kinds from beside the workspace. No cloud, no Docker, no terraform binary.",
68
+ "description": "A multi-root Terraform estate, declared only: two roots applied separately (envs/prod and platform) plus the shared module both are built on — 37 cards boxed by root, module calls expanded into their own contents, and the two look-alike versions.tf files told apart. Shows what behold does NOT infer as clearly as what it draws: platform declares the KMS key and prod reads it back by alias, and no edge is drawn between them, because both ends carry the same unresolved interpolation (#381). Needs chant's terraform lexicon beside behold, which behold does not install: npm install --no-save @intentius/chant-lexicon-terraform@^0.124.0 @cdktn/hcl2json@^0.24.0 (without them the demo refuses with that line); the copy is linked to the lexicon behold resolved, since chant reads a workspace's pinned kinds from beside the workspace. No cloud, no Docker, no terraform binary.",
69
69
  "source": "bundled",
70
70
  "dir": "example-terraform-estate",
71
71
  "requires": [],