Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<address>.carve.json` into `carve emit --output`; `carve
bridge` and `carve apply` add their own records to the same file. behold reads
those and never writes one, so the progression is on disk rather than in a
session.

`GET /api/project`'s `carve.state` publishes it: `{manifests, progress:
{applied, bridged, emitted, inFlight, total, label, detail}, states[], apply:
{human, note}}`. Each entry carries `target`, `stage`
(`emitted`/`bridged`/`applied`), `graduated`, `note` and a retypeable
`applyCommand`. The same field appears on an ordinary `behold serve` whose
project directory carries a carveout — no report and no demo needed — and is
absent entirely when nothing has been carved.

In the graph, an `applied` address draws inside the chant member box (keeping
its Terraform address as its node id) instead of in a band; `emitted` and
`bridged` stay banded with `attrs._status: "accent"` and the stage in
`attrs.carve`. Only `applied` means ownership moved.

**There is no `/api/carve/apply`.** `chant carve apply` graduates ownership;
behold renders what the manifest records and echoes the command. Do not add the
endpoint — src/carve-manifest.ts `APPLY_IS_HUMAN` is the statement of it, and
src/carve-actions.ts and src/server.ts both carry the refusal where the route
would go.

### The walkthrough (`behold demo carve`, #254)

`behold demo carve` copies a bundled half-migrated estate (a chant project
Expand Down
24 changes: 24 additions & 0 deletions docs/src/content/docs/using/carve.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 — `<address>.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 <project>` 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).
Expand Down
20 changes: 20 additions & 0 deletions example-carve/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions smoke/stub.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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 = "";
Expand All @@ -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") {
Expand All @@ -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 }),
),
},
});
}
Expand Down
24 changes: 23 additions & 1 deletion smoke/ui-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
66 changes: 66 additions & 0 deletions src/__fixtures__/carve-manifests/aws_iam_role-api.carve.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
98 changes: 98 additions & 0 deletions src/__fixtures__/carve-manifests/aws_s3_bucket-assets.carve.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Loading