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
44 changes: 44 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,50 @@ resources on water park, against 247 and 43 through the symlink. Nothing in the
answer mentions the scratch path (a terraform entity carries `attrs.file`
relative to its root, and no `sourceLoc`).

### A choudoufu member's overlay is ownership; attribute drift is the plan (#404)

**Read a green choudoufu card as "this is ours", never as "nothing changed".**
The overlay is composed from `live-ls -json` and `live-plan -json`, and those
two documents answer one question — bound, unowned, omitted, or invisible to
the listing. Neither of them ever compares an attribute VALUE. A resource whose
tag, path or mutability was changed out of band still carries its markers, so
it is still bound, and it is still green.

The other half of the terminal's answer — "would a plan change anything?" — is
a **separate, opt-in read**: `choudoufu plan -input=false -out=<tmp>` then
`choudoufu show -json <tmp>`, whose `resource_changes[].change.{actions,before,
after}` name the attributes and both their values. (`choudoufu plan -json` is
NOT this: on 0.16.0 it prints choudoufu's own ownership document, the same
shape as `live-plan -json`, with no attribute values in it at all. The
measurement is in src/choudoufu-plan.ts's header.) The plan file is written to
a scratch directory of behold's own and removed after — never inside the served
member, which would be a write into someone's source.

It is opt-in because it costs differently: the ownership read is answered out
of the tagging index and is flat in the estate's size, while `plan` refreshes
every resource — one provider read per card, 301 of them on `terralith-4`. So:

- **Never on an ordinary overlay read.** `GET /api/overlay?plan=1` and
`GET /api/diff?plan=1` are the only things that spawn one, and the palette's
**"Re-check live with plan (attribute drift)"** row is the only thing in the
SPA that sends the flag. The cheap **"Re-check live (refresh drift)"** row is
deliberately left alone — it is `POST /api/refresh`, a primary-only re-observe
that captures a lanes frame and never composes the estate.
- **Cached under the member's source stamp**, the same key half `memberIr`
uses. A read that did NOT ask is served that stored answer, so a reload after
a refresh keeps showing the drift without paying for a second pass; a read
that DID ask always re-plans, because "re-check" means re-check.
- **The ownership verdict stays the card's colour.** A bound card that drifted
is still painted bound and still counted `bound`; it gains `attrs._planDrift`
and wears a dashed `--degraded` edge plus a `~ n attributes` corner glyph
(web/app.js `markDriftedCards`, the same post-render stamp as the carve and
operator marks). A card that is not bound is never marked — an unowned
object's planned `create` is an ownership fact the overlay already paints,
and `planDrift` drops creates for that reason.
- **`/api/overlay`'s meta carries `drift`**: `{read: false}` or `{read: true,
drifted: n}`. The two are different answers — "nobody looked" versus "looked,
nothing drifted" — and the legend prints a count only for the second.

### A choudoufu estate's own references (#393)

`choudoufu live-check -json` states the roster and `references[]`, and that
Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,13 @@ There's a whole catalog — `behold demo --list` names the rest (`behold demo
k8s` stands the same loop up on a throwaway k3d cluster: runtime Pods, field
ownership; `behold demo argo-estate` needs nothing at all — a three-project
Argo CD estate, declared only, so it runs where Docker doesn't; `behold demo
carve` is the Terraform peel walkthrough, below). Every loaded demo lands in
the panel's recents, so switching between them is the Scope tab.
choudoufu-estate` is four tag-owned OpenTofu estates served composed, where a
card's colour is **ownership** — bound, unowned, pending — and not "nothing
changed": a resource whose attributes drifted out of band still carries its
markers and is still green, so attribute drift is a separate read you ask for
with ⌘K → "Re-check live with plan"; `behold demo carve` is the Terraform peel
walkthrough, below). Every loaded demo lands in the panel's recents, so
switching between them is the Scope tab.

