diff --git a/AGENTS.md b/AGENTS.md
index 7a331e2..599ebe1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -78,6 +78,32 @@ To move a resource: confirm its band on `/api/carve`, then run chant's own
applying the generated survivor rewrites stay a human gate — behold has no
endpoint that writes Terraform, and adding one would break the invariant below.
+### Carve state, from chant's manifests (#230 M3)
+
+chant ≥ 0.52.2 writes `
.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.
+
### The walkthrough (`behold demo carve`, #254)
`behold demo carve` copies a bundled half-migrated estate (a chant project
diff --git a/docs/src/content/docs/using/carve.mdx b/docs/src/content/docs/using/carve.mdx
index fb9cc16..7027ea5 100644
--- a/docs/src/content/docs/using/carve.mdx
+++ b/docs/src/content/docs/using/carve.mdx
@@ -45,6 +45,30 @@ The Emit step reports **`chant lint`, not `chant build`**. chant#1637: `carve em
behold writes only into the copy it just made, and only into `app/carveout/` inside it. Both steps refuse outside a demo copy; a plain `behold carve report.json` shows the same six steps with the runs greyed out and a reason.
+## The strangler fig: what has actually graduated
+
+`chant carve emit` (chant ≥ 0.52.2) writes a state manifest beside the source it emits — `.carve.json` in the `--output` directory. `carve bridge` adds its record to the same file, and `carve apply` adds the ownership marker. behold reads those manifests and never writes one, which is what makes the picture survive a restart: the progression lives on disk, in chant's file, not in a behold session.
+
+Three stages, and only the last of them is a graduation:
+
+- **emitted** — typed chant source exists; the surviving Terraform still reads the resource directly. The card stays in its band, repainted in the in-flight blue with the words `emitted — not bridged`.
+- **bridged** — the survivor patch is proposed. Still banded, still Terraform's: `bridged — apply is yours`.
+- **applied** — `carve apply` recorded the ownership marker. The card leaves the Terraform bands and draws inside the chant project box, keeping its Terraform address, with `carved → chant` on it.
+
+The chant box's panel carries the count — `carved so far — 1 of 12 carved` — and the panel's **project** section lists each carved address with its stage. A resource that graduated in a previous session opens in the chant box; a partly-carved one opens back in its band, repainted, saying exactly how far it got. Nothing is inferred from what you did in this tab.
+
+Point `behold carve` at a report in a directory that also holds manifests and you get the same reading with no demo at all — the graduated pile becomes its own band above the ranking, since there is no chant member box to move a card into. An ordinary `behold serve ` whose directory carries a carveout surfaces the state in the same panel section, off `/api/project`.
+
+### `carve apply` is not a button
+
+`carve apply` graduates ownership. behold renders what the manifest records about it and echoes the command — no `--select`, because the manifest in the output directory already names the target:
+
+```sh
+chant carve apply --from legacy-tf --output app/carveout --env prod --stack assets --write-source
+```
+
+There is no `/api/carve/apply` endpoint and no apply button anywhere in the UI. That is the same line the Handoff step draws around `terraform state rm`: behold triggers delegated work, it does not decide when an estate changes hands.
+
### The `--live` tier
`npx behold demo carve --live` (docker + terraform on PATH) boots a scratch Floci — its own container name and port, refused if taken, deleted on exit — arms the estate's provider override into the demo copy, and really applies the starred resources into it. The advisor then reads a tfstate terraform wrote, and the Handoff step gains a read-only **terraform plan** button: run it after pasting `terraform state rm` and the carved resource is simply absent from the plan — `0 to destroy`, nothing blinked. The Emit step gains the observe beat: **watch chant read it live** runs `chant lifecycle diff --live` in the carveout against the scratch Floci, and the carved resource reads observed — `EXTERNAL`, ownership `foreign` — while Terraform still owns it (chant#1647's identity read path, chant ≥ 0.44.12).
diff --git a/example-carve/README.md b/example-carve/README.md
index 9d15aaa..f4131dd 100644
--- a/example-carve/README.md
+++ b/example-carve/README.md
@@ -163,6 +163,26 @@ Then the box slides out of the Terraform half and into `app/`, beside the log
group and the SSM parameter that made the same trip last month. Around two
minutes end to end.
+## What the copy remembers
+
+Every beat above writes to `app/carveout/aws_s3_bucket-assets.carve.json` —
+chant's carve state manifest (chant ≥ 0.52.2). `carve emit` creates it, `carve
+bridge` adds its record, `carve apply` adds the ownership marker, and behold
+reads it back on every request without ever writing to it.
+
+That is why the walkthrough survives a reload. After Emit, the bucket's card
+stays in its band but repaints in the in-flight blue and reads `emitted — not
+bridged`; after Bridge, `bridged — apply is yours`. Restart the server and it
+still says so. Run `chant carve apply` yourself in the copy and the card leaves
+the Terraform bands for the `app — chant` box, keeping its Terraform address,
+and the panel counts `carved so far — 1 of 12 carved`.
+
+`carve apply` is the one beat that has no button — not in the stepper, not on
+the API. It resolves the marker that makes chant the owner of a live resource,
+which is a decision that stays with the person at the terminal. The Handoff
+step echoes the command beside the two `terraform` ones, for the same reason
+and in the same shape.
+
## Two tiers
**Offline is the default.** The synthetic `terraform.tfstate` is committed, so
diff --git a/smoke/stub.mjs b/smoke/stub.mjs
index 0b0f47c..78c12ae 100644
--- a/smoke/stub.mjs
+++ b/smoke/stub.mjs
@@ -16,6 +16,9 @@ import { helmIconFor } from "../src/icon-packs.ts";
// #254: the carve lens itself, so carve mode's stub graph is the real
// conversion of the real committed report — see the carve block below.
import { carveReportToIr } from "../src/carve-lens.ts";
+// #230 M3: the same reader the server uses, so the stub's carve state is the
+// real shape rather than a hand-cut echo of it.
+import { carveStateOf, carveStatePayload } from "../src/carve-manifest.ts";
const WEB = join(dirname(fileURLToPath(import.meta.url)), "..", "web");
@@ -344,6 +347,14 @@ export function startStub(port, { carve = false } = {}) {
* so the smoke asserts the wire contract (the EXISTING op-signal route), not
* just that a button existed. */
const signalPosts = [];
+ // #230 M3: the carve state manifests, in memory instead of on disk — the
+ // stub records what a step WOULD have written the way chant's `carve emit`
+ // and `carve bridge` write their sections, and `/api/project` reads them back
+ // through the real `carveStatePayload`. So the smoke exercises the actual
+ // reader, and the panel updating mid-walkthrough is the same code path a demo
+ // copy takes. Nothing here fakes an `apply`: that stage only ever arrives
+ // from a person running the command.
+ const carveManifests = new Map();
const readBody = (req) =>
new Promise((r) => {
let s = "";
@@ -367,6 +378,15 @@ export function startStub(port, { carve = false } = {}) {
if (!CARVE_REPORT.resources.some((r) => r.address === body.select)) {
return json({ error: `\`select\` must name a resource this report ranks — ${JSON.stringify(body.select)} isn't one.`, code: "carve-select", remedy: "Pick a card in the graph." }, 400);
}
+ const held = carveManifests.get(body.select) ?? {
+ version: 1,
+ target: body.select,
+ from: CARVE_DEMO.from,
+ boundary: {},
+ };
+ if (path.endsWith("emit")) held.emit = { source: "tfstate", files: [`${CARVE_DEMO.out}/src/assets.ts`], at: new Date().toISOString() };
+ else held.bridge = { written: [], appliedInPlace: false, excised: [body.select], at: new Date().toISOString() };
+ carveManifests.set(body.select, held);
return json(path.endsWith("emit") ? CARVE_EMIT : CARVE_BRIDGE);
}
if (path === "/api/project") {
@@ -384,6 +404,16 @@ export function startStub(port, { carve = false } = {}) {
bands: CARVE_REPORT.bands,
advisory: CARVE_REPORT.advisory,
demo: CARVE_DEMO,
+ state: carveStatePayload(
+ new Map(
+ [...carveManifests].map(([target, m]) => [
+ target,
+ carveStateOf(m, `${CARVE_DEMO.out}/${target.replace(/[^A-Za-z0-9_]+/g, "-")}.carve.json`),
+ ]),
+ ),
+ CARVE_REPORT.count,
+ () => ({ from: CARVE_DEMO.fromLabel, out: CARVE_DEMO.outLabel }),
+ ),
},
});
}
diff --git a/smoke/ui-smoke.mjs b/smoke/ui-smoke.mjs
index 948a2f0..b1a0a6f 100644
--- a/smoke/ui-smoke.mjs
+++ b/smoke/ui-smoke.mjs
@@ -832,6 +832,20 @@ try {
check("the boundary report renders as a JSON tree, not a blob", (await carvePage.locator("#tab-carve .jsonv").count()) >= 1);
check("a finished run does NOT skip past its own result", (await bodyStep()) === "emit");
check("bridge unblocks once emit has run", (await stepState("bridge")) !== "blocked");
+ // #230 M3: the emit wrote a carve manifest, so the estate's own progress
+ // read moves — and it reads it back off /api/project rather than off what
+ // the POST happened to answer.
+ await step("advise").click();
+ await carvePage.waitForTimeout(200);
+ const advised = await carveText();
+ // `#panel h3` is uppercased in CSS, and innerText renders it — match the
+ // heading case-insensitively rather than against the styling.
+ check("Advise now shows the manifest-backed progress", /carved so far/i.test(advised) && advised.includes("0 of 12 carved"));
+ check("…naming the carve and its stage", advised.includes("aws_s3_bucket.assets") && advised.includes("emitted"));
+ check("…and does NOT call an emit a graduation", !advised.includes("1 of 12 carved"));
+ check("the scope panel carries the same read", /carved out of terraform/i.test(await carvePage.locator("#tab-scope").innerText()));
+ await step("emit").click();
+ await carvePage.waitForTimeout(150);
await carvePage.screenshot({ path: join(SHOTS, "8-carve-emit.png"), fullPage: true });
// 4. Bridge — proposals only, never --apply-rewrites.
@@ -849,9 +863,17 @@ try {
const handoff = await carveText();
check("Handoff renders the runbook's commands", handoff.includes("terraform state rm aws_s3_bucket.assets"));
check("…deduped (the runbook prints `terraform plan` twice)", (handoff.match(/terraform plan/g) || []).length === 1);
- check("Handoff has a copy button per command", (await carvePage.locator("#tab-carve .carve-cmd-row .carve-copy").count()) === 3);
+ check("Handoff has a copy button per command", (await carvePage.locator("#tab-carve .carve-cmd-row:not(.carve-apply-row) .carve-copy").count()) === 3);
check("Handoff has NO run button — the destructive middle stays human", (await carvePage.locator("#tab-carve button.carve-run").count()) === 0);
check("…and the UI says why", handoff.includes("not buttons, on purpose") && handoff.includes("terraform state rm"));
+ // #230 M3: `carve apply` is echoed on the same terms — a command with a
+ // copy button, and no control that runs it. The stage comes off the carve
+ // manifest the two steps wrote, not off this session's memory of them.
+ check("Handoff echoes the graduation command off the manifest", handoff.includes("chant carve apply --from legacy-tf --output app/carveout"));
+ check("…composed from the manifest, so no --select", !/carve apply[^\n]*--select/.test(handoff));
+ check("…and it says the trigger is a person, not an endpoint", handoff.includes("no apply endpoint and no apply button"));
+ check("the manifest says bridged, and does not call that carved", handoff.includes("bridged") && !handoff.includes("chant owns it"));
+ check("still no run button anywhere on the step", (await carvePage.locator("#tab-carve button.carve-run").count()) === 0);
const copyCtl = carvePage.locator("#tab-carve .carve-cmd-row .carve-copy").first();
await copyCtl.click();
await carvePage.waitForTimeout(120);
diff --git a/src/__fixtures__/carve-manifests/aws_cloudwatch_log_group-worker.carve.json b/src/__fixtures__/carve-manifests/aws_cloudwatch_log_group-worker.carve.json
new file mode 100644
index 0000000..db89870
--- /dev/null
+++ b/src/__fixtures__/carve-manifests/aws_cloudwatch_log_group-worker.carve.json
@@ -0,0 +1,41 @@
+{
+ "version": 1,
+ "target": "aws_cloudwatch_log_group.worker",
+ "tfType": "aws_cloudwatch_log_group",
+ "from": "/tmp/carve-fixture/legacy-tf",
+ "statePath": "/tmp/carve-fixture/legacy-tf/terraform.tfstate",
+ "boundary": {
+ "target": "aws_cloudwatch_log_group.worker",
+ "carveSet": [
+ {
+ "address": "aws_cloudwatch_log_group.worker",
+ "type": "aws_cloudwatch_log_group"
+ }
+ ],
+ "peelability": 100,
+ "inbound": [],
+ "outbound": [],
+ "reversible": true,
+ "diagnostics": []
+ },
+ "emit": {
+ "source": "tfstate",
+ "files": [
+ "/tmp/carve-fixture/app/carveout/src/worker.ts"
+ ],
+ "at": "2026-08-30T04:13:19.378Z"
+ },
+ "bridge": {
+ "written": [
+ "/tmp/carve-fixture/app/carveout/aws_cloudwatch_log_group-worker-runbook.md",
+ "/tmp/carve-fixture/app/carveout/observability.tf",
+ "/tmp/carve-fixture/app/carveout/aws_cloudwatch_log_group-worker-bridge.patch"
+ ],
+ "appliedInPlace": false,
+ "patch": "/tmp/carve-fixture/app/carveout/aws_cloudwatch_log_group-worker-bridge.patch",
+ "excised": [
+ "aws_cloudwatch_log_group.worker"
+ ],
+ "at": "2026-08-30T04:13:36.376Z"
+ }
+}
diff --git a/src/__fixtures__/carve-manifests/aws_iam_role-api.carve.json b/src/__fixtures__/carve-manifests/aws_iam_role-api.carve.json
new file mode 100644
index 0000000..7be1242
--- /dev/null
+++ b/src/__fixtures__/carve-manifests/aws_iam_role-api.carve.json
@@ -0,0 +1,66 @@
+{
+ "version": 1,
+ "target": "aws_iam_role.api",
+ "tfType": "aws_iam_role",
+ "from": "/tmp/carve-fixture/legacy-tf",
+ "statePath": "/tmp/carve-fixture/legacy-tf/terraform.tfstate",
+ "boundary": {
+ "target": "aws_iam_role.api",
+ "carveSet": [
+ {
+ "address": "aws_iam_role.api",
+ "type": "aws_iam_role"
+ }
+ ],
+ "peelability": 69,
+ "inbound": [
+ {
+ "direction": "inbound",
+ "survivor": "aws_lambda_function.api",
+ "carved": "aws_iam_role.api",
+ "attrs": [
+ "arn"
+ ],
+ "via": [
+ "role"
+ ],
+ "bridge": "tf-data-source",
+ "required": "immediately"
+ }
+ ],
+ "outbound": [
+ {
+ "direction": "outbound",
+ "survivor": "random_pet.suffix",
+ "carved": "aws_iam_role.api",
+ "attrs": [
+ "id"
+ ],
+ "via": [
+ "name"
+ ],
+ "bridge": "deferred-input",
+ "required": "at-apply"
+ }
+ ],
+ "reversible": true,
+ "diagnostics": []
+ },
+ "emit": {
+ "source": "tfstate",
+ "files": [
+ "/tmp/carve-fixture/app/carveout/src/api.ts"
+ ],
+ "params": {
+ "name": {
+ "tfAttr": "name",
+ "survivor": "random_pet.suffix",
+ "attrs": [
+ "id"
+ ],
+ "default": "acme-platform-api-gently-mutt"
+ }
+ },
+ "at": "2026-08-30T04:13:37.282Z"
+ }
+}
diff --git a/src/__fixtures__/carve-manifests/aws_s3_bucket-assets.carve.json b/src/__fixtures__/carve-manifests/aws_s3_bucket-assets.carve.json
new file mode 100644
index 0000000..7a50c24
--- /dev/null
+++ b/src/__fixtures__/carve-manifests/aws_s3_bucket-assets.carve.json
@@ -0,0 +1,98 @@
+{
+ "version": 1,
+ "target": "aws_s3_bucket.assets",
+ "tfType": "aws_s3_bucket",
+ "from": "/tmp/carve-fixture/legacy-tf",
+ "statePath": "/tmp/carve-fixture/legacy-tf/terraform.tfstate",
+ "boundary": {
+ "target": "aws_s3_bucket.assets",
+ "carveSet": [
+ {
+ "address": "aws_s3_bucket.assets",
+ "type": "aws_s3_bucket"
+ },
+ {
+ "address": "aws_s3_bucket_public_access_block.assets",
+ "type": "aws_s3_bucket_public_access_block",
+ "foldedInto": "aws_s3_bucket.assets"
+ },
+ {
+ "address": "aws_s3_bucket_versioning.assets",
+ "type": "aws_s3_bucket_versioning",
+ "foldedInto": "aws_s3_bucket.assets"
+ }
+ ],
+ "peelability": 84,
+ "inbound": [
+ {
+ "direction": "inbound",
+ "survivor": "aws_lambda_function.api",
+ "carved": "aws_s3_bucket.assets",
+ "attrs": [
+ "bucket"
+ ],
+ "via": [
+ "environment"
+ ],
+ "bridge": "tf-data-source",
+ "required": "immediately"
+ },
+ {
+ "direction": "inbound",
+ "survivor": "output.assets_bucket",
+ "carved": "aws_s3_bucket.assets",
+ "attrs": [
+ "bucket"
+ ],
+ "via": [
+ "value"
+ ],
+ "bridge": "tf-output-rewrite",
+ "required": "immediately"
+ }
+ ],
+ "outbound": [],
+ "reversible": true,
+ "diagnostics": []
+ },
+ "emit": {
+ "source": "tfstate",
+ "files": [
+ "/tmp/carve-fixture/app/carveout/src/assets.ts"
+ ],
+ "at": "2026-08-30T04:12:44.246Z"
+ },
+ "bridge": {
+ "written": [
+ "/tmp/carve-fixture/app/carveout/aws_s3_bucket-assets-runbook.md",
+ "/tmp/carve-fixture/app/carveout/aws_s3_bucket-assets-datasources.tf",
+ "/tmp/carve-fixture/app/carveout/compute.tf",
+ "/tmp/carve-fixture/app/carveout/outputs.tf",
+ "/tmp/carve-fixture/app/carveout/storage.tf",
+ "/tmp/carve-fixture/app/carveout/aws_s3_bucket-assets-bridge.patch"
+ ],
+ "appliedInPlace": false,
+ "patch": "/tmp/carve-fixture/app/carveout/aws_s3_bucket-assets-bridge.patch",
+ "excised": [
+ "aws_s3_bucket.assets",
+ "aws_s3_bucket_versioning.assets",
+ "aws_s3_bucket_public_access_block.assets"
+ ],
+ "at": "2026-08-30T04:13:05.804Z"
+ },
+ "apply": {
+ "marker": {
+ "stack": "assets",
+ "env": "prod"
+ },
+ "ownershipTags": {
+ "chant:managed-by": "chant",
+ "chant:stack": "assets",
+ "chant:env": "prod"
+ },
+ "stampedFiles": [
+ "/tmp/carve-fixture/app/carveout/src/assets.ts"
+ ],
+ "at": "2026-08-30T04:13:10.754Z"
+ }
+}
diff --git a/src/carve-actions.ts b/src/carve-actions.ts
index 763b1f8..7e6a685 100644
--- a/src/carve-actions.ts
+++ b/src/carve-actions.ts
@@ -24,6 +24,17 @@
* anyone's chant source. The demo copy is the user's own scratch directory, and
* a carve emits into it exactly the way the CLI would.
*
+ * NO `runCarveApply` LIVES HERE, and one should not be added (#230 M3). Every
+ * guard above would hold for it — a demo copy, a closed-set `--select`, one
+ * output directory — which is exactly why the refusal has to be written down
+ * next to the functions it would sit beside rather than left to be inferred
+ * from its absence. `chant carve apply` resolves the ownership marker that
+ * makes chant the owner of a live resource; the boundary is not that behold
+ * can't run it, it is that behold won't decide when an estate changes hands.
+ * behold renders what the carve manifest RECORDS about an apply
+ * (src/carve-manifest.ts) and echoes the command for a person to retype, the
+ * same posture the Handoff step already takes with `terraform state rm`.
+ *
* Emit shows `chant lint`, never `chant build` — chant#1637's fold is applied
* now (chant 0.44.7, chant PR #1640): the emitted bucket carries its folded
* versioning/public-access-block sub-resources as native props, so `build`
diff --git a/src/carve-manifest.test.ts b/src/carve-manifest.test.ts
new file mode 100644
index 0000000..f0e8e75
--- /dev/null
+++ b/src/carve-manifest.test.ts
@@ -0,0 +1,430 @@
+import { describe, it, expect } from "vitest";
+import { readFileSync, readdirSync } from "node:fs";
+import { join, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+import {
+ APPLY_IS_HUMAN,
+ applyCommandFor,
+ carveProgress,
+ carveStageNote,
+ carveStageOf,
+ carveStateNote,
+ carveStateOf,
+ carveStatePayload,
+ carveToneFor,
+ carveWordFor,
+ bandGraduated,
+ listCarveManifests,
+ parseCarveManifest,
+ readCarveManifest,
+ readCarveStates,
+ splitCarveState,
+ type CarveManifest,
+ type CarveManifestIo,
+ type CarveState,
+} from "./carve-manifest.ts";
+import { carveReportToIr, type CarveReport } from "./carve-lens.ts";
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const FIXTURES = join(HERE, "__fixtures__", "carve-manifests");
+
+/**
+ * The fixtures are REAL manifests. This repo's own `example-carve` was copied
+ * to a scratch dir (so nothing in the checkout was touched), `npm install` in
+ * its `app/` pinned to `@intentius/chant@0.52.2` +
+ * `@intentius/chant-lexicon-aws@0.52.2` (chant#998's manifest, chant PR #1575,
+ * ships at 0.52.2), `npm install --no-save @cdktf/hcl2json` beside it for the
+ * HCL parse, then the three carve verbs run against `legacy-tf/` with
+ * `--output app/carveout`:
+ *
+ * aws_iam_role.api `carve emit` -> emitted
+ * aws_cloudwatch_log_group.worker `carve emit` + `carve bridge` -> bridged
+ * aws_s3_bucket.assets emit + bridge + `carve apply --env prod
+ * --stack assets --write --write-source` -> applied
+ *
+ * Committed as chant wrote them, with ONE substitution: the scratch directory's
+ * absolute prefix was rewritten to `/tmp/carve-fixture`, because the real one
+ * carried a session id and the operator's home path and neither is part of the
+ * shape under test. Nothing else is edited — the boundary reports, the emit
+ * params, the ownership tags and the timestamps are chant's own.
+ *
+ * `carve apply` was run BY HAND here, in a terminal, which is the only way it
+ * is ever run: behold has no apply endpoint and no apply button. Generating
+ * this fixture is the one place the applied stage exists in this repo at all.
+ */
+const manifestPath = (name: string) => join(FIXTURES, name);
+const read = (name: string) => JSON.parse(readFileSync(manifestPath(name), "utf8")) as CarveManifest;
+
+const EMITTED = "aws_iam_role-api.carve.json";
+const BRIDGED = "aws_cloudwatch_log_group-worker.carve.json";
+const APPLIED = "aws_s3_bucket-assets.carve.json";
+
+describe("the real chant carve manifests", () => {
+ it("are all v1, each naming one Terraform address", () => {
+ const files = readdirSync(FIXTURES).filter((f) => f.endsWith(".carve.json"));
+ expect(files.sort()).toEqual([BRIDGED, EMITTED, APPLIED].sort());
+ for (const f of files) {
+ const m = parseCarveManifest(read(f));
+ expect(m, f).not.toBeNull();
+ expect(m!.version).toBe(1);
+ expect(typeof m!.target).toBe("string");
+ expect(m!.boundary, f).toBeTruthy();
+ }
+ });
+
+ it("stage off the sections chant recorded, cumulatively", () => {
+ expect(carveStageOf(read(EMITTED))).toBe("emitted");
+ expect(carveStageOf(read(BRIDGED))).toBe("bridged");
+ expect(carveStageOf(read(APPLIED))).toBe("applied");
+ });
+
+ it("only the applied one counts as graduated", () => {
+ const state = (f: string) => carveStateOf(read(f), manifestPath(f));
+ expect(state(EMITTED).graduated).toBe(false);
+ expect(state(BRIDGED).graduated).toBe(false);
+ expect(state(APPLIED).graduated).toBe(true);
+ // Ownership is chant's, recorded by the apply — behold reports it, never mints it.
+ expect(state(APPLIED).ownership).toEqual({
+ stack: "assets",
+ env: "prod",
+ tags: { "chant:managed-by": "chant", "chant:stack": "assets", "chant:env": "prod" },
+ });
+ expect(state(EMITTED).ownership).toBeUndefined();
+ });
+
+ it("flattens the emit params chant recorded as deferred inputs", () => {
+ // aws_iam_role.api reads random_pet.suffix.id for its name; chant's emit
+ // turned that outbound edge into a build parameter with a state-resolved
+ // default.
+ expect(carveStateOf(read(EMITTED), manifestPath(EMITTED)).deferredInputs).toEqual(["name ← random_pet.suffix"]);
+ });
+
+ it("carries the bridge's excision list on the bridged carve", () => {
+ const state = carveStateOf(read(BRIDGED), manifestPath(BRIDGED));
+ expect(state.excised).toEqual(["aws_cloudwatch_log_group.worker"]);
+ // The bridge wrote proposals, not edits — the manifest says so, and behold
+ // repeats it rather than reading the .tf itself.
+ expect(read(BRIDGED).bridge?.appliedInPlace).toBe(false);
+ });
+
+ it("takes the age of the state from the last step chant stamped", () => {
+ const applied = read(APPLIED);
+ expect(carveStateOf(applied, manifestPath(APPLIED)).at).toBe(applied.apply!.at);
+ const emitted = read(EMITTED);
+ expect(carveStateOf(emitted, manifestPath(EMITTED)).at).toBe(emitted.emit!.at);
+ });
+});
+
+describe("parseCarveManifest", () => {
+ it("refuses anything that isn't a v1 manifest", () => {
+ expect(parseCarveManifest(null)).toBeNull();
+ expect(parseCarveManifest([])).toBeNull();
+ expect(parseCarveManifest({})).toBeNull();
+ expect(parseCarveManifest({ version: 1 })).toBeNull();
+ expect(parseCarveManifest({ version: 1, target: "" })).toBeNull();
+ // A future major means something else by these sections; half-reading it
+ // would put a wrong stage on a card.
+ expect(parseCarveManifest({ version: 2, target: "aws_s3_bucket.assets" })).toBeNull();
+ });
+
+ it("accepts a minimal one — the fields the renderer reads, not every field chant emits", () => {
+ const m = parseCarveManifest({ version: 1, target: "aws_s3_bucket.assets", somethingNew: true });
+ expect(m?.target).toBe("aws_s3_bucket.assets");
+ });
+});
+
+describe("readCarveManifest", () => {
+ it("reads a real one off disk", () => {
+ expect(readCarveManifest(manifestPath(APPLIED))?.target).toBe("aws_s3_bucket.assets");
+ });
+
+ it("is null, never a throw, on an unreadable or unparseable file", () => {
+ expect(
+ readCarveManifest("/nope.carve.json", () => {
+ throw new Error("ENOENT");
+ }),
+ ).toBeNull();
+ expect(readCarveManifest("x", () => "{not json")).toBeNull();
+ });
+});
+
+/** An in-memory tree, so discovery is tested without a temp dir. */
+function fakeIo(files: Record): CarveManifestIo {
+ const dirs = new Set();
+ for (const p of Object.keys(files)) {
+ for (let d = dirname(p); d && d !== "/" && d !== "."; d = dirname(d)) dirs.add(d);
+ }
+ const children = (dir: string): string[] => {
+ const out = new Set();
+ for (const p of [...Object.keys(files), ...dirs]) {
+ if (dirname(p) === dir) out.add(p.slice(dir.length + 1));
+ }
+ return [...out];
+ };
+ return {
+ readdir: (dir) => children(dir),
+ isDirectory: (p) => dirs.has(p),
+ readFile: (p) => {
+ if (!(p in files)) throw new Error(`ENOENT ${p}`);
+ return files[p];
+ },
+ };
+}
+
+const manifestJson = (target: string, extra: Record = {}) =>
+ JSON.stringify({ version: 1, target, ...extra });
+
+describe("listCarveManifests", () => {
+ it("finds manifests two levels down — the carveout inside a project", () => {
+ const io = fakeIo({
+ "/e/app/carveout/aws_s3_bucket-assets.carve.json": manifestJson("aws_s3_bucket.assets"),
+ "/e/app/src/main.ts": "//",
+ });
+ expect(listCarveManifests("/e", io)).toEqual(["/e/app/carveout/aws_s3_bucket-assets.carve.json"]);
+ });
+
+ it("never walks vendored or dot directories", () => {
+ const io = fakeIo({
+ "/e/node_modules/pkg/x.carve.json": manifestJson("aws_s3_bucket.vendored"),
+ "/e/.terraform/y.carve.json": manifestJson("aws_s3_bucket.hidden"),
+ "/e/out/z.carve.json": manifestJson("aws_s3_bucket.real"),
+ });
+ expect(listCarveManifests("/e", io)).toEqual(["/e/out/z.carve.json"]);
+ });
+
+ it("stops at the documented depth rather than walking someone's repo", () => {
+ const io = fakeIo({ "/e/a/b/c/deep.carve.json": manifestJson("aws_s3_bucket.deep") });
+ expect(listCarveManifests("/e", io)).toEqual([]);
+ });
+
+ it("is empty, not a throw, on a directory that isn't there", () => {
+ expect(listCarveManifests("/gone", fakeIo({}))).toEqual([]);
+ });
+});
+
+describe("readCarveStates", () => {
+ it("keys every discovered manifest by its Terraform address", () => {
+ const io = fakeIo({
+ "/e/out/a.carve.json": manifestJson("aws_s3_bucket.assets", { emit: { files: [], at: "t" } }),
+ "/e/out/b.carve.json": manifestJson("aws_iam_role.api"),
+ });
+ const states = readCarveStates(["/e"], io);
+ expect([...states.keys()].sort()).toEqual(["aws_iam_role.api", "aws_s3_bucket.assets"]);
+ });
+
+ it("resolves a duplicated target to the further-along stage", () => {
+ const io = fakeIo({
+ "/e/stale/a.carve.json": manifestJson("aws_s3_bucket.assets"),
+ "/e/out/a.carve.json": manifestJson("aws_s3_bucket.assets", { bridge: { at: "t" }, apply: { at: "t" } }),
+ });
+ expect(readCarveStates(["/e"], io).get("aws_s3_bucket.assets")?.stage).toBe("applied");
+ });
+
+ it("skips a file that isn't a manifest without failing the read", () => {
+ const io = fakeIo({
+ "/e/out/broken.carve.json": "{ not json",
+ "/e/out/good.carve.json": manifestJson("aws_s3_bucket.assets"),
+ });
+ expect([...readCarveStates(["/e"], io).keys()]).toEqual(["aws_s3_bucket.assets"]);
+ });
+
+ it("reads the real fixture directory", () => {
+ const states = readCarveStates([FIXTURES]);
+ expect(states.size).toBe(3);
+ expect(states.get("aws_s3_bucket.assets")?.stage).toBe("applied");
+ expect(states.get("aws_cloudwatch_log_group.worker")?.stage).toBe("bridged");
+ expect(states.get("aws_iam_role.api")?.stage).toBe("emitted");
+ });
+});
+
+describe("how a stage reads", () => {
+ it("gives each stage its own word", () => {
+ expect(carveWordFor("emitted")).toBe("emitted — not bridged");
+ expect(carveWordFor("bridged")).toBe("bridged — apply is yours");
+ expect(carveWordFor("applied")).toBe("carved → chant");
+ });
+
+ it("paints only the graduated stage green — the partials share the in-flight tone", () => {
+ expect(carveToneFor("applied")).toBe("good");
+ expect(carveToneFor("bridged")).toBe("accent");
+ expect(carveToneFor("emitted")).toBe("accent");
+ });
+
+ it("says who owns it, in words, at each stage", () => {
+ const state = (f: string) => carveStateOf(read(f), manifestPath(f));
+ expect(carveStageNote(state(APPLIED))).toContain("chant owns it");
+ expect(carveStageNote(state(APPLIED))).toContain("stack assets");
+ expect(carveStageNote(state(BRIDGED))).toContain("Terraform still owns the resource");
+ expect(carveStageNote(state(EMITTED))).toContain("still reads the resource directly");
+ });
+});
+
+describe("carveProgress", () => {
+ const states = () => [...readCarveStates([FIXTURES]).values()];
+
+ it("counts the graduated against the ranking, and names the rest as in flight", () => {
+ const p = carveProgress(states(), 12);
+ expect(p).toMatchObject({ applied: 1, bridged: 1, emitted: 1, inFlight: 2, total: 12 });
+ expect(p.label).toBe("1 of 12 carved");
+ expect(p.detail).toBe("1 emitted, 1 bridged — apply is yours");
+ });
+
+ it("has no denominator to invent when there is no report", () => {
+ expect(carveProgress(states()).label).toBe("1 carved");
+ });
+
+ it("is quiet about nothing", () => {
+ const p = carveProgress([], 12);
+ expect(p.label).toBe("0 of 12 carved");
+ expect(p.detail).toBe("");
+ });
+});
+
+describe("carveStateNote", () => {
+ it("says nothing at all when no carve has been recorded", () => {
+ expect(carveStateNote(carveProgress([], 12), new Map())).toBe("");
+ });
+
+ it("names the manifest count, so the reader knows where the claim comes from", () => {
+ const states = readCarveStates([FIXTURES]);
+ const note = carveStateNote(carveProgress(states.values(), 12), states);
+ expect(note).toContain("3 manifests");
+ expect(note).toContain("1 of 12 carved");
+ });
+});
+
+describe("applyCommandFor", () => {
+ const state = () => carveStateOf(read(BRIDGED), manifestPath(BRIDGED));
+
+ it("composes off the manifest — no --select, which is what chant#998 shipped", () => {
+ const cmd = applyCommandFor(state());
+ expect(cmd).toContain("chant carve apply");
+ expect(cmd).not.toContain("--select");
+ expect(cmd).toContain("--from /tmp/carve-fixture/legacy-tf");
+ expect(cmd).toContain(`--output ${FIXTURES}`);
+ });
+
+ it("shows placeholders for what chant needs and the manifest doesn't have yet", () => {
+ expect(applyCommandFor(state())).toContain("--env --stack ");
+ });
+
+ it("uses the marker the apply recorded once there is one", () => {
+ const cmd = applyCommandFor(carveStateOf(read(APPLIED), manifestPath(APPLIED)));
+ expect(cmd).toContain("--env prod --stack assets");
+ });
+
+ it("takes display labels without changing which carve it names", () => {
+ const cmd = applyCommandFor(state(), { from: "legacy-tf", out: "app/carveout" });
+ expect(cmd).toContain("--from legacy-tf --output app/carveout");
+ });
+});
+
+describe("carveStatePayload", () => {
+ it("publishes one shape, sorted, with the apply boundary on the wire", () => {
+ const payload = carveStatePayload(readCarveStates([FIXTURES]), 12);
+ expect(payload.manifests).toBe(3);
+ expect(payload.states.map((s) => s.target)).toEqual([
+ "aws_cloudwatch_log_group.worker",
+ "aws_iam_role.api",
+ "aws_s3_bucket.assets",
+ ]);
+ expect(payload.states[0].note).toContain("Terraform still owns");
+ expect(payload.states[0].applyCommand).toContain("chant carve apply");
+ expect(payload.apply).toEqual({ human: true, note: APPLY_IS_HUMAN });
+ expect(payload.apply.note).toContain("no apply endpoint and no");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// The strangler-fig split — the picture #230 M3 is actually about.
+// ---------------------------------------------------------------------------
+
+const REPORT = JSON.parse(readFileSync(join(HERE, "__fixtures__", "carve-sample-estate.json"), "utf8")) as CarveReport;
+
+const stateFor = (target: string, stage: "emitted" | "bridged" | "applied"): CarveState =>
+ carveStateOf(
+ {
+ version: 1,
+ target,
+ ...(stage !== "emitted" ? { bridge: { at: "t" } } : {}),
+ ...(stage === "applied" ? { apply: { marker: { stack: "s", env: "e" }, at: "t" } } : {}),
+ emit: { files: [`/out/src/${target}.ts`], at: "t" },
+ } as CarveManifest,
+ "/out/x.carve.json",
+ );
+
+describe("splitCarveState", () => {
+ const ir = carveReportToIr(REPORT);
+ const target = REPORT.resources[0].address;
+
+ it("is a no-op with no manifests — the #254 frame, unchanged", () => {
+ const { tf, graduated } = splitCarveState(ir, new Map());
+ expect(tf).toBe(ir);
+ expect(graduated).toEqual([]);
+ });
+
+ it("moves a graduated address out of the bands and hands it back as a chant node", () => {
+ const { tf, graduated } = splitCarveState(ir, new Map([[target, stateFor(target, "applied")]]));
+ expect(tf.nodes.some((n) => n.id === target)).toBe(false);
+ expect(Object.values(tf.groups.byStack as Record).flat()).not.toContain(target);
+ // It keeps its Terraform ADDRESS as its id — the morph's identity
+ // continuity, and what makes a restart show the same card in the chant box.
+ expect(graduated.map((n) => n.id)).toEqual([target]);
+ expect(graduated[0].attrs._status).toBe("good");
+ expect(graduated[0].attrs.carve).toBe("carved → chant");
+ expect(graduated[0].attrs.ownedStack).toBe("s");
+ });
+
+ it("drops the boundary edges of a graduated address — that cut has been made", () => {
+ const withEdges = { ...ir, edges: [{ from: "aws_vpc.main", to: target, kind: "ref" as const }] };
+ const { tf } = splitCarveState(withEdges, new Map([[target, stateFor(target, "applied")]]));
+ expect(tf.edges).toEqual([]);
+ });
+
+ it("keeps a partial carve in its band, repainted with the stage's word", () => {
+ for (const stage of ["emitted", "bridged"] as const) {
+ const { tf, graduated } = splitCarveState(ir, new Map([[target, stateFor(target, stage)]]));
+ expect(graduated).toEqual([]);
+ const node = tf.nodes.find((n) => n.id === target)!;
+ // Tone AND word, never tone alone — and never the band's own three tones.
+ expect(node.attrs._status).toBe("accent");
+ expect(node.attrs.carve).toBe(carveWordFor(stage));
+ expect(node.attrs.carveState).toBe(stage);
+ expect(node.attrs.chantSource).toContain("/out/src/");
+ // Still banded: nothing has changed hands.
+ expect(Object.values(tf.groups.byStack as Record).flat()).toContain(target);
+ }
+ });
+
+ it("empties a band rather than leaving a titled hole when its last member graduates", () => {
+ const band = Object.entries(ir.groups.byStack as Record).find(([, m]) => m.length === 1);
+ if (!band) return; // the sample estate has no singleton band; nothing to assert
+ const [title, [only]] = band;
+ const { tf } = splitCarveState(ir, new Map([[only, stateFor(only, "applied")]]));
+ expect(Object.keys(tf.groups.byStack as Record)).not.toContain(title);
+ });
+
+ it("bands the graduated pile above the ranking when there is no chant box", () => {
+ const { tf, graduated } = splitCarveState(ir, new Map([[target, stateFor(target, "applied")]]));
+ const single = bandGraduated(tf, graduated);
+ const bands = Object.keys(single.groups.byStack as Record);
+ expect(bands[0]).toBe("carved → chant");
+ expect((single.groups.byStack as Record)["carved → chant"]).toEqual([target]);
+ expect(single.nodes).toHaveLength(ir.nodes.length);
+ // No graduation, no extra band — the #252 single view, unchanged.
+ expect(bandGraduated(ir, [])).toBe(ir);
+ });
+
+ it("trims the emitted source path for the card when a caller says how", () => {
+ const { graduated } = splitCarveState(ir, new Map([[target, stateFor(target, "applied")]]), {
+ shorten: (p) => p.replace("/out/", ""),
+ });
+ expect(graduated[0].attrs.chantSource).toBe(`src/${target}.ts`);
+ });
+
+ it("ignores a manifest for an address this report never ranked", () => {
+ const { tf, graduated } = splitCarveState(ir, new Map([["aws_s3_bucket.not_here", stateFor("aws_s3_bucket.not_here", "applied")]]));
+ expect(graduated).toEqual([]);
+ expect(tf.nodes).toHaveLength(ir.nodes.length);
+ });
+});
diff --git a/src/carve-manifest.ts b/src/carve-manifest.ts
new file mode 100644
index 0000000..4c871f9
--- /dev/null
+++ b/src/carve-manifest.ts
@@ -0,0 +1,545 @@
+/**
+ * The carve state manifest reader (#230 M3) — what makes the strangler-fig
+ * picture survive a restart.
+ *
+ * chant ≥ 0.52.2 (chant#998, chant PR #1575) persists a manifest beside the
+ * emitted source: `carve emit` writes `.carve.json` into its `--output`
+ * dir carrying the target address, the boundary report and an `emit` record;
+ * `carve bridge` adds a `bridge` record; `carve apply` adds an `apply` record
+ * with the ownership marker. Plain JSON on disk, one file per carved address —
+ * the durable record of which resources have graduated from Terraform to
+ * chant, which is exactly the progression source #230 M3 asked for.
+ *
+ * behold reads it and never writes it. The manifest is chant's file: it is
+ * written by the three carve verbs and by nothing else, and a reader that
+ * touched it would be inventing state the tool it describes did not record.
+ *
+ * The three sections are cumulative, so their presence IS the stage:
+ *
+ * emit only -> `emitted` — typed chant source exists; the
+ * surviving Terraform still reads the
+ * resource directly.
+ * emit + bridge -> `bridged` — the survivor patch is proposed; the
+ * resource has NOT changed hands.
+ * emit + bridge + apply-> `applied` — graduation is recorded: the ownership
+ * marker is resolved and chant owns it.
+ *
+ * Only `applied` is graduation. `emitted` and `bridged` are work in progress
+ * and are rendered as such — a card that read "carved" the moment source was
+ * emitted would claim an ownership transfer that has not happened, which is
+ * the one class of lie every behold view is built to avoid.
+ *
+ * `carve apply` is a HUMAN step here, always — see {@link APPLY_IS_HUMAN}.
+ */
+import { readdirSync, readFileSync, statSync } from "node:fs";
+import { dirname, join } from "node:path";
+import type { GraphIR, IRNode } from "@intentius/chant";
+
+/** chant's own suffix (`CARVE_MANIFEST_SUFFIX` in its manifest.ts). */
+export const CARVE_MANIFEST_SUFFIX = ".carve.json";
+
+/**
+ * THE APPLY BOUNDARY, stated once and referenced from every place the
+ * temptation arises (src/carve-actions.ts, carveRoutes in src/server.ts,
+ * web/carve-steps.js).
+ *
+ * `carve apply` resolves the ownership marker that makes chant the owner of a
+ * live resource. behold renders its state from the manifest and echoes the
+ * command a person could retype — the same posture the Handoff step already
+ * takes with `terraform state rm` — and offers no endpoint and no button for
+ * it. That is not an oversight to be fixed later: behold triggers delegated
+ * work, it does not decide when an estate changes hands.
+ */
+export const APPLY_IS_HUMAN =
+ "`chant carve apply` graduates ownership — it resolves the marker that makes chant the owner of a live " +
+ "resource. behold shows what the manifest records and echoes the command; it has no apply endpoint and no " +
+ "apply button, by design. Read the graduation plan, then run it yourself.";
+
+/** One carve's persisted state, as chant writes it. Every field past `version`
+ * and `target` is optional here even where chant's own type requires it — a
+ * reader should never harden a contract it does not own more than it must. */
+export interface CarveManifest {
+ version: 1;
+ /** Terraform address of the carved resource, e.g. `aws_s3_bucket.assets`. */
+ target: string;
+ tfType?: string;
+ /** The Terraform estate the carve came from. */
+ from?: string;
+ statePath?: string;
+ /** chant's boundary report for this carve, persisted at emit time. */
+ boundary?: unknown;
+ emit?: {
+ source?: "tfstate" | "live" | string;
+ files?: string[];
+ /** Deferred outbound inputs declared as build parameters (chant#998). */
+ params?: Record;
+ at?: string;
+ };
+ bridge?: {
+ written?: string[];
+ appliedInPlace?: boolean;
+ patch?: string;
+ /** Carved addresses whose own `.tf` block the rewrites remove. */
+ excised?: string[];
+ at?: string;
+ };
+ apply?: {
+ marker?: { stack?: string; env?: string };
+ ownershipTags?: Record;
+ stampedFiles?: string[];
+ at?: string;
+ };
+}
+
+/** How far along a carve is. Cumulative — see the module comment. */
+export type CarveStage = "emitted" | "bridged" | "applied";
+
+/** How a stage paints. `accent` is the in-flight tone: an existing pinhole
+ * status token, distinct from the three band tones (`good`/`warn`/`neutral`)
+ * the ranking already spends, so a partly-carved card cannot be mistaken for a
+ * band verdict. Both partial stages share it and are told apart by their WORD
+ * ({@link carveWordFor}), which is on the card: a second tone would have to
+ * borrow a token that already means something else in this picture, and tone
+ * alone was never allowed to carry a state here anyway. */
+export type CarveTone = "good" | "accent";
+
+/** One carved address, flattened for rendering. */
+export interface CarveState {
+ target: string;
+ tfType?: string;
+ stage: CarveStage;
+ /** True only at `applied`: chant owns it, so it draws in the chant box. */
+ graduated: boolean;
+ /** When the LAST recorded step ran (ISO 8601), when chant stamped one. */
+ at?: string;
+ /** Emitted chant source files, as the manifest records them. */
+ files: string[];
+ /** Terraform blocks the bridge patch would remove. */
+ excised: string[];
+ /** Deferred deploy-time inputs the emit recorded, ` ← `. */
+ deferredInputs: string[];
+ /** The ownership marker `carve apply` resolved. Present iff `applied`. */
+ ownership?: { stack?: string; env?: string; tags?: Record };
+ /** The Terraform estate the carve came from, as the manifest recorded it. */
+ from?: string;
+ /** The `--output` dir this manifest sits in — where the later verbs compose. */
+ outDir: string;
+ /** Where the manifest was read from. */
+ path: string;
+}
+
+const isRecord = (v: unknown): v is Record => typeof v === "object" && v !== null && !Array.isArray(v);
+
+const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []);
+
+/**
+ * A parsed manifest, or null when the value is not a v1 carve manifest.
+ *
+ * Same shallow discipline as `parseCarveReport`: the fields the renderer reads
+ * are checked, the rest is passed through. `version !== 1` is a refusal rather
+ * than a best-effort read — a future major would mean something else by these
+ * sections, and half-reading it would put a wrong stage on a card.
+ */
+export function parseCarveManifest(value: unknown): CarveManifest | null {
+ if (!isRecord(value)) return null;
+ if (value.version !== 1) return null;
+ if (typeof value.target !== "string" || !value.target) return null;
+ return value as unknown as CarveManifest;
+}
+
+/** Read + parse one manifest file. `readFile` is injected so the reader stays
+ * pure for tests; anything unreadable or unparseable is null, never a throw —
+ * a stray file in a carveout is not a reason to fail a graph request. */
+export function readCarveManifest(path: string, readFile: (p: string) => string = (p) => readFileSync(p, "utf8")): CarveManifest | null {
+ let text: string;
+ try {
+ text = readFile(path);
+ } catch {
+ return null;
+ }
+ try {
+ return parseCarveManifest(JSON.parse(text));
+ } catch {
+ return null;
+ }
+}
+
+/** The stage a manifest's recorded sections add up to. */
+export function carveStageOf(m: CarveManifest): CarveStage {
+ if (m.apply) return "applied";
+ if (m.bridge) return "bridged";
+ return "emitted";
+}
+
+/** The stage's word, as it reads on a card and in the panel. The word is the
+ * state's carrier; the tone only reinforces it. */
+export function carveWordFor(stage: CarveStage): string {
+ switch (stage) {
+ case "applied":
+ return "carved → chant";
+ case "bridged":
+ return "bridged — apply is yours";
+ default:
+ return "emitted — not bridged";
+ }
+}
+
+/** The stage's tone. See {@link CarveTone} for why both partial stages share
+ * `accent`. */
+export function carveToneFor(stage: CarveStage): CarveTone {
+ return stage === "applied" ? "good" : "accent";
+}
+
+/** A one-line reading of a stage, for the panel and the inspect pane. */
+export function carveStageNote(state: CarveState): string {
+ switch (state.stage) {
+ case "applied":
+ return (
+ `graduated — \`carve apply\` recorded the ownership marker` +
+ (state.ownership?.stack ? ` (stack ${state.ownership.stack}${state.ownership.env ? `, env ${state.ownership.env}` : ""})` : "") +
+ ". chant owns it."
+ );
+ case "bridged":
+ return "emitted and bridged — the survivor patch is proposed. Terraform still owns the resource until `carve apply`.";
+ default:
+ return "emitted — typed chant source exists, and the surviving Terraform still reads the resource directly. `carve bridge` is next.";
+ }
+}
+
+/** Flatten a manifest into the shape the views render. */
+export function carveStateOf(m: CarveManifest, path: string): CarveState {
+ const stage = carveStageOf(m);
+ const deferred = Object.entries(m.emit?.params ?? {}).map(([name, p]) => `${name} ← ${p?.survivor ?? "a survivor"}`);
+ return {
+ target: m.target,
+ ...(m.tfType ? { tfType: m.tfType } : {}),
+ stage,
+ graduated: stage === "applied",
+ // The last stamped step wins — that is the age of the state, not the age
+ // of the carve.
+ ...(m.apply?.at || m.bridge?.at || m.emit?.at ? { at: m.apply?.at ?? m.bridge?.at ?? m.emit?.at } : {}),
+ files: strings(m.emit?.files),
+ excised: strings(m.bridge?.excised),
+ deferredInputs: deferred,
+ ...(m.apply
+ ? {
+ ownership: {
+ ...(m.apply.marker?.stack ? { stack: m.apply.marker.stack } : {}),
+ ...(m.apply.marker?.env ? { env: m.apply.marker.env } : {}),
+ ...(m.apply.ownershipTags ? { tags: m.apply.ownershipTags } : {}),
+ },
+ }
+ : {}),
+ ...(m.from ? { from: m.from } : {}),
+ outDir: dirname(path),
+ path,
+ };
+}
+
+/** The filesystem calls discovery makes, injectable for tests. */
+export interface CarveManifestIo {
+ readdir: (dir: string) => string[];
+ isDirectory: (p: string) => boolean;
+ readFile: (p: string) => string;
+}
+
+export const nodeCarveIo: CarveManifestIo = {
+ readdir: (dir) => readdirSync(dir),
+ isDirectory: (p) => {
+ try {
+ return statSync(p).isDirectory();
+ } catch {
+ return false;
+ }
+ },
+ readFile: (p) => readFileSync(p, "utf8"),
+};
+
+/** How deep discovery walks under a scanned PROJECT directory.
+ *
+ * chant writes the manifest flat into `--output`, and that dir is almost always
+ * a child of the project it belongs to (`app/carveout/` in the demo; whatever
+ * `--output` a person passed otherwise). Two levels finds `/carveout/`
+ * and `//carveout/` without turning a graph request into a
+ * tree walk of someone's repo. Deeper than that and the manifest is somewhere
+ * behold has no business guessing about.
+ *
+ * A directory that IS the output dir — the one a `--output` named, or the one
+ * a `behold carve /report.json` pointed at — is scanned flat (depth 0)
+ * instead. Walking down from a named output dir would be a guess; walking down
+ * from a project root is the convention. */
+export const MANIFEST_SCAN_DEPTH = 2;
+
+/** Directories discovery never descends into — vendored trees and dot-dirs
+ * hold nothing chant wrote, and walking them is the whole cost of the scan. */
+const SKIP_DIRS = new Set(["node_modules", "dist", "cdk.out", ".terraform", ".git"]);
+
+/** Every carve manifest path under `dir`, sorted, bounded by `depth` (default
+ * {@link MANIFEST_SCAN_DEPTH}; 0 scans `dir` itself and nothing under it).
+ * Unreadable directories are skipped, never fatal. */
+export function listCarveManifests(dir: string, io: CarveManifestIo = nodeCarveIo, depth = MANIFEST_SCAN_DEPTH): string[] {
+ const out: string[] = [];
+ const walk = (d: string, level: number): void => {
+ let entries: string[];
+ try {
+ entries = io.readdir(d).sort();
+ } catch {
+ return;
+ }
+ for (const name of entries) {
+ const full = join(d, name);
+ if (name.endsWith(CARVE_MANIFEST_SUFFIX)) {
+ out.push(full);
+ continue;
+ }
+ if (level >= depth) continue;
+ if (name.startsWith(".") || SKIP_DIRS.has(name)) continue;
+ if (io.isDirectory(full)) walk(full, level + 1);
+ }
+ };
+ walk(dir, 0);
+ return out;
+}
+
+/**
+ * Every carve state discoverable under `dirs`, keyed by Terraform address.
+ *
+ * One address can only be in one place at a time, so a duplicate (the same
+ * target carved into two output dirs) resolves to the FURTHER-ALONG stage:
+ * `applied` beats `bridged` beats `emitted`. A half-finished second copy of a
+ * carve that already graduated is a stale artifact, not a demotion.
+ */
+export function readCarveStates(
+ dirs: readonly string[],
+ io: CarveManifestIo = nodeCarveIo,
+ depth = MANIFEST_SCAN_DEPTH,
+): Map {
+ const rank: Record = { emitted: 0, bridged: 1, applied: 2 };
+ const byTarget = new Map();
+ for (const dir of dirs) {
+ for (const path of listCarveManifests(dir, io, depth)) {
+ const manifest = readCarveManifest(path, io.readFile);
+ if (!manifest) continue;
+ const state = carveStateOf(manifest, path);
+ const held = byTarget.get(state.target);
+ if (!held || rank[state.stage] > rank[held.stage]) byTarget.set(state.target, state);
+ }
+ }
+ return byTarget;
+}
+
+/** The strangler-fig progress read. `total` is the ranked-resource count from
+ * the carve report when there is one; without a report there is no denominator
+ * and the label says the numerator alone rather than inventing one. */
+export interface CarveProgress {
+ applied: number;
+ bridged: number;
+ emitted: number;
+ /** Carves started but not graduated — the partial states. */
+ inFlight: number;
+ total: number | null;
+ /** "1 of 12 carved" / "1 carved". */
+ label: string;
+ /** "1 emitted, 1 bridged — apply is yours", or "" when nothing is in flight. */
+ detail: string;
+}
+
+export function carveProgress(states: Iterable, total?: number | null): CarveProgress {
+ let applied = 0;
+ let bridged = 0;
+ let emitted = 0;
+ for (const s of states) {
+ if (s.stage === "applied") applied++;
+ else if (s.stage === "bridged") bridged++;
+ else emitted++;
+ }
+ const inFlight = bridged + emitted;
+ const parts: string[] = [];
+ if (emitted) parts.push(`${emitted} emitted`);
+ if (bridged) parts.push(`${bridged} bridged`);
+ return {
+ applied,
+ bridged,
+ emitted,
+ inFlight,
+ total: typeof total === "number" ? total : null,
+ label: typeof total === "number" ? `${applied} of ${total} carved` : `${applied} carved`,
+ detail: parts.length ? `${parts.join(", ")} — apply is yours` : "",
+ };
+}
+
+/**
+ * The retypeable `chant carve apply` line for a carve in hand.
+ *
+ * Echoed, never run — {@link APPLY_IS_HUMAN}. `--select` is deliberately
+ * absent: the manifest in `--output` already names the target, which is the
+ * composition chant#998 shipped and the shortest true command a person can
+ * paste. `--env`/`--stack` are shown as placeholders when the manifest has no
+ * marker yet, because chant needs them to resolve one and a command that
+ * silently omits them would fail in the viewer's terminal, not here.
+ *
+ * `labels` shortens the two paths for display (the demo prints them relative to
+ * its copy, the way every other echoed command in the walkthrough does). It
+ * changes what the line READS as, never which carve it names.
+ */
+export function applyCommandFor(state: CarveState, labels: { from?: string; out?: string } = {}): string {
+ const env = state.ownership?.env ?? "";
+ const stack = state.ownership?.stack ?? "";
+ const from = labels.from ?? state.from ?? "";
+ const out = labels.out ?? state.outDir;
+ return `chant carve apply --from ${from} --output ${out} --env ${env} --stack ${stack} --write-source`;
+}
+
+/** What `/api/project` publishes about the carve state — the same shape in
+ * carve mode and on an ordinary project serve, so the panel that renders it
+ * has one branch, not two. */
+export interface CarveStatePayload {
+ manifests: number;
+ progress: CarveProgress;
+ states: Array;
+ /** The apply boundary restated on the wire, so an agent reading this sees it
+ * without reading the source. There is no endpoint to pair it with — that is
+ * the point. */
+ apply: { human: true; note: string };
+}
+
+/** Build the payload. `labels` shortens each carve's echoed paths for display
+ * (the demo prints them relative to its copy); it never changes which carve a
+ * state names. */
+export function carveStatePayload(
+ states: Map,
+ total?: number | null,
+ labels: (s: CarveState) => { from?: string; out?: string } = () => ({}),
+): CarveStatePayload {
+ const list = [...states.values()].sort((a, b) => a.target.localeCompare(b.target));
+ return {
+ manifests: list.length,
+ progress: carveProgress(list, total),
+ states: list.map((s) => ({ ...s, note: carveStageNote(s), applyCommand: applyCommandFor(s, labels(s)) })),
+ apply: { human: true, note: APPLY_IS_HUMAN },
+ };
+}
+
+/**
+ * Split a Terraform ranking against the manifest state.
+ *
+ * A graduated address leaves the TF bands entirely and comes back as a chant
+ * node — it keeps its Terraform ADDRESS as its id (the morph's identity
+ * continuity, #230 M2b, depends on that), and carries the ownership marker in
+ * its attrs so the inspect pane can say who owns it and since when.
+ *
+ * A partial address stays in its band — nothing has changed hands — but its
+ * card is repainted: the in-flight tone plus the stage's word in the `carve`
+ * field the presentation pack already shows.
+ *
+ * Boundary edges to a graduated node are dropped along with it. The edge
+ * described a cut that has now been made; drawing it would claim the survivor
+ * still reads the Terraform resource, which after `carve bridge` + `apply` it
+ * does not.
+ */
+export function splitCarveState(
+ tfIr: GraphIR,
+ states: Map,
+ // The emitted source path lands on the card, and chant records it absolute.
+ // `shorten` is how a caller trims it to something a viewer learns from —
+ // `app/carveout/src/assets.ts` rather than the length of an operator's
+ // tmpdir. Identity, not redaction: the same presentation choice
+ // src/carve-actions.ts makes for every command it echoes.
+ opts: { shorten?: (path: string) => string } = {},
+): { tf: GraphIR; graduated: IRNode[] } {
+ if (states.size === 0) return { tf: tfIr, graduated: [] };
+ const shorten = opts.shorten ?? ((p: string) => p);
+ const source = (files: string[]): string => files.map(shorten).join(", ");
+ const gone = new Set();
+ const graduated: IRNode[] = [];
+ const nodes: IRNode[] = [];
+
+ for (const node of tfIr.nodes) {
+ const state = states.get(node.id);
+ if (!state) {
+ nodes.push(node);
+ continue;
+ }
+ if (state.graduated) {
+ gone.add(node.id);
+ graduated.push({
+ ...node,
+ attrs: {
+ ...node.attrs,
+ _status: "good",
+ carve: carveWordFor(state.stage),
+ carveState: state.stage,
+ ...(state.ownership?.stack ? { ownedStack: state.ownership.stack } : {}),
+ ...(state.ownership?.env ? { ownedEnv: state.ownership.env } : {}),
+ ...(state.at ? { carvedAt: state.at } : {}),
+ ...(state.files.length ? { chantSource: source(state.files) } : {}),
+ },
+ });
+ continue;
+ }
+ nodes.push({
+ ...node,
+ attrs: {
+ ...node.attrs,
+ _status: carveToneFor(state.stage),
+ carve: carveWordFor(state.stage),
+ carveState: state.stage,
+ ...(state.at ? { carvedAt: state.at } : {}),
+ ...(state.files.length ? { chantSource: source(state.files) } : {}),
+ ...(state.excised.length ? { excisesOnApply: state.excised.join(", ") } : {}),
+ ...(state.deferredInputs.length ? { deferredInputs: state.deferredInputs.join(", ") } : {}),
+ },
+ });
+ }
+
+ const byStack: Record = {};
+ for (const [band, members] of Object.entries((tfIr.groups?.byStack ?? {}) as Record)) {
+ const left = members.filter((id) => !gone.has(id));
+ if (left.length) byStack[band] = left;
+ }
+
+ return {
+ tf: {
+ nodes,
+ edges: tfIr.edges.filter((e) => !gone.has(e.from) && !gone.has(e.to)),
+ groups: { ...tfIr.groups, byStack },
+ },
+ graduated,
+ };
+}
+
+/** The band a graduated resource lands in when there is no chant box to move
+ * it into — the same words the estate frame paints on the card. */
+export const GRADUATED_BAND = "carved → chant";
+
+/**
+ * The single-view answer to the split (`behold carve report.json`, no demo).
+ *
+ * There is no chant member box to move a graduated card into, and dropping it
+ * would be worse than either option — the resource did not stop existing, it
+ * stopped being Terraform's. So it gets its own band, ABOVE the ranking, which
+ * is where the picture reads as a progress bar: the graduated pile grows at the
+ * top while the bands below it drain.
+ */
+export function bandGraduated(tf: GraphIR, graduated: IRNode[]): GraphIR {
+ if (graduated.length === 0) return tf;
+ return {
+ nodes: [...graduated, ...tf.nodes],
+ edges: tf.edges,
+ groups: {
+ ...tf.groups,
+ byStack: {
+ [GRADUATED_BAND]: graduated.map((n) => n.id),
+ ...((tf.groups?.byStack ?? {}) as Record),
+ },
+ },
+ };
+}
+
+/** The manifest half of the statusbar's honesty line, or "" when no carve has
+ * been recorded at all. Appended to `carveNote`'s advisory reading. */
+export function carveStateNote(progress: CarveProgress, states: Map): string {
+ if (states.size === 0) return "";
+ const head = ` Carve state (from ${states.size} manifest${states.size === 1 ? "" : "s"}): ${progress.label}`;
+ return progress.detail ? `${head}, ${progress.detail}.` : `${head}.`;
+}
diff --git a/src/carve-route.test.ts b/src/carve-route.test.ts
index 051a14e..25ecdef 100644
--- a/src/carve-route.test.ts
+++ b/src/carve-route.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
-import { mkdtempSync, writeFileSync } from "node:fs";
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
@@ -131,6 +131,172 @@ describe("GET /api/project — carve mode", () => {
});
});
+// ---------------------------------------------------------------------------
+// #230 M3 — the manifest-driven strangler-fig, over the route.
+// ---------------------------------------------------------------------------
+
+/** The sample report plus carve manifests, in one throwaway output dir. The
+ * applied manifest is the REAL one (src/__fixtures__/carve-manifests, whose
+ * provenance src/carve-manifest.test.ts states); `aws_s3_bucket.assets` is an
+ * address both it and the sample estate carry, which is what lets the two
+ * fixtures compose. The second is written here at the emitted stage, because
+ * no real manifest exists for an address this particular report ranks. */
+function servedWithManifests(): ReturnType {
+ const dir = mkdtempSync(join(tmpdir(), "behold-carve-state-"));
+ const report = join(dir, "carve-report.json");
+ writeFileSync(report, readFileSync(FIXTURE, "utf8"));
+ writeFileSync(
+ join(dir, "aws_s3_bucket-assets.carve.json"),
+ readFileSync(join(HERE, "__fixtures__", "carve-manifests", "aws_s3_bucket-assets.carve.json"), "utf8"),
+ );
+ writeFileSync(
+ join(dir, "aws_lambda_function-api.carve.json"),
+ JSON.stringify({
+ version: 1,
+ target: "aws_lambda_function.api",
+ from: "/tmp/carve-fixture/legacy-tf",
+ boundary: {},
+ emit: { source: "tfstate", files: ["/tmp/carve-fixture/out/src/api.ts"], at: "2026-08-30T04:12:44.246Z" },
+ }),
+ );
+ return carveApp(report);
+}
+
+describe("the carve state manifests drive the picture (#230 M3)", () => {
+ it("draws a graduated address in its own band, out of the ranking", async () => {
+ const body = (await (await servedWithManifests().request("/api/graph")).json()) as {
+ ir: GraphIR;
+ svg: string;
+ meta: Record;
+ };
+ const bands = body.ir.groups.byStack as Record;
+ expect(Object.keys(bands)[0]).toBe("carved → chant");
+ expect(bands["carved → chant"]).toEqual(["aws_s3_bucket.assets"]);
+ expect(bands["carve now"]).not.toContain("aws_s3_bucket.assets");
+ const carved = body.ir.nodes.find((n) => n.id === "aws_s3_bucket.assets")!;
+ expect(carved.attrs._status).toBe("good");
+ expect(carved.attrs.carve).toBe("carved → chant");
+ expect(carved.attrs.ownedStack).toBe("assets");
+ // Actually drawn, not merely in the IR.
+ expect(body.svg).toContain("carved → chant");
+ });
+
+ it("leaves a partial carve in its band, repainted with the stage's word", async () => {
+ const body = (await (await servedWithManifests().request("/api/graph")).json()) as { ir: GraphIR };
+ const bands = body.ir.groups.byStack as Record;
+ const partial = body.ir.nodes.find((n) => n.id === "aws_lambda_function.api")!;
+ expect(partial.attrs._status).toBe("accent");
+ expect(partial.attrs.carve).toBe("emitted — not bridged");
+ expect(Object.values(bands).flat()).toContain("aws_lambda_function.api");
+ expect(bands["carved → chant"]).not.toContain("aws_lambda_function.api");
+ });
+
+ it("puts the progress read on the statusbar note, saying where the claim comes from", async () => {
+ const body = (await (await servedWithManifests().request("/api/graph")).json()) as { meta: { note: string } };
+ expect(body.meta.note).toContain("1 of 8 carved");
+ expect(body.meta.note).toContain("2 manifests");
+ expect(body.meta.note).toContain("1 emitted");
+ });
+
+ it("publishes the state on /api/project, with the apply boundary on the wire", async () => {
+ const body = (await (await servedWithManifests().request("/api/project")).json()) as {
+ carve: {
+ state: {
+ manifests: number;
+ progress: { label: string; applied: number; inFlight: number };
+ states: Array<{ target: string; stage: string; graduated: boolean; applyCommand: string }>;
+ apply: { human: boolean; note: string };
+ };
+ };
+ };
+ const state = body.carve.state;
+ expect(state.manifests).toBe(2);
+ expect(state.progress).toMatchObject({ label: "1 of 8 carved", applied: 1, inFlight: 1 });
+ expect(state.states.map((s) => s.stage)).toEqual(["emitted", "applied"]);
+ expect(state.states[0].applyCommand).toContain("chant carve apply");
+ expect(state.states[0].applyCommand).not.toContain("--select");
+ expect(state.apply.human).toBe(true);
+ });
+
+ it("has no apply endpoint — the graduation step is a person, not a POST", async () => {
+ const app = servedWithManifests();
+ for (const path of ["/api/carve/apply", "/api/carve/graduate"]) {
+ const res = await app.request(path, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: "{}",
+ });
+ expect(res.status, path).toBe(404);
+ }
+ // …and it isn't advertised either.
+ const api = (await (await app.request("/api")).json()) as { routes: Array<{ path: string }> };
+ expect(api.routes.some((r) => r.path.includes("apply") && r.path.includes("carve"))).toBe(false);
+ });
+
+ it("says nothing about carve state when no manifest exists — the #252 view, unchanged", async () => {
+ const body = (await (await carveApp(FIXTURE).request("/api/graph")).json()) as { ir: GraphIR; meta: { note: string } };
+ expect(Object.keys(body.ir.groups.byStack as Record)).not.toContain("carved → chant");
+ expect(body.meta.note).not.toContain("Carve state");
+ const project = (await (await carveApp(FIXTURE).request("/api/project")).json()) as {
+ carve: { state: { manifests: number } };
+ };
+ expect(project.carve.state.manifests).toBe(0);
+ });
+});
+
+describe("a real project serve surfaces its carve state without the demo scaffolding (#230 M3)", () => {
+ /** A chant project with a carveout inside it, the way `chant carve emit
+ * --output ./carveout` leaves one. No report, no demo, no stepper. */
+ function carvedProject(): string {
+ const dir = mkdtempSync(join(tmpdir(), "behold-carved-project-"));
+ writeFileSync(join(dir, "chant.config.ts"), 'export default { lexicons: ["aws"] };\n');
+ mkdirSync(join(dir, "carveout"), { recursive: true });
+ for (const f of ["aws_s3_bucket-assets.carve.json", "aws_iam_role-api.carve.json"]) {
+ writeFileSync(join(dir, "carveout", f), readFileSync(join(HERE, "__fixtures__", "carve-manifests", f), "utf8"));
+ }
+ return dir;
+ }
+
+ function projectApp(dir: string) {
+ const broadcaster = new Broadcaster();
+ return createApp(
+ { projectDir: dir, port: 0 },
+ broadcaster,
+ new FrameBuffer(),
+ new OpRunner({ projectDir: dir, broadcaster, onDone: () => {} }),
+ );
+ }
+
+ it("finds the manifests in the project's carveout and publishes what they record", async () => {
+ const dir = carvedProject();
+ try {
+ const body = (await (await projectApp(dir).request("/api/project")).json()) as {
+ carve?: { state: { manifests: number; progress: { label: string }; states: Array<{ target: string; stage: string }> } };
+ };
+ expect(body.carve?.state.manifests).toBe(2);
+ // No report, so there is no denominator to invent.
+ expect(body.carve?.state.progress.label).toBe("1 carved");
+ expect(body.carve?.state.states.map((s) => `${s.target}:${s.stage}`)).toEqual([
+ "aws_iam_role.api:emitted",
+ "aws_s3_bucket.assets:applied",
+ ]);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("omits `carve` entirely for a project that was never carved — no dead panel", async () => {
+ const dir = mkdtempSync(join(tmpdir(), "behold-uncarved-project-"));
+ writeFileSync(join(dir, "chant.config.ts"), 'export default { lexicons: ["aws"] };\n');
+ try {
+ const body = (await (await projectApp(dir).request("/api/project")).json()) as Record;
+ expect("carve" in body).toBe(false);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+});
+
describe("carve mode probes nothing and writes nothing", () => {
it("answers substrates and history empty rather than running Docker and git for a static file", async () => {
const app = carveApp(FIXTURE);
diff --git a/src/render.test.ts b/src/render.test.ts
index 684722f..5f39acf 100644
--- a/src/render.test.ts
+++ b/src/render.test.ts
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import { renderGraph, renderBanded, renderCarveEstate, renderCarveMorph } from "./render.ts";
+import { carveStateOf } from "./carve-manifest.ts";
import type { GraphIR } from "@intentius/chant";
// M4: renderGraph gained an explicit `boxes: "byStack"` opt-in for the
@@ -337,6 +338,78 @@ describe("renderCarveEstate — the carve estate frame (#254)", () => {
expect(w / h).toBeLessThan(4);
});
+ // #230 M3: the same frame, driven by the carve state manifests. The
+ // manifest reader itself is covered in src/carve-manifest.test.ts against
+ // real chant output; these pin what the PICTURE does with it.
+ describe("the manifest-driven strangler-fig (#230 M3)", () => {
+ const state = (target: string, stage: "emitted" | "bridged" | "applied") =>
+ carveStateOf(
+ {
+ version: 1,
+ target,
+ emit: { files: [`/out/src/${target}.ts`], at: "t" },
+ ...(stage !== "emitted" ? { bridge: { at: "t" } } : {}),
+ ...(stage === "applied" ? { apply: { marker: { stack: "s", env: "e" }, at: "t" } } : {}),
+ },
+ "/out/x.carve.json",
+ );
+ const carvedEstate = (states: Map>) =>
+ renderCarveEstate(bandedIr, appIr, { tfTitle: "legacy-tf — terraform", appTitle: "app — chant", carved: states });
+
+ it("draws a graduated resource inside the chant box, keeping its Terraform address", () => {
+ const target = bandedIr.nodes[0].id;
+ const { svg, ir } = carvedEstate(new Map([[target, state(target, "applied")]]));
+ const byStack = ir.groups.byStack as Record;
+ expect(byStack["app — chant"]).toContain(target);
+ expect(byStack["carve now"]).not.toContain(target);
+ // One card, one id — the morph's continuity, and what a restart shows.
+ expect((svg.match(new RegExp(`data-node-id="${target.replace(".", "\\.")}"`, "g")) || []).length).toBe(1);
+ });
+
+ it("puts the progress read on the chant panel's title, where the estate summarizes it", () => {
+ const target = bandedIr.nodes[0].id;
+ const { svg } = carvedEstate(new Map([[target, state(target, "applied")]]));
+ expect(svg).toContain(`carved so far — 1 of ${bandedIr.nodes.length} carved`);
+ });
+
+ it("leaves a partial carve in its band, repainted with the stage's word", () => {
+ const target = bandedIr.nodes[0].id;
+ const { ir } = carvedEstate(new Map([[target, state(target, "bridged")]]));
+ const byStack = ir.groups.byStack as Record;
+ expect(byStack["carve now"]).toContain(target);
+ expect(byStack["app — chant"]).not.toContain(target);
+ const node = ir.nodes.find((n) => n.id === target)!;
+ expect(node.attrs._status).toBe("accent");
+ expect(node.attrs.carve).toBe("bridged — apply is yours");
+ });
+
+ it("is the #254 frame exactly when no manifest exists", () => {
+ const plain = estate();
+ const empty = carvedEstate(new Map());
+ expect(empty.ir.groups).toEqual(plain.ir.groups);
+ expect(empty.svg).toContain("carved so far");
+ expect(empty.svg).not.toContain("carved so far —");
+ });
+
+ it("opens the morph with an already-graduated card in the chant box", () => {
+ const parseViews = (html: string) => JSON.parse(html.match(/const VIEWS = (\[[\s\S]*?\]);\n/)![1].replace(/\\u003c/g, "<"));
+ const already = bandedIr.nodes[1].id;
+ const VIEWS = parseViews(
+ renderCarveMorph(bandedIr, appIr, ["aws_s3_bucket.b0"], {
+ tfTitle: "legacy-tf — terraform",
+ appTitle: "app — chant",
+ carved: new Map([[already, state(already, "applied")]]),
+ }),
+ );
+ const appBox = (v: { boxes: Array<{ key: string; x: number }> }) => v.boxes.find((b) => b.key === "app — chant")!;
+ // In the BEFORE frame already — the morph is about the one card moving.
+ expect(VIEWS[0].pos[already].x).toBeGreaterThan(appBox(VIEWS[0]).x);
+ expect(VIEWS[1].pos[already].x).toBeGreaterThan(appBox(VIEWS[1]).x);
+ // Both views keep the plain panel title, so the box morphs instead of swapping.
+ for (const v of VIEWS) expect(v.boxes.map((b: { key: string }) => b.key)).toContain("carved so far");
+ });
+ });
+
// #230 M2b: the morph — two estate frames, the carved card gliding between
// them. Identity continuity is the whole trick: same id in both views.
describe("renderCarveMorph", () => {
diff --git a/src/render.ts b/src/render.ts
index 378c34d..2501016 100644
--- a/src/render.ts
+++ b/src/render.ts
@@ -21,6 +21,7 @@ import type { GraphIR, IRGroups, Layout } from "@intentius/chant";
import type { ByContainer } from "./logical.ts";
import { k8sIconFor, helmIconFor } from "./icon-packs.ts";
import { carveCardFields } from "./carve-lens.ts";
+import { carveProgress, splitCarveState, type CarveState } from "./carve-manifest.ts";
import { opCardFields } from "./ops-lens.ts";
// Lexicon-native icons (#227), step 2 of 2. pinhole resolves a node's glyph
@@ -260,13 +261,26 @@ function bandedPlan(ir: GraphIR, bands: Record): BandedPlan {
* ranked TF addresses keep their ids untouched — the morph's identity
* continuity (#230 M2b) depends on the carved card keeping its id across
* views. `groups.byStack` on the composed IR carries the bands plus the app
- * member, so the client's box matcher sees every box it is shown. */
+ * member, so the client's box matcher sees every box it is shown.
+ *
+ * `carved` (#230 M3) is the carve state manifests chant persisted — the
+ * strangler-fig's memory. It decides which side of the estate a ranked address
+ * draws on, so the picture is the same after a restart as it was before one;
+ * see src/carve-manifest.ts. Absent (or empty) it is a no-op and this renders
+ * exactly the #254 frame. */
export function renderCarveEstate(
tfIr: GraphIR,
appIr: GraphIR,
- opts: { tfTitle: string; appTitle: string; theme?: string },
+ opts: {
+ tfTitle: string;
+ appTitle: string;
+ theme?: string;
+ carved?: Map;
+ shorten?: (path: string) => string;
+ },
): RenderResult & { ir: GraphIR } {
- const comp = composeCarveEstate(tfIr, namespaceAppIr(appIr, opts.appTitle), opts);
+ const staged = withCarveState(tfIr, appIr, opts);
+ const comp = composeCarveEstate(staged.tf, staged.appNs, { ...opts, carvedTitle: staged.carvedTitle });
const svg = renderSvg(comp.ir, comp.layout, {
fit: true,
hideTitle: true,
@@ -276,6 +290,37 @@ export function renderCarveEstate(
return { svg, ir: comp.ir };
}
+/**
+ * The manifest overlay, applied to both halves of the estate at once (#230 M3).
+ *
+ * Graduated addresses come out of the TF ranking and go into the chant box
+ * keeping their Terraform address as their id — the same identity continuity
+ * the morph relies on, which is what makes a restart show the card ALREADY in
+ * the chant box rather than a different card in a similar place. Partial
+ * carves stay in their band, repainted (src/carve-manifest.ts `splitCarveState`).
+ *
+ * The chant panel's title carries the progress read, because the panel is where
+ * the estate already summarizes this member's contents. The denominator is the
+ * ranking's own size BEFORE the split: a graduated resource left the ranking,
+ * and counting it out of both halves would make the progress bar shrink as it
+ * filled.
+ */
+function withCarveState(
+ tfIr: GraphIR,
+ appIr: GraphIR,
+ opts: { appTitle: string; carved?: Map; shorten?: (path: string) => string },
+): { tf: GraphIR; appNs: GraphIR; carvedTitle: string } {
+ const states = opts.carved ?? new Map();
+ const { tf, graduated } = splitCarveState(tfIr, states, { ...(opts.shorten ? { shorten: opts.shorten } : {}) });
+ const ns = namespaceAppIr(appIr, opts.appTitle);
+ const progress = carveProgress(states.values(), tfIr.nodes.length);
+ return {
+ tf,
+ appNs: { ...ns, nodes: [...ns.nodes, ...graduated] },
+ carvedTitle: states.size ? `carved so far — ${progress.label}` : "carved so far",
+ };
+}
+
/** App-side node ids get the member prefix (`app/`, composeStacks'
* convention) so they can never collide with a Terraform address. The carved
* nodes the morph moves INTO the app box deliberately skip this — they keep
@@ -300,11 +345,16 @@ interface EstateComposition {
boxes: GroupBox[];
}
-function composeCarveEstate(tfIr: GraphIR, appNs: GraphIR, opts: { tfTitle: string; appTitle: string }): EstateComposition {
+function composeCarveEstate(
+ tfIr: GraphIR,
+ appNs: GraphIR,
+ opts: { tfTitle: string; appTitle: string; carvedTitle?: string },
+): EstateComposition {
const tfPlan = bandedPlan(tfIr, (tfIr.groups.byStack ?? {}) as Record);
// The app side reuses the banded grid as a single panel: same cell metrics
- // discipline, and the panel title says what these cards have in common.
- const appPlan = bandedPlan(appNs, { "carved so far": appNs.nodes.map((n) => n.id) });
+ // discipline, and the panel title says what these cards have in common —
+ // plus the manifest-backed progress read when there is one (#230 M3).
+ const appPlan = bandedPlan(appNs, { [opts.carvedTitle ?? "carved so far"]: appNs.nodes.map((n) => n.id) });
// Two member boxes, top-aligned, TF on the left where the ranking's weight
// is. Content sits inside each member at (PAD, TITLE + PAD).
@@ -362,28 +412,45 @@ export function renderCarveMorph(
tfIr: GraphIR,
appIr: GraphIR,
carved: string[],
- opts: { tfTitle: string; appTitle: string; title?: string },
+ opts: {
+ tfTitle: string;
+ appTitle: string;
+ title?: string;
+ carved?: Map;
+ shorten?: (path: string) => string;
+ },
): string {
- const appNs = namespaceAppIr(appIr, opts.appTitle);
- const before = composeCarveEstate(tfIr, appNs, opts);
+ // The BEFORE frame is the estate as it stands, manifests included (#230 M3):
+ // a resource that graduated in a previous session is already in the chant box
+ // when the morph opens, so the one card that moves is the one this morph is
+ // about. Selecting an address that has already graduated is therefore a
+ // no-movement morph — which is the truth, not a bug.
+ const staged = withCarveState(tfIr, appIr, opts);
+ const appNs = staged.appNs;
+ // Both views keep the PLAIN panel title, not the progress one the estate
+ // frame uses: a box morphs to the box with the same title, and a title that
+ // counted the cards would read "N of M" in a frame showing N+1 of them and
+ // break the FLIP into a swap. The progress read lives on the estate view.
+ const morphOpts = { ...opts, carvedTitle: "carved so far" };
+ const before = composeCarveEstate(staged.tf, appNs, morphOpts);
const gone = new Set(carved);
- const bands = (tfIr.groups.byStack ?? {}) as Record;
+ const bands = (staged.tf.groups.byStack ?? {}) as Record;
const bandsAfter: Record = {};
for (const [band, members] of Object.entries(bands)) {
const left = members.filter((id) => !gone.has(id));
if (left.length) bandsAfter[band] = left;
}
const tfAfter: GraphIR = {
- nodes: tfIr.nodes.filter((n) => !gone.has(n.id)),
- edges: tfIr.edges.filter((e) => !gone.has(e.from) && !gone.has(e.to)),
+ nodes: staged.tf.nodes.filter((n) => !gone.has(n.id)),
+ edges: staged.tf.edges.filter((e) => !gone.has(e.from) && !gone.has(e.to)),
groups: { byStack: bandsAfter },
};
- const carvedNodes = tfIr.nodes
+ const carvedNodes = staged.tf.nodes
.filter((n) => gone.has(n.id))
.map((n) => ({ ...n, attrs: { ...n.attrs, _status: "good", carve: "carved → chant" } }));
const appAfter: GraphIR = { ...appNs, nodes: [...appNs.nodes, ...carvedNodes] };
- const after = composeCarveEstate(tfAfter, appAfter, opts);
+ const after = composeCarveEstate(tfAfter, appAfter, morphOpts);
const view = (name: string, comp: EstateComposition): MorphView => ({
name,
diff --git a/src/server.ts b/src/server.ts
index 1dfcedf..0d41de0 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -58,6 +58,16 @@ import { resourcesByComponent, nonResourceEntities } from "./resources.ts";
import { summarizePlan } from "./reconcile.ts";
import { renderGraph, renderArchitecture, renderBanded, renderCarveEstate, renderCarveMorph } from "./render.ts";
import { readCarveReport, carveReportToIr, carveNote } from "./carve-lens.ts";
+import {
+ bandGraduated,
+ carveProgress,
+ carveStateNote,
+ carveStatePayload,
+ nodeCarveIo,
+ readCarveStates,
+ splitCarveState,
+ type CarveState,
+} from "./carve-manifest.ts";
import {
carveWriteBlock,
runCarveBridge,
@@ -65,6 +75,7 @@ import {
runCarveObserve,
runCarvePlan,
selectFromReport,
+ shortenIn,
BUILD_CAVEAT,
type CarveDemo,
} from "./carve-actions.ts";
@@ -413,6 +424,18 @@ async function captureFrame(
function carveRoutes(app: Hono, reportPath: string, demo?: CarveDemo): void {
const load = () => readCarveReport(reportPath, (p) => readFileSync(p, "utf8"));
+ // The carve state manifests (#230 M3, chant#998) — read fresh per request,
+ // exactly like the report above, because emit and bridge write one while the
+ // walkthrough is open and a cached read would show a stale strangler-fig.
+ //
+ // Both scanned FLAT (depth 0), because both are output dirs rather than
+ // project roots: chant writes the manifest into `--output`, a demo's runs are
+ // handed `--output `, and `behold carve out/report.json` names the
+ // directory the report was written into. Read-only — behold never writes a
+ // manifest; see src/carve-manifest.ts.
+ const carveDirs = [dirname(reportPath), ...(demo ? [demo.out] : [])];
+ const carveStates = (): Map => readCarveStates(carveDirs, nodeCarveIo, 0);
+
// #254's stepper needs to know, before it draws a button, whether this server
// can actually run a step — a `behold carve report.json` looks identical
// otherwise, and offering an Emit button that always 403s would be a lie in
@@ -471,12 +494,30 @@ function carveRoutes(app: Hono, reportPath: string, demo?: CarveDemo): void {
// each one, because dagre lays an edgeless graph out along a single row (see
// its doc comment for the numbers).
const appSide = await appGraphOnce();
+ // #230 M3: the manifests decide which side of the estate each ranked
+ // address draws on, so the picture survives a restart — a resource that
+ // graduated last session opens in the chant box, not back in its band.
+ const states = carveStates();
+ // With no chant box to move a graduated card into (a bare `behold carve
+ // report.json`), it gets its own band above the ranking — `bandGraduated`.
+ // The emitted source path reaches the card; a demo trims it to the copy the
+ // way every echoed command in the walkthrough is trimmed.
+ const shorten = demo ? (p: string) => shortenIn(p, demo.root) : (p: string) => p;
+ const single = () => {
+ const split = splitCarveState(tfIr, states, { shorten });
+ return bandGraduated(split.tf, split.graduated);
+ };
const { svg, ir } = appSide
? renderCarveEstate(tfIr, appSide.ir, {
tfTitle: `${relative(demo!.root, demo!.from).split(sep).join("/")} — terraform`,
appTitle: `${appSide.label} — chant`,
+ carved: states,
+ shorten,
})
- : { ...renderBanded(tfIr), ir: tfIr };
+ : (() => {
+ const banded = single();
+ return { ...renderBanded(banded), ir: banded };
+ })();
return c.json({
ir,
svg,
@@ -488,7 +529,10 @@ function carveRoutes(app: Hono, reportPath: string, demo?: CarveDemo): void {
carve: true,
// A demo whose own advisor run failed says so on the statusbar, not
// only in the terminal the viewer isn't looking at.
- note: carveNote(parsed.report, tfIr) + (demo?.degraded ? ` Degraded: ${demo.degraded}` : ""),
+ note:
+ carveNote(parsed.report, tfIr) +
+ carveStateNote(carveProgress(states.values(), tfIr.nodes.length), states) +
+ (demo?.degraded ? ` Degraded: ${demo.degraded}` : ""),
},
});
});
@@ -532,12 +576,22 @@ function carveRoutes(app: Hono, reportPath: string, demo?: CarveDemo): void {
tfTitle: `${relative(demo!.root, demo!.from).split(sep).join("/")} — terraform`,
appTitle: `${appSide.label} — chant`,
title: `carve — ${select}`,
+ carved: carveStates(),
+ shorten: (p: string) => shortenIn(p, demo!.root),
});
return c.html(html);
});
app.get("/api/project", (c) => {
const parsed = load();
+ const total = parsed.ok ? (parsed.report.count ?? parsed.report.resources.length) : null;
+ // #230 M3: the manifest-backed state, in the same shape an ordinary project
+ // serve publishes (see `carveStateForDirs` below). Paths are echoed
+ // relative to the demo copy — nobody learns anything from the length of the
+ // operator's tmpdir.
+ const state = carveStatePayload(carveStates(), total, () =>
+ demo ? { from: shortenIn(demo.from, demo.root), out: shortenIn(demo.out, demo.root) } : {},
+ );
return c.json({
projectDir: reportPath,
recents: [],
@@ -552,12 +606,13 @@ function carveRoutes(app: Hono, reportPath: string, demo?: CarveDemo): void {
? {
report: reportPath,
from: parsed.report.from ?? null,
- count: parsed.report.count ?? parsed.report.resources.length,
+ count: total,
bands: parsed.report.bands ?? {},
advisory: parsed.report.advisory ?? null,
demo: demoInfo(),
+ state,
}
- : { report: reportPath, demo: demoInfo() },
+ : { report: reportPath, demo: demoInfo(), state },
});
});
@@ -611,6 +666,16 @@ function carveRoutes(app: Hono, reportPath: string, demo?: CarveDemo): void {
app.post("/api/carve/emit", (c) => runStep(c, runCarveEmit));
app.post("/api/carve/bridge", (c) => runStep(c, runCarveBridge));
+ // There is no `/api/carve/apply`, and adding one is not a follow-up (#230 M3).
+ // `chant carve apply` graduates ownership — it resolves the marker that makes
+ // chant the owner of a live resource. `runStep` above would take it in four
+ // lines and every guard already written would still hold, which is exactly
+ // why the refusal has to be stated here rather than assumed: the boundary is
+ // not "behold can't", it is "behold won't". What the manifest records about
+ // an apply is rendered (carveStatePayload above) and the command is echoed
+ // for a person to retype; the trigger stays a human in a terminal, the same
+ // posture the Handoff step takes with `terraform state rm`. See
+ // src/carve-manifest.ts `APPLY_IS_HUMAN`.
// The observe beat (#254, chant#1647): chant reads the carved resource live
// from the carveout, endpoint pointed at the scratch Floci. Same select
// discipline as emit/bridge (membership in the report), refuses on a
@@ -923,8 +988,22 @@ export function createApp(
// the tab is open grows the ops stop on the next /api/project, without a
// restart.
const emittedOps = discoverOpIrs(estateDirs).length;
+ // #230 M3 (item 4): a real project whose directory carries `*.carve.json`
+ // manifests surfaces its carve state with none of the demo scaffolding —
+ // no report, no stepper, no write actions, just what chant recorded. Read
+ // at request time like `emittedOps` above, and gated on there being
+ // manifests at all, so a project that was never carved grows no dead panel.
+ //
+ // Deliberately NOT joined onto the graph. The manifest keys on a TERRAFORM
+ // address; these nodes key on chant entity names. Matching the two is the
+ // dedupe question #230's tier grading called unsolved, and getting it wrong
+ // means claiming two things are one resource. So this publishes what the
+ // manifest actually says — which Terraform addresses graduated, and how far
+ // the others got — beside the estate rather than painted onto it.
+ const carveState = carveStatePayload(readCarveStates(estateDirs));
return c.json({
projectDir: cfg.projectDir,
+ ...(carveState.manifests ? { carve: { state: carveState } } : {}),
// #195: the full estate composition (multi-project serves) and the
// switcher's recents, so the SPA's project section can show what's
// loaded and offer where to go. Recents exclude nothing here — the SPA
diff --git a/web/app.js b/web/app.js
index b74ebf4..d209703 100644
--- a/web/app.js
+++ b/web/app.js
@@ -1064,6 +1064,55 @@ function primeDemoCatalog() {
});
}
+/**
+ * The manifest-backed carve state (#230 M3), as the scope panel shows it: the
+ * progress read, then one row per carved address with its stage.
+ *
+ * Stage is carried by the WORD, with the tone reinforcing it — green for
+ * graduated, the pending blue for a carve still in flight (the same blue
+ * pinhole paints `accent` cards with, so the panel row and the card agree).
+ * A no-op when the served estate carries no manifests: a project that was
+ * never carved grows no dead section.
+ *
+ * `chant carve apply` is echoed per row and never offered as a button — see
+ * src/carve-manifest.ts APPLY_IS_HUMAN, which `state.apply.note` carries here
+ * verbatim.
+ */
+function renderCarveState(host, state) {
+ if (!state || !state.manifests) return;
+ host.appendChild(panelHeading("carved out of terraform"));
+ const head = document.createElement("div");
+ head.className = "count-row";
+ const label = document.createElement("span");
+ label.className = "grow";
+ label.textContent = state.progress.label;
+ const tag = document.createElement("span");
+ tag.className = "tag";
+ tag.textContent = state.manifests + (state.manifests === 1 ? " manifest" : " manifests");
+ tag.title = "chant's own *.carve.json state manifests — behold reads them and never writes one.";
+ head.append(label, tag);
+ host.appendChild(head);
+ if (state.progress.detail) host.appendChild(panelMuted(state.progress.detail));
+ for (const s of state.states) {
+ const row = document.createElement("div");
+ row.className = "count-row";
+ const dot = document.createElement("span");
+ dot.className = "dot";
+ dot.style.background = s.graduated ? "var(--managed)" : "var(--pending)";
+ const name = document.createElement("span");
+ name.className = "grow";
+ name.textContent = s.target;
+ name.title = s.note;
+ const stage = document.createElement("span");
+ stage.className = "tag";
+ stage.textContent = s.stage;
+ row.append(dot, name, stage);
+ host.appendChild(row);
+ if (!s.graduated) host.appendChild(panelMuted(s.applyCommand));
+ }
+ host.appendChild(panelMuted(state.apply.note));
+}
+
function renderPanelScope() {
const host = document.getElementById("tab-scope");
if (!host) return;
@@ -1089,6 +1138,12 @@ function renderPanelScope() {
host.appendChild(panelMuted(dir));
}
if (!estateDirs.length) host.appendChild(panelMuted("no project loaded yet"));
+ // #230 M3: the strangler-fig progress, right where the panel already
+ // summarizes this estate's members. It reads off chant's own `*.carve.json`
+ // manifests (chant#998), so it says the same thing after a restart as before
+ // one — and it renders identically in carve mode and on an ordinary project
+ // serve, because /api/project publishes one shape for both.
+ renderCarveState(host, info.carve && info.carve.state);
// #195: switching — recents first (server-persisted, validated), then a
// free path input. Locked in preview mode (the demo's contract) and
// meaningless in a static export.
@@ -1417,6 +1472,27 @@ const carveActions = {
/** Run one of the two safe steps. Both are POSTs with a `{select}` body; both
* answer either their result or #193's `{error, code, remedy}`. */
+/**
+ * Re-read the manifest-backed carve state (#230 M3) after a step wrote one.
+ *
+ * One `/api/project` plus a graph reload: the panel's "carved so far" and the
+ * repainted card both come off the manifests on disk, so an in-session update
+ * and a post-restart read are the same read. A failed refresh leaves what was
+ * already shown alone — stale is better than a state change nobody made.
+ */
+async function refreshCarveState() {
+ try {
+ const info = await apiFetch("/api/project").then((r) => r.json());
+ projectInfo = info;
+ carveInfo = info.carve || null;
+ } catch {
+ return;
+ }
+ renderPanelScope();
+ renderPanelCarve();
+ await load();
+}
+
async function runCarveStep(which) {
if (carveState.busy || !carveState.pick) return;
carveState.busy = which;
@@ -1439,6 +1515,11 @@ async function runCarveStep(which) {
// to the next button would hide the thing the run was for. The "next"
// control unlocks; pressing it stays the viewer's move.
showToast(`✓ carve ${which} — wrote into ${(carveInfo.demo && carveInfo.demo.outLabel) || "the demo copy"}`, true);
+ // #230 M3: the step just wrote a carve manifest. Re-read it rather than
+ // inferring the new state from the response — the file on disk is what
+ // the panel and the card claim to be showing, and after a reload it is
+ // the only thing left.
+ await refreshCarveState();
}
} catch (err) {
carveState.error = { step: which, error: String((err && err.message) || err), remedy: "Is the behold server still running?" };
diff --git a/web/carve-steps.js b/web/carve-steps.js
index 06a506a..6514d1f 100644
--- a/web/carve-steps.js
+++ b/web/carve-steps.js
@@ -280,6 +280,30 @@ export function lintVerdict(lint) {
return { tone: "bad", text: `chant lint exited ${lint.code} — the emitted source doesn't pass yet` };
}
+/**
+ * The carve state manifest for one address, off `/api/project`'s
+ * `carve.state` (#230 M3). Null when nothing has been carved for it — which is
+ * the honest reading before Emit runs, and the reading a fresh clone gives.
+ */
+export function manifestStateFor(carveState, address) {
+ const states = (carveState && carveState.states) || [];
+ return states.find((s) => s.target === address) || null;
+}
+
+/**
+ * A carve's recorded stage as one line + a tone.
+ *
+ * `applied` is the only green: it is the only stage at which ownership has
+ * actually moved. `emitted` and `bridged` are real progress and read as such
+ * (the pending blue, the same tone the card carries), but they are work in
+ * flight, not a graduation — and the line says which, in words, because tone
+ * alone was never allowed to carry a state here.
+ */
+export function manifestStageLine(state) {
+ if (!state) return null;
+ return { tone: state.graduated ? "good" : "pending", text: `${state.target}: ${state.note}` };
+}
+
/**
* The observe verdict as one line + a tone (chant#1647). `observed` with
* EXTERNAL/foreign IS the beat: live, outside every stack, still Terraform's —
@@ -372,6 +396,18 @@ function artifactBlock(a, actions) {
return wrap;
}
+/** A dot + a line, tone reinforcing the words (never replacing them). `good` is
+ * the managed green, `pending` the in-flight blue pinhole paints `accent` cards
+ * with, anything else the degraded red. */
+function toneRow(tone, text, tag) {
+ const row = el("div", "count-row");
+ const dot = el("span", "dot");
+ dot.style.background = tone === "good" ? "var(--managed)" : tone === "pending" ? "var(--pending)" : "var(--degraded)";
+ row.append(dot, el("span", "grow", text));
+ if (tag) row.appendChild(el("span", "tag", tag));
+ return row;
+}
+
function refusalBlock(err) {
const wrap = el("div", "carve-refusal");
wrap.appendChild(el("div", "carve-refusal-title", err.error || "that step didn't run"));
@@ -450,6 +486,16 @@ function renderAdvise(body, ctx) {
row.append(dot, el("span", "grow", meaning), el("span", "tag", String(bands[band] ?? 0)));
body.appendChild(row);
}
+ // #230 M3: what the estate has ALREADY carved, off chant's own state
+ // manifests rather than off this session. Reload the page mid-walkthrough and
+ // this is unchanged, because the manifests on disk are unchanged.
+ const carveState = ctx.carve && ctx.carve.state;
+ if (carveState && carveState.manifests) {
+ body.appendChild(el("h3", null, "carved so far"));
+ body.appendChild(toneRow("good", carveState.progress.label, `${carveState.manifests} manifest(s)`));
+ for (const s of carveState.states) body.appendChild(toneRow(s.graduated ? "good" : "pending", s.target, s.stage));
+ if (carveState.progress.detail) body.appendChild(el("p", "panel-muted", carveState.progress.detail));
+ }
if (ctx.carve && ctx.carve.advisory) body.appendChild(el("p", "panel-muted", ctx.carve.advisory));
if (ctx.demo) {
body.appendChild(
@@ -669,6 +715,32 @@ function renderHandoff(body, state, ctx, actions) {
if (c.note) body.appendChild(el("p", "panel-muted carve-cmd-note", c.note));
}
+ // The graduation step (#230 M3). `chant carve apply` resolves the ownership
+ // marker that makes chant the owner of a live resource, and it composes off
+ // the carve manifest — no --select needed, the emit left the target there.
+ //
+ // It is echoed here for the same reason `terraform state rm` is: this is
+ // where the temptation to add a button lives. There is no apply endpoint on
+ // the server and no apply button here, and neither is a gap waiting to be
+ // filled — behold triggers delegated work, it does not decide when an estate
+ // changes hands. What the manifest RECORDS about an apply is rendered (the
+ // Done step, and the Advise step's "carved so far"); the trigger is a person.
+ const applyState = manifestStateFor(ctx.carve && ctx.carve.state, state.pick && state.pick.node.id);
+ if (applyState) {
+ body.appendChild(el("h3", null, "graduation"));
+ const line = manifestStageLine(applyState);
+ body.appendChild(toneRow(line.tone, line.text));
+ if (!applyState.graduated) {
+ // Its own class, not the runbook's: this row is chant's command, not one
+ // of the runbook's terraform lines, and the two are counted separately.
+ const row = el("div", "prow carve-cmd-row carve-apply-row");
+ row.appendChild(el("code", "grow carve-cmd-text", applyState.applyCommand));
+ row.appendChild(copyButton("copy", applyState.applyCommand, actions));
+ body.appendChild(row);
+ body.appendChild(el("p", "panel-muted carve-cmd-note", (ctx.carve.state.apply && ctx.carve.state.apply.note) || ""));
+ }
+ }
+
const ran = el("button", "act carve-ran", state.handoff ? "✓ marked as run" : "I ran these →");
ran.title = "Marks the handoff done in this walkthrough. behold has not checked — it can't, and won't pretend to.";
ran.addEventListener("click", actions.markHandoff);
@@ -693,6 +765,13 @@ function renderDone(body, state, ctx, actions) {
);
body.appendChild(card);
+ // #230 M3: the durable half of the ending. The card above is the shape of the
+ // walkthrough; this is what chant's manifest actually records — and it is
+ // still here after a restart, when everything in `state` is gone.
+ const recorded = manifestStateFor(ctx.carve && ctx.carve.state, state.pick && state.pick.node.id);
+ const line = manifestStageLine(recorded);
+ if (line) body.appendChild(toneRow(line.tone, line.text, recorded.stage));
+
if (!state.handoff) {
body.appendChild(el("p", "panel-muted", "The handoff commands haven't been marked as run — this is the shape of the ending, not a claim about your estate."));
}
diff --git a/web/carve-steps.test.js b/web/carve-steps.test.js
index b130378..09999d5 100644
--- a/web/carve-steps.test.js
+++ b/web/carve-steps.test.js
@@ -15,6 +15,8 @@ import {
edgeLine,
initialCarveState,
lintVerdict,
+ manifestStageLine,
+ manifestStateFor,
observeSummary,
pickFacts,
runbookCommands,
@@ -277,3 +279,68 @@ describe("observeSummary", () => {
expect(lintVerdict(null)).toBeNull();
});
});
+
+// ---------------------------------------------------------------------------
+// #230 M3 — the manifest-backed state the panel reads off /api/project.
+// ---------------------------------------------------------------------------
+
+const CARVE_STATE = {
+ manifests: 2,
+ progress: {
+ applied: 1,
+ bridged: 0,
+ emitted: 1,
+ inFlight: 1,
+ total: 12,
+ label: "1 of 12 carved",
+ detail: "1 emitted — apply is yours",
+ },
+ states: [
+ {
+ target: "aws_iam_role.api",
+ stage: "emitted",
+ graduated: false,
+ note: "emitted — typed chant source exists, and the surviving Terraform still reads the resource directly. `carve bridge` is next.",
+ applyCommand: "chant carve apply --from legacy-tf --output app/carveout --env --stack --write-source",
+ },
+ {
+ target: "aws_s3_bucket.assets",
+ stage: "applied",
+ graduated: true,
+ note: "graduated — `carve apply` recorded the ownership marker (stack assets, env prod). chant owns it.",
+ applyCommand: "chant carve apply --from legacy-tf --output app/carveout --env prod --stack assets --write-source",
+ },
+ ],
+ apply: { human: true, note: "behold has no apply endpoint and no apply button, by design." },
+};
+
+describe("manifestStateFor", () => {
+ it("finds the carve recorded for an address", () => {
+ expect(manifestStateFor(CARVE_STATE, "aws_s3_bucket.assets").stage).toBe("applied");
+ });
+
+ it("is null for an address nothing has been carved for — the honest read before Emit", () => {
+ expect(manifestStateFor(CARVE_STATE, "aws_vpc.main")).toBeNull();
+ expect(manifestStateFor(null, "aws_s3_bucket.assets")).toBeNull();
+ expect(manifestStateFor(CARVE_STATE, null)).toBeNull();
+ });
+});
+
+describe("manifestStageLine", () => {
+ it("greens only the graduated one — the only stage at which ownership moved", () => {
+ const line = manifestStageLine(manifestStateFor(CARVE_STATE, "aws_s3_bucket.assets"));
+ expect(line.tone).toBe("good");
+ expect(line.text).toContain("chant owns it");
+ });
+
+ it("paints a partial carve as in flight, and says which stage in words", () => {
+ const line = manifestStageLine(manifestStateFor(CARVE_STATE, "aws_iam_role.api"));
+ expect(line.tone).toBe("pending");
+ expect(line.text).toContain("aws_iam_role.api");
+ expect(line.text).toContain("still reads the resource directly");
+ });
+
+ it("is null with nothing recorded — no manifest, no claim", () => {
+ expect(manifestStageLine(null)).toBeNull();
+ });
+});