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
14 changes: 14 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,20 @@ graph.
3. **inspect** — a node's `sourceLoc.file` is the typed source that declared it;
edit there to change the estate (chant is the source of truth, not behold).

## The carve loop (Terraform → chant, #230)

`behold carve <report.json>` serves a `chant carve advise --json` peelability
report instead of a chant project. Same SPA, same `/api/graph` shape; `attrs.
_status` carries the band (`good` = carve now, `warn` = has boundary work,
`neutral` = leave in Terraform) and `attrs` carries the score arithmetic
(`score`, `arithmetic`, `inbound`, `outbound`, `tier`, `mapsTo`).
`GET /api/carve` returns the raw report verbatim.

To move a resource: confirm its band on `/api/carve`, then run chant's own
`carve emit --state` and `carve bridge` in the project. `terraform state rm` and
applying the generated survivor rewrites stay a human gate — behold has no
endpoint that writes Terraform, and adding one would break the invariant below.

## The act loop (delegated, never direct)

behold does not apply. To change the estate:
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,26 @@ behold shells the **project's own** chant (resolved from the project's
`@intentius/chant ^0.18.1` or later for the live overlay (`graph --live` observed
nothing before that fix).

## Terraform carve-out: `behold carve <report.json>`

`chant carve advise` ranks a Terraform estate by **peelability** — how cleanly
each resource could be carved into native chant source. `behold carve` draws
that ranking: one card per resource, three panels (carve now / boundary work /
leave in Terraform) on the same `attrs._status` drift palette every other view
uses, and the score arithmetic behind each rank in the inspect pane.

```sh
chant carve advise --from ./terraform --report carve.json
behold carve carve.json # → http://localhost:4600
curl localhost:4600/api/carve # the raw report, for agents
```

behold parses no HCL and needs no Terraform tooling: the report **is** the
contract. A file that isn't a peelability report is refused with a structured
`{error, code: "carve-report", remedy}` — in the terminal, and from the routes —
never a blank graph. See `docs/using/carve` and issue #230 for the roadmap
(the post-emit diff, then Terraform as an estate member).

## Configuration — `.behold.json`

An optional `.behold.json` in the served project's root is **behold's own**
Expand Down
1 change: 1 addition & 0 deletions docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export default defineConfig({
{ label: 'Reading the graph', slug: 'using/reading-the-graph' },
{ label: 'Acting on it', slug: 'using/acting' },
{ label: 'Export a snapshot', slug: 'using/export' },
{ label: 'Look at a Terraform carve-out', slug: 'using/carve' },
{ label: 'Driving it from an agent', slug: 'using/agents' },
],
},
Expand Down
42 changes: 42 additions & 0 deletions docs/src/content/docs/using/carve.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
title: Look at a Terraform carve-out
description: Render a chant peelability report as a graph — what is cheap to move off Terraform, and what it would cost.
---

`chant carve advise` reads a Terraform estate and ranks each resource by **peelability**: how cleanly it could be carved into native chant source. `behold carve` draws that ranking.

```sh
chant carve advise --from ./terraform --report carve.json
npx behold carve carve.json
```

Read-only twice over. chant's advisor emits no chant source, patches no Terraform and makes no cloud calls; behold only draws what the report says. behold parses no HCL at all and needs no Terraform tooling installed — the report is the whole contract between the two.

## What you see

One card per ranked resource or module, in three panels:

- **carve now** (green) — score 80-100. A clean leaf with a 1:1 native mapping and little or no boundary work.
- **boundary work** (amber) — score 50-79. Carvable, but survivors depend on it, so carving means patching the surviving Terraform.
- **leave in Terraform** (grey) — score 0-49. Unmappable, or too load-bearing to be worth it.

The bands ride the same `attrs._status` channel the live drift overlay paints, so the colours mean what they always mean here: green is the good outcome, amber is work, grey is "not a claim".

## The arithmetic behind a rank

Click a card. The inspect pane shows the score spelled out — `100 - 12x3 inbound = 64` — alongside the counts it came from: inbound edges (a Terraform `data`-source patch each, the expensive ones), outbound edges (a deferred deploy-time input each, cheap), the native tier the resource maps to, whether `count`/`for_each` is in play, and the instance count.

Terraform sub-resources that inline into a parent — `aws_s3_bucket_versioning` into its bucket — are not ranked separately. They carve with the parent, so they never appear as their own card.

## For agents