Working in a checkout rather than an npm install? There is a second catalog,
`workbench.json`, which is this checkout's and is not shipped — it is
Expand Down
2 changes: 1 addition & 1 deletion demos.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@
},
{
"name": "choudoufu-estate",
"description": "A tag-owned estate: four choudoufu estates (an OpenTofu fork whose ownership is two AWS tags) served composed \u2014 the terralith applied to a scratch floci, three team estates whose declared resources are the monolith's (every card `owned by` it, dashed to its card), a cross-estate data-source edge, and a move plan whose `live-mv` lines you run by hand while behold previews with -dry-run and reads the receipt. Docker + choudoufu (0.16.0+, or a build from main) on PATH; provider download on first init. scripts/choudoufu-down.sh removes the container.",
"description": "A tag-owned estate: four choudoufu estates (an OpenTofu fork whose ownership is two AWS tags) served composed \u2014 the terralith applied to a scratch floci, three team estates whose declared resources are the monolith's (every card `owned by` it, dashed to its card), a cross-estate data-source edge, and a move plan whose `live-mv` lines you run by hand while behold previews with -dry-run and reads the receipt. A card's colour is ownership, not \"nothing changed\": attribute drift is a separate plan read, opt-in through \u2318K \u2192 \"Re-check live with plan\". Docker + choudoufu (0.16.0+, or a build from main) on PATH; provider download on first init. scripts/choudoufu-down.sh removes the container.",
"source": "bundled",
"dir": "example-choudoufu-estate",
"requires": [
Expand Down
50 changes: 48 additions & 2 deletions smoke/stub.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,15 @@ const CHOUDOUFU_IR = {
edges: [],
};
const CHOUDOUFU_VOCABULARY = { of: "choudoufu", labels: { good: "bound", warn: "unowned", accent: "pending", neutral: "not observed", runtime: "runtime child" } };
// #404: the attribute drift a plan would revert, on the BOUND card — the case
// the ownership half cannot see, because both markers are intact on either
// side of the change. Served only for `?plan=1`, so the stub reproduces the
// opt-in: an ordinary overlay read gets `{read: false}` and no mark at all.
const CHOUDOUFU_PLAN_DRIFT = { actions: ["update"], attributes: ["tags"] };
const CHOUDOUFU_PLAN_CHANGES = [
{ path: "tags", oldValue: { drifted: "out-of-band", "tofu-estate": "terralith-4" }, newValue: { "tofu-estate": "terralith-4" } },
{ path: "image_tag_mutability", oldValue: "IMMUTABLE", newValue: "MUTABLE" },
];
const CHOUDOUFU_PROJECT = {
projectDir: "/estates/terralith-4",
recents: [],
Expand Down Expand Up @@ -673,6 +682,13 @@ export function startStub(port, { carve = false, nonChant = false, choudoufu = f
// layer without a project on disk. `server.layout` lets the test read and
// seed it as if it were the file.
const layout = new Map();
// #404: the server's plan cache, modelled. `?plan=1` reads a plan and stores
// it under the member's source stamp; every read AFTER that one is served
// from the store without re-planning, which is why the SPA's `/api/diff`
// fetch carries no `plan=1` of its own and still gets the attributes. A stub
// that only answered the flagged request would make the client look broken
// where the server is doing exactly what it was asked to.
let choudoufuPlanned = false;
const carvePosts = [];
/** #284 item 2: what the pending gate card's Approve button actually sent —
* so the smoke asserts the wire contract (the EXISTING op-signal route), not
Expand Down Expand Up @@ -823,16 +839,46 @@ export function startStub(port, { carve = false, nonChant = false, choudoufu = f
};
if (path === "/api/project") return json(CHOUDOUFU_PROJECT);
if (path === "/api/graph" || path === "/api/overlay") {
// #404: the plan read is opt-in, so the mark and the count exist only
// on the request that asked for them.
if (url.searchParams.get("plan") === "1") choudoufuPlanned = true;
const planned = choudoufuPlanned;
const ir = {
...CHOUDOUFU_IR,
nodes: CHOUDOUFU_IR.nodes.map((n) =>
planned && n.id === "terralith-4/aws_ecs_cluster.main" ? { ...n, attrs: { ...n.attrs, _planDrift: CHOUDOUFU_PLAN_DRIFT } } : n,
),
};
return json({
ir: CHOUDOUFU_IR,
ir,
svg: CHOUDOUFU_SVG,
meta: { projectDir: CHOUDOUFU_PROJECT.projectDir, env: "live", tier: null, target: null, estate: 1, mode: "overlay", vocabulary: CHOUDOUFU_VOCABULARY },
meta: {
projectDir: CHOUDOUFU_PROJECT.projectDir,
env: "live",
tier: null,
target: null,
estate: 1,
mode: "overlay",
vocabulary: CHOUDOUFU_VOCABULARY,
drift: planned ? { read: true, drifted: 1 } : { read: false },
},
});
}
if (path === "/api/diff") {
if (url.searchParams.get("plan") === "1") choudoufuPlanned = true;
const planned = choudoufuPlanned;
return json({
env: "live",
nodes: {
"terralith-4/aws_ecs_cluster.main": {
observed: { type: "aws_ecs_cluster", physicalId: "terralith-4-main", ownership: "owned", attributes: { bound: CHOUDOUFU_IR.nodes[0].attrs.bound } },
// The ownership verdict is unchanged by the drift — `healthy`
// either way, which is the whole of #404's second bullet.
diff: planned ? { category: "planned", changes: CHOUDOUFU_PLAN_CHANGES } : null,
health: "healthy",
healthDetail: CHOUDOUFU_IR.nodes[0].attrs.bound,
fieldDrift: null,
},
"terralith-4/aws_cloudwatch_log_group.extra": {
observed: {
type: "aws_cloudwatch_log_group",
Expand Down
38 changes: 38 additions & 0 deletions smoke/ui-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1198,6 +1198,44 @@ try {
await chdfPage.waitForTimeout(100);
check("the copy button confirms", (await copy.innerText()).includes("copied"));

// ---- #404: attribute drift, opt-in, as a SECOND signal ----------------
// Before the plan is read the estate looks exactly as #404 reported it:
// every card bound and green while a plan would change one of them.
check("no card is marked drifted before the plan is read", (await chdfPage.locator("#graph [data-node-id].plan-drifted").count()) === 0);
check("the statusbar says nothing about drift", !meta.includes("drifted"));
check("the legend says the question was not asked, and prints no count", legend.includes("attribute drift not read") && !/drifted \(/.test(legend));

// The palette's second row reads the plan.
await chdfPage.keyboard.press("Meta+k");
await chdfPage.waitForTimeout(200);
await chdfPage.keyboard.type("Re-check live with plan");
await chdfPage.waitForTimeout(200);
await chdfPage.keyboard.press("Enter");
await chdfPage.waitForSelector("#graph [data-node-id].plan-drifted", { timeout: 20000 });

const marked = chdfPage.locator('#graph [data-node-id="terralith-4/aws_ecs_cluster.main"]');
check("the drifted card wears the mark", (await marked.locator('[data-plan-drift="1"]').count()) === 1);
// textContent, not innerText: the mark is an SVG <text>, not an HTMLElement.
check("…as a count, so a long attribute name cannot run off the card", (await marked.locator('[data-plan-drift="1"]').textContent()).trim() === "~ 1 attribute");
// The whole of #404's second bullet: ownership is still the card's colour.
check("…and the card is still painted bound, not demoted", (await chdfPage.locator("#graph [data-node-id].plan-drifted").count()) === 1);
const meta2 = await chdfPage.locator("#meta").innerText();
check("the statusbar counts the drift beside the ownership counts", meta2.includes("1 bound") && meta2.includes("1 drifted"));

await chdfPage.click('#panel-tabs button[data-tab="model"]');
await chdfPage.waitForTimeout(100);
const legend2 = await chdfPage.locator("#tab-model").innerText();
check("the legend gains a drifted row with its count", /drifted \(the plan would change it\)/.test(legend2) && !legend2.includes("attribute drift not read"));

// The pane names the attributes, before → after.
await chdfPage.click('[data-node-id="terralith-4/aws_ecs_cluster.main"]');
await chdfPage.waitForTimeout(400);
const driftPane = await chdfPage.locator("#inspect-body").innerText();
check("the inspect pane says what the plan would do", driftPane.includes("the plan would change this"));
check("…names the changed attributes", driftPane.includes("tags") && driftPane.includes("image_tag_mutability"));
check("…and prints a scalar as before → after", driftPane.includes('"IMMUTABLE" → "MUTABLE"'));
check("…while the card's health stays healthy", driftPane.includes("healthy"));

check("no console errors on a choudoufu estate", chdfErrors.length === 0);
if (chdfErrors.length) console.error("choudoufu page errors:", chdfErrors);
await chdfPage.screenshot({ path: join(SHOTS, "10-choudoufu-vocabulary.png") });
Expand Down
1 change: 1 addition & 0 deletions src/__fixtures__/choudoufu-plan-iam-ecr-clean.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/__fixtures__/choudoufu-plan-iam-ecr-create.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/__fixtures__/choudoufu-plan-iam-ecr-drift.json

Large diffs are not rendered by default.

37 changes: 37 additions & 0 deletions src/choudoufu-live.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,43 @@ describe("choudoufuDiffNodes — the pane's live state (#370)", () => {
// Unprefixed on a single-member serve.
expect(Object.keys(choudoufuDiffNodes(doc, listing, plan("choudoufu-live-plan-monolith-clean.json")))).toContain("aws_iam_role.team_a");
});

// #404: the attribute half, when a plan was read for this member.
it("carries the plan's changed attributes as the pane's `diff`, before → after", () => {
const doc = check("choudoufu-live-check-monolith.json");
const listing = ls("choudoufu-live-ls-monolith.json");
const clean = plan("choudoufu-live-plan-monolith-clean.json");
const drift = new Map([
["aws_iam_role.team_a", { actions: ["update"], changes: [{ path: "tags", oldValue: { extra: "1" }, newValue: {} }] }],
]);
const nodes = choudoufuDiffNodes(doc, listing, clean, "mono", drift);
expect(nodes["mono/aws_iam_role.team_a"]).toMatchObject({
// Still BOUND: attribute drift is a second signal, never a demotion —
// the health the pane prints is the ownership verdict, unchanged.
health: "healthy",
diff: { category: "planned", changes: [{ path: "tags", oldValue: { extra: "1" }, newValue: {} }] },
// The k8s managed-fields question, which a Terraform plan never answers.
fieldDrift: null,
});
// An address the plan did not change keeps the null it always had.
expect(nodes["mono/aws_iam_policy.team_a"].diff).toBeNull();
// And with no plan read at all, nothing anywhere carries a diff.
const none = choudoufuDiffNodes(doc, listing, clean, "mono");
expect(Object.values(none).every((n) => n.diff === null)).toBe(true);
});

it("never shows plan drift on a card that is not bound", () => {
const doc = check("choudoufu-live-check-monolith.json");
doc.instances.push({ address: "aws_cloudwatch_log_group.extra", type: "aws_cloudwatch_log_group", rung: "tag-governable" });
const unowned = plan("choudoufu-live-plan-monolith-unowned-and-renamed.json");
const drift = new Map([["aws_cloudwatch_log_group.extra", { actions: ["update"], changes: [{ path: "retention_in_days", oldValue: 1, newValue: 7 }] }]]);
const nodes = choudoufuDiffNodes(doc, ls("choudoufu-live-ls-monolith.json"), unowned, "mono", drift);
// UNOWNED is the answer this card owes a reader; an attribute diff beside
// it would be behold arguing with its own overlay (`paintPlanDrift` skips
// the same card, so the two can never disagree).
expect(nodes["mono/aws_cloudwatch_log_group.extra"].health).toBe("degraded");
expect(nodes["mono/aws_cloudwatch_log_group.extra"].diff).toBeNull();
});
});

describe("projectChoudoufuLogical — the estate box (#370)", () => {
Expand Down
Loading