```sh
curl localhost:4600/api/carve # the raw report, verbatim
curl localhost:4600/api/graph # the same thing as {ir, svg, meta}
```

A file that isn't a peelability report gets a structured `{error, code: "carve-report", remedy}` — in the terminal before the server starts, and from the routes if it goes bad later. It never renders as a blank graph.

## What is not here yet

Today's `carve advise --json` reports per-resource boundary **counts**, not the edge lists behind them, so no boundary edges are drawn and the graph says so on its status line. chant#1636 is the ask to publish the lists; behold already draws them (labelled `inbound`/`outbound`, with the survivors that would need a data-source patch named in the inspect pane) the moment a report carries them.
179 changes: 179 additions & 0 deletions src/__fixtures__/carve-sample-estate.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
{
"from": "sample-estate",
"advisory": "read-only — emits nothing, patches nothing, touches no live resource",
"count": 8,
"bands": {
"clean leaf": 6,
"carvable w/ edits": 1,
"leave in Terraform": 1
},
"resources": [
{
"address": "aws_cloudwatch_log_group.api",
"kind": "resource",
"score": 100,
"band": "clean leaf",
"mapsTo": "AWS::Logs::LogGroup",
"breakdown": {
"inbound": 0,
"outbound": 0,
"tier": 1,
"hasDynamic": false,
"instances": 1,
"penalties": {
"inbound": 0,
"outbound": 0,
"tier": 0,
"dynamic": 0,
"instances": 0
}
}
},
{
"address": "aws_subnet.a",
"kind": "resource",
"score": 96,
"band": "clean leaf",
"mapsTo": "AWS::EC2::Subnet",
"breakdown": {
"inbound": 0,
"outbound": 1,
"tier": 1,
"hasDynamic": false,
"instances": 1,
"penalties": {
"inbound": 0,
"outbound": -4,
"tier": 0,
"dynamic": 0,
"instances": 0
}
}
},
{
"address": "aws_subnet.b",
"kind": "resource",
"score": 96,
"band": "clean leaf",
"mapsTo": "AWS::EC2::Subnet",
"breakdown": {
"inbound": 0,
"outbound": 1,
"tier": 1,
"hasDynamic": false,
"instances": 1,
"penalties": {
"inbound": 0,
"outbound": -4,
"tier": 0,
"dynamic": 0,
"instances": 0
}
}
},
{
"address": "aws_subnet.c",
"kind": "resource",
"score": 96,
"band": "clean leaf",
"mapsTo": "AWS::EC2::Subnet",
"breakdown": {
"inbound": 0,
"outbound": 1,
"tier": 1,
"hasDynamic": false,
"instances": 1,
"penalties": {
"inbound": 0,
"outbound": -4,
"tier": 0,
"dynamic": 0,
"instances": 0
}
}
},
{
"address": "aws_s3_bucket.assets",
"kind": "resource",
"score": 88,
"band": "clean leaf",
"mapsTo": "AWS::S3::Bucket",
"breakdown": {
"inbound": 1,
"outbound": 0,
"tier": 1,
"hasDynamic": false,
"instances": 1,
"penalties": {
"inbound": -12,
"outbound": 0,
"tier": 0,
"dynamic": 0,
"instances": 0
}
}
},
{
"address": "aws_lambda_function.api",
"kind": "resource",
"score": 81,
"band": "clean leaf",
"mapsTo": "AWS::Lambda::Function",
"breakdown": {
"inbound": 0,
"outbound": 1,
"tier": 2,
"hasDynamic": false,
"instances": 1,
"penalties": {
"inbound": 0,
"outbound": -4,
"tier": -15,
"dynamic": 0,
"instances": 0
}
}
},
{
"address": "aws_vpc.main",
"kind": "resource",
"score": 64,
"band": "carvable w/ edits",
"mapsTo": "AWS::EC2::VPC",
"breakdown": {
"inbound": 3,
"outbound": 0,
"tier": 1,
"hasDynamic": false,
"instances": 1,
"penalties": {
"inbound": -36,
"outbound": 0,
"tier": 0,
"dynamic": 0,
"instances": 0
}
}
},
{
"address": "random_pet.suffix",
"kind": "resource",
"score": 0,
"band": "leave in Terraform",
"breakdown": {
"inbound": 0,
"outbound": 0,
"tier": null,
"hasDynamic": false,
"instances": 1,
"penalties": {
"inbound": 0,
"outbound": 0,
"tier": 0,
"dynamic": 0,
"instances": 0
}
}
}
]
}
Loading
Loading