cast/src/inventory.ts

202 lines
7.4 KiB
TypeScript
Raw Normal View History

feat: read-side coordinates (#17, #18) + cast inventory (#19) Three fixes at one seam: cast could not READ a box it did not build. #17 — the environment had no read-side coordinate. `--project` exists because a hand-built project is called whatever someone typed. The environment has the identical problem and had no flag, so reading a legacy box forced a choice between mutating that box's UI and renaming OUR environment to match it. The second is what happened: `prod` became `production` across the manifest and environments.yaml — a box being deleted next week naming the environment of the box that replaces it, permanently (apply creates the environment from --env), moving the store to incubator.production.env.age and invalidating every runbook. Reverted. `--environment` is now the coordinate. `--env` stays OURS: manifest block, binding, age key, store path, team assert. `--environment` is theirs, on the wire, and nothing else. #18 — an absent RESOURCE reported as N missing secrets. The D-237 lie, one level deeper. A resource that is absent reads back exactly like one present with no env vars, so capture reported all 15 required names as individually MISSING — from a box that was serving production and sending mail at that moment — and offered --override as the remedy. Taking that offer would have "worked": a valid store, hand-carried values, and the real finding (the manifest and the box disagree about what the app is called) buried. capture now refuses on the resource, names what does exist, and only reports per-name MISSING for resources it actually found — where it means what it says. #19 — cast inventory: see the box before you adopt it. The missing first step. cast could describe a box it built, change one, and take values off one for names a manifest declares — but not tell you what is on a box you did not build, which is the first thing adoption needs. Every mismatch above surfaced as a refusal from a verb already committed to a course of action, and the tempting fix for two of them was to bend the manifest toward the legacy box. inventory reads resources and env var KEYS (never values), sorts them into on-both / manifest-only / box-only, and needs no store, no age key and no recipient — it runs before adoption exists. Its output is a document: inventory → human reads → manifest PR → capture → apply That boundary is what lets capture stay strict. inventory may read everything, because a person reads its output. capture may only write what the manifest declares, because `apply` reads its output. Same box, two consumers, two contracts. A manifest-draft emitter is deliberately NOT included: it would be one `cp` away from becoming desired state, which is the failure this design exists to prevent. Zero drift against a hand-built box is reported as suspicious, not as a pass. npm run check + build clean; 164 tests passing, 18 files (was 151/16). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
import type { ManifestResource } from "./resolve.js";
// `inventory` answers the question every other verb assumes you already
// answered: **what is actually on this box?**
//
// cast can describe a Coolify it built (`diff`), change one (`apply`), and take
// secret values off one for names a manifest already declares (`capture`). None
// of those can tell you what is on a box you did NOT build — and that is the
// first thing anyone needs when adopting an existing deployment. Without it,
// every mismatch surfaces later, one at a time, as a refusal from a verb that is
// already committed to a course of action; and the tempting fix for a refusal is
// to bend the manifest toward the legacy box, which is exactly backwards.
//
// The output is a DOCUMENT, read by a person. It is deliberately not desired
// state, not a store, and not consumed by `apply`:
//
// inventory → human reads → manifest PR → capture → apply
//
// That boundary is what keeps `capture` safe to be strict about. `inventory` may
// read everything, because a person reads its output. `capture` may only ever
// write what the manifest declares, because `apply` reads its output. Same box,
// two consumers, two contracts.
export type LiveResource = {
kind: string;
feat: --resource, the third name a hand-built box does not share with you #20 shipped the refusal without shipping the resolution: capture correctly refuses when a manifest resource does not exist on the source, and then there was no way to say "it's over there, under another name." Found immediately, on the box that motivated it. The manifest says `core`, `landing`, `postgres`, `redis`, `umami`. The box says `Incubator Stack v2`, `Incubator Landing`, `Incubator Database v2`, `Incubator Redis v2`, `Incubator Umami`. Neither is wrong — one names things for a human reading a UI, the other for a machine reading a diff — and neither gets to overwrite the other. --resource core="Incubator Stack v2" (repeatable) Applied at the boundary: live resources are renamed to the manifest's vocabulary once, immediately after the lookup, so computeDiff / classify / reconcile all match by name exactly as before and none of them needs to know a hand-built box was involved. Read-side only, and `apply` refuses it up front — before a clone, a decrypt or a single call. apply CREATES under the manifest's names, so an alias there could only mean "adopt the existing one instead": a different operation nobody has asked for, whose silent failure mode is a duplicate resource created beside the one you were pointing at. diff needed this as much as capture did. Without it, a --full diff against a box whose resources are named differently reports every manifest resource as "to create" and never mentions the live ones — the D-237 lie by another route, a confident full-create plan against a box that has all of it under other names. That diff is the staleness gate of a live migration. Two smaller things, both about not laundering a naming gap into a pass: - inventory, when NOTHING matched and yet the box has resources, now says so and prints the --resource lines to paste. "The box is empty" is exactly the wrong conclusion, and it was the easy one to draw. - the absent-resource refusal prints the same, per absent resource. An alias whose left side names no manifest resource is an error, not a no-op: a typo would otherwise map nothing, leave the real resource looked-up under its own name, and refuse with no hint that the flag had missed. inventory keeps the box's own name beside ours in the report (`core ← "Incubator Stack v2" on the box`) — a document that renamed the box's resources to our vocabulary and never mentioned theirs would be unusable against the UI it describes. npm run check + build clean; 169 tests passing (was 164). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:50:06 +00:00
// The MANIFEST's name for it, once --resource has aliased it. Without an
// alias, whatever the box calls it.
feat: read-side coordinates (#17, #18) + cast inventory (#19) Three fixes at one seam: cast could not READ a box it did not build. #17 — the environment had no read-side coordinate. `--project` exists because a hand-built project is called whatever someone typed. The environment has the identical problem and had no flag, so reading a legacy box forced a choice between mutating that box's UI and renaming OUR environment to match it. The second is what happened: `prod` became `production` across the manifest and environments.yaml — a box being deleted next week naming the environment of the box that replaces it, permanently (apply creates the environment from --env), moving the store to incubator.production.env.age and invalidating every runbook. Reverted. `--environment` is now the coordinate. `--env` stays OURS: manifest block, binding, age key, store path, team assert. `--environment` is theirs, on the wire, and nothing else. #18 — an absent RESOURCE reported as N missing secrets. The D-237 lie, one level deeper. A resource that is absent reads back exactly like one present with no env vars, so capture reported all 15 required names as individually MISSING — from a box that was serving production and sending mail at that moment — and offered --override as the remedy. Taking that offer would have "worked": a valid store, hand-carried values, and the real finding (the manifest and the box disagree about what the app is called) buried. capture now refuses on the resource, names what does exist, and only reports per-name MISSING for resources it actually found — where it means what it says. #19 — cast inventory: see the box before you adopt it. The missing first step. cast could describe a box it built, change one, and take values off one for names a manifest declares — but not tell you what is on a box you did not build, which is the first thing adoption needs. Every mismatch above surfaced as a refusal from a verb already committed to a course of action, and the tempting fix for two of them was to bend the manifest toward the legacy box. inventory reads resources and env var KEYS (never values), sorts them into on-both / manifest-only / box-only, and needs no store, no age key and no recipient — it runs before adoption exists. Its output is a document: inventory → human reads → manifest PR → capture → apply That boundary is what lets capture stay strict. inventory may read everything, because a person reads its output. capture may only write what the manifest declares, because `apply` reads its output. Same box, two consumers, two contracts. A manifest-draft emitter is deliberately NOT included: it would be one `cp` away from becoming desired state, which is the failure this design exists to prevent. Zero drift against a hand-built box is reported as suspicious, not as a pass. npm run check + build clean; 164 tests passing, 18 files (was 151/16). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
name: string;
feat: --resource, the third name a hand-built box does not share with you #20 shipped the refusal without shipping the resolution: capture correctly refuses when a manifest resource does not exist on the source, and then there was no way to say "it's over there, under another name." Found immediately, on the box that motivated it. The manifest says `core`, `landing`, `postgres`, `redis`, `umami`. The box says `Incubator Stack v2`, `Incubator Landing`, `Incubator Database v2`, `Incubator Redis v2`, `Incubator Umami`. Neither is wrong — one names things for a human reading a UI, the other for a machine reading a diff — and neither gets to overwrite the other. --resource core="Incubator Stack v2" (repeatable) Applied at the boundary: live resources are renamed to the manifest's vocabulary once, immediately after the lookup, so computeDiff / classify / reconcile all match by name exactly as before and none of them needs to know a hand-built box was involved. Read-side only, and `apply` refuses it up front — before a clone, a decrypt or a single call. apply CREATES under the manifest's names, so an alias there could only mean "adopt the existing one instead": a different operation nobody has asked for, whose silent failure mode is a duplicate resource created beside the one you were pointing at. diff needed this as much as capture did. Without it, a --full diff against a box whose resources are named differently reports every manifest resource as "to create" and never mentions the live ones — the D-237 lie by another route, a confident full-create plan against a box that has all of it under other names. That diff is the staleness gate of a live migration. Two smaller things, both about not laundering a naming gap into a pass: - inventory, when NOTHING matched and yet the box has resources, now says so and prints the --resource lines to paste. "The box is empty" is exactly the wrong conclusion, and it was the easy one to draw. - the absent-resource refusal prints the same, per absent resource. An alias whose left side names no manifest resource is an error, not a no-op: a typo would otherwise map nothing, leave the real resource looked-up under its own name, and refuse with no hint that the flag had missed. inventory keeps the box's own name beside ours in the report (`core ← "Incubator Stack v2" on the box`) — a document that renamed the box's resources to our vocabulary and never mentioned theirs would be unusable against the UI it describes. npm run check + build clean; 169 tests passing (was 164). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:50:06 +00:00
// What the box calls it, when that differs. Kept, and printed: a document
// that renamed the box's resources to our vocabulary and then never mentioned
// theirs would be unusable against the UI it describes.
sourceName?: string;
feat: read-side coordinates (#17, #18) + cast inventory (#19) Three fixes at one seam: cast could not READ a box it did not build. #17 — the environment had no read-side coordinate. `--project` exists because a hand-built project is called whatever someone typed. The environment has the identical problem and had no flag, so reading a legacy box forced a choice between mutating that box's UI and renaming OUR environment to match it. The second is what happened: `prod` became `production` across the manifest and environments.yaml — a box being deleted next week naming the environment of the box that replaces it, permanently (apply creates the environment from --env), moving the store to incubator.production.env.age and invalidating every runbook. Reverted. `--environment` is now the coordinate. `--env` stays OURS: manifest block, binding, age key, store path, team assert. `--environment` is theirs, on the wire, and nothing else. #18 — an absent RESOURCE reported as N missing secrets. The D-237 lie, one level deeper. A resource that is absent reads back exactly like one present with no env vars, so capture reported all 15 required names as individually MISSING — from a box that was serving production and sending mail at that moment — and offered --override as the remedy. Taking that offer would have "worked": a valid store, hand-carried values, and the real finding (the manifest and the box disagree about what the app is called) buried. capture now refuses on the resource, names what does exist, and only reports per-name MISSING for resources it actually found — where it means what it says. #19 — cast inventory: see the box before you adopt it. The missing first step. cast could describe a box it built, change one, and take values off one for names a manifest declares — but not tell you what is on a box you did not build, which is the first thing adoption needs. Every mismatch above surfaced as a refusal from a verb already committed to a course of action, and the tempting fix for two of them was to bend the manifest toward the legacy box. inventory reads resources and env var KEYS (never values), sorts them into on-both / manifest-only / box-only, and needs no store, no age key and no recipient — it runs before adoption exists. Its output is a document: inventory → human reads → manifest PR → capture → apply That boundary is what lets capture stay strict. inventory may read everything, because a person reads its output. capture may only write what the manifest declares, because `apply` reads its output. Same box, two consumers, two contracts. A manifest-draft emitter is deliberately NOT included: it would be one `cp` away from becoming desired state, which is the failure this design exists to prevent. Zero drift against a hand-built box is reported as suspicious, not as a pass. npm run check + build clean; 164 tests passing, 18 files (was 151/16). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
envKeys: string[];
};
export type Matched = {
kind: string;
name: string;
feat: --resource, the third name a hand-built box does not share with you #20 shipped the refusal without shipping the resolution: capture correctly refuses when a manifest resource does not exist on the source, and then there was no way to say "it's over there, under another name." Found immediately, on the box that motivated it. The manifest says `core`, `landing`, `postgres`, `redis`, `umami`. The box says `Incubator Stack v2`, `Incubator Landing`, `Incubator Database v2`, `Incubator Redis v2`, `Incubator Umami`. Neither is wrong — one names things for a human reading a UI, the other for a machine reading a diff — and neither gets to overwrite the other. --resource core="Incubator Stack v2" (repeatable) Applied at the boundary: live resources are renamed to the manifest's vocabulary once, immediately after the lookup, so computeDiff / classify / reconcile all match by name exactly as before and none of them needs to know a hand-built box was involved. Read-side only, and `apply` refuses it up front — before a clone, a decrypt or a single call. apply CREATES under the manifest's names, so an alias there could only mean "adopt the existing one instead": a different operation nobody has asked for, whose silent failure mode is a duplicate resource created beside the one you were pointing at. diff needed this as much as capture did. Without it, a --full diff against a box whose resources are named differently reports every manifest resource as "to create" and never mentions the live ones — the D-237 lie by another route, a confident full-create plan against a box that has all of it under other names. That diff is the staleness gate of a live migration. Two smaller things, both about not laundering a naming gap into a pass: - inventory, when NOTHING matched and yet the box has resources, now says so and prints the --resource lines to paste. "The box is empty" is exactly the wrong conclusion, and it was the easy one to draw. - the absent-resource refusal prints the same, per absent resource. An alias whose left side names no manifest resource is an error, not a no-op: a typo would otherwise map nothing, leave the real resource looked-up under its own name, and refuse with no hint that the flag had missed. inventory keeps the box's own name beside ours in the report (`core ← "Incubator Stack v2" on the box`) — a document that renamed the box's resources to our vocabulary and never mentioned theirs would be unusable against the UI it describes. npm run check + build clean; 169 tests passing (was 164). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:50:06 +00:00
sourceName?: string;
feat: read-side coordinates (#17, #18) + cast inventory (#19) Three fixes at one seam: cast could not READ a box it did not build. #17 — the environment had no read-side coordinate. `--project` exists because a hand-built project is called whatever someone typed. The environment has the identical problem and had no flag, so reading a legacy box forced a choice between mutating that box's UI and renaming OUR environment to match it. The second is what happened: `prod` became `production` across the manifest and environments.yaml — a box being deleted next week naming the environment of the box that replaces it, permanently (apply creates the environment from --env), moving the store to incubator.production.env.age and invalidating every runbook. Reverted. `--environment` is now the coordinate. `--env` stays OURS: manifest block, binding, age key, store path, team assert. `--environment` is theirs, on the wire, and nothing else. #18 — an absent RESOURCE reported as N missing secrets. The D-237 lie, one level deeper. A resource that is absent reads back exactly like one present with no env vars, so capture reported all 15 required names as individually MISSING — from a box that was serving production and sending mail at that moment — and offered --override as the remedy. Taking that offer would have "worked": a valid store, hand-carried values, and the real finding (the manifest and the box disagree about what the app is called) buried. capture now refuses on the resource, names what does exist, and only reports per-name MISSING for resources it actually found — where it means what it says. #19 — cast inventory: see the box before you adopt it. The missing first step. cast could describe a box it built, change one, and take values off one for names a manifest declares — but not tell you what is on a box you did not build, which is the first thing adoption needs. Every mismatch above surfaced as a refusal from a verb already committed to a course of action, and the tempting fix for two of them was to bend the manifest toward the legacy box. inventory reads resources and env var KEYS (never values), sorts them into on-both / manifest-only / box-only, and needs no store, no age key and no recipient — it runs before adoption exists. Its output is a document: inventory → human reads → manifest PR → capture → apply That boundary is what lets capture stay strict. inventory may read everything, because a person reads its output. capture may only write what the manifest declares, because `apply` reads its output. Same box, two consumers, two contracts. A manifest-draft emitter is deliberately NOT included: it would be one `cp` away from becoming desired state, which is the failure this design exists to prevent. Zero drift against a hand-built box is reported as suspicious, not as a pass. npm run check + build clean; 164 tests passing, 18 files (was 151/16). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
// Declared by the manifest, absent from the box.
manifestOnlyKeys: string[];
// On the box, and the manifest knows nothing about it. Either something the
// manifest must gain, or cruft that must not travel — and only a human can
// say which. That judgment is the whole reason this verb exists.
boxOnlyKeys: string[];
sharedKeys: string[];
};
export type Reconciliation = {
matched: Matched[];
manifestOnly: ManifestResource[];
boxOnly: LiveResource[];
};
const sorted = (xs: Iterable<string>) => [...xs].sort();
export function reconcile(
manifest: ManifestResource[],
live: LiveResource[],
): Reconciliation {
// Matched by NAME, not by kind: a manifest `application` that the box models
// as a `service` is a real and interesting finding, and collapsing it into
// "manifest-only + box-only" would hide the fact that they are the same thing.
const liveByName = new Map(live.map((l) => [l.name, l]));
const matched: Matched[] = [];
const manifestOnly: ManifestResource[] = [];
for (const m of manifest) {
const l = liveByName.get(m.name);
if (!l) {
manifestOnly.push(m);
continue;
}
const boxKeys = new Set(l.envKeys);
const manifestKeys = new Set(m.envKeys);
matched.push({
kind: m.kind === l.kind ? m.kind : `${m.kind} / ${l.kind} on the box`,
name: m.name,
feat: --resource, the third name a hand-built box does not share with you #20 shipped the refusal without shipping the resolution: capture correctly refuses when a manifest resource does not exist on the source, and then there was no way to say "it's over there, under another name." Found immediately, on the box that motivated it. The manifest says `core`, `landing`, `postgres`, `redis`, `umami`. The box says `Incubator Stack v2`, `Incubator Landing`, `Incubator Database v2`, `Incubator Redis v2`, `Incubator Umami`. Neither is wrong — one names things for a human reading a UI, the other for a machine reading a diff — and neither gets to overwrite the other. --resource core="Incubator Stack v2" (repeatable) Applied at the boundary: live resources are renamed to the manifest's vocabulary once, immediately after the lookup, so computeDiff / classify / reconcile all match by name exactly as before and none of them needs to know a hand-built box was involved. Read-side only, and `apply` refuses it up front — before a clone, a decrypt or a single call. apply CREATES under the manifest's names, so an alias there could only mean "adopt the existing one instead": a different operation nobody has asked for, whose silent failure mode is a duplicate resource created beside the one you were pointing at. diff needed this as much as capture did. Without it, a --full diff against a box whose resources are named differently reports every manifest resource as "to create" and never mentions the live ones — the D-237 lie by another route, a confident full-create plan against a box that has all of it under other names. That diff is the staleness gate of a live migration. Two smaller things, both about not laundering a naming gap into a pass: - inventory, when NOTHING matched and yet the box has resources, now says so and prints the --resource lines to paste. "The box is empty" is exactly the wrong conclusion, and it was the easy one to draw. - the absent-resource refusal prints the same, per absent resource. An alias whose left side names no manifest resource is an error, not a no-op: a typo would otherwise map nothing, leave the real resource looked-up under its own name, and refuse with no hint that the flag had missed. inventory keeps the box's own name beside ours in the report (`core ← "Incubator Stack v2" on the box`) — a document that renamed the box's resources to our vocabulary and never mentioned theirs would be unusable against the UI it describes. npm run check + build clean; 169 tests passing (was 164). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:50:06 +00:00
...(l.sourceName ? { sourceName: l.sourceName } : {}),
feat: read-side coordinates (#17, #18) + cast inventory (#19) Three fixes at one seam: cast could not READ a box it did not build. #17 — the environment had no read-side coordinate. `--project` exists because a hand-built project is called whatever someone typed. The environment has the identical problem and had no flag, so reading a legacy box forced a choice between mutating that box's UI and renaming OUR environment to match it. The second is what happened: `prod` became `production` across the manifest and environments.yaml — a box being deleted next week naming the environment of the box that replaces it, permanently (apply creates the environment from --env), moving the store to incubator.production.env.age and invalidating every runbook. Reverted. `--environment` is now the coordinate. `--env` stays OURS: manifest block, binding, age key, store path, team assert. `--environment` is theirs, on the wire, and nothing else. #18 — an absent RESOURCE reported as N missing secrets. The D-237 lie, one level deeper. A resource that is absent reads back exactly like one present with no env vars, so capture reported all 15 required names as individually MISSING — from a box that was serving production and sending mail at that moment — and offered --override as the remedy. Taking that offer would have "worked": a valid store, hand-carried values, and the real finding (the manifest and the box disagree about what the app is called) buried. capture now refuses on the resource, names what does exist, and only reports per-name MISSING for resources it actually found — where it means what it says. #19 — cast inventory: see the box before you adopt it. The missing first step. cast could describe a box it built, change one, and take values off one for names a manifest declares — but not tell you what is on a box you did not build, which is the first thing adoption needs. Every mismatch above surfaced as a refusal from a verb already committed to a course of action, and the tempting fix for two of them was to bend the manifest toward the legacy box. inventory reads resources and env var KEYS (never values), sorts them into on-both / manifest-only / box-only, and needs no store, no age key and no recipient — it runs before adoption exists. Its output is a document: inventory → human reads → manifest PR → capture → apply That boundary is what lets capture stay strict. inventory may read everything, because a person reads its output. capture may only write what the manifest declares, because `apply` reads its output. Same box, two consumers, two contracts. A manifest-draft emitter is deliberately NOT included: it would be one `cp` away from becoming desired state, which is the failure this design exists to prevent. Zero drift against a hand-built box is reported as suspicious, not as a pass. npm run check + build clean; 164 tests passing, 18 files (was 151/16). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
manifestOnlyKeys: sorted(m.envKeys.filter((k) => !boxKeys.has(k))),
boxOnlyKeys: sorted(l.envKeys.filter((k) => !manifestKeys.has(k))),
sharedKeys: sorted(m.envKeys.filter((k) => boxKeys.has(k))),
});
}
const manifestNames = new Set(manifest.map((m) => m.name));
const boxOnly = live.filter((l) => !manifestNames.has(l.name));
return { matched, manifestOnly, boxOnly };
}
// Names and keys. NEVER values — the whole artifact is meant to be read, pasted,
// and committed to a PR discussion, so it must be safe to do all three with.
export function renderInventory(
rec: Reconciliation,
ctx: {
orgRepo: string;
env: string;
instance: string;
project: string;
environment: string;
},
): string {
const lines = [
`inventory — ${ctx.orgRepo} ${ctx.env}`,
"",
` source: instance ${ctx.instance}`,
` project: ${ctx.project}`,
` environment: ${ctx.environment}`,
"",
" Env var KEYS only — no values are read or printed.",
"",
];
const bullet = (kind: string, name: string) =>
` ${kind.padEnd(12)} ${name}`;
lines.push("on the box, and in the manifest");
if (rec.matched.length === 0) {
lines.push(" (nothing matched — see both lists below)");
}
for (const m of rec.matched) {
feat: --resource, the third name a hand-built box does not share with you #20 shipped the refusal without shipping the resolution: capture correctly refuses when a manifest resource does not exist on the source, and then there was no way to say "it's over there, under another name." Found immediately, on the box that motivated it. The manifest says `core`, `landing`, `postgres`, `redis`, `umami`. The box says `Incubator Stack v2`, `Incubator Landing`, `Incubator Database v2`, `Incubator Redis v2`, `Incubator Umami`. Neither is wrong — one names things for a human reading a UI, the other for a machine reading a diff — and neither gets to overwrite the other. --resource core="Incubator Stack v2" (repeatable) Applied at the boundary: live resources are renamed to the manifest's vocabulary once, immediately after the lookup, so computeDiff / classify / reconcile all match by name exactly as before and none of them needs to know a hand-built box was involved. Read-side only, and `apply` refuses it up front — before a clone, a decrypt or a single call. apply CREATES under the manifest's names, so an alias there could only mean "adopt the existing one instead": a different operation nobody has asked for, whose silent failure mode is a duplicate resource created beside the one you were pointing at. diff needed this as much as capture did. Without it, a --full diff against a box whose resources are named differently reports every manifest resource as "to create" and never mentions the live ones — the D-237 lie by another route, a confident full-create plan against a box that has all of it under other names. That diff is the staleness gate of a live migration. Two smaller things, both about not laundering a naming gap into a pass: - inventory, when NOTHING matched and yet the box has resources, now says so and prints the --resource lines to paste. "The box is empty" is exactly the wrong conclusion, and it was the easy one to draw. - the absent-resource refusal prints the same, per absent resource. An alias whose left side names no manifest resource is an error, not a no-op: a typo would otherwise map nothing, leave the real resource looked-up under its own name, and refuse with no hint that the flag had missed. inventory keeps the box's own name beside ours in the report (`core ← "Incubator Stack v2" on the box`) — a document that renamed the box's resources to our vocabulary and never mentioned theirs would be unusable against the UI it describes. npm run check + build clean; 169 tests passing (was 164). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:50:06 +00:00
lines.push(
m.sourceName
? `${bullet(m.kind, m.name)} ← "${m.sourceName}" on the box`
: bullet(m.kind, m.name),
);
feat: read-side coordinates (#17, #18) + cast inventory (#19) Three fixes at one seam: cast could not READ a box it did not build. #17 — the environment had no read-side coordinate. `--project` exists because a hand-built project is called whatever someone typed. The environment has the identical problem and had no flag, so reading a legacy box forced a choice between mutating that box's UI and renaming OUR environment to match it. The second is what happened: `prod` became `production` across the manifest and environments.yaml — a box being deleted next week naming the environment of the box that replaces it, permanently (apply creates the environment from --env), moving the store to incubator.production.env.age and invalidating every runbook. Reverted. `--environment` is now the coordinate. `--env` stays OURS: manifest block, binding, age key, store path, team assert. `--environment` is theirs, on the wire, and nothing else. #18 — an absent RESOURCE reported as N missing secrets. The D-237 lie, one level deeper. A resource that is absent reads back exactly like one present with no env vars, so capture reported all 15 required names as individually MISSING — from a box that was serving production and sending mail at that moment — and offered --override as the remedy. Taking that offer would have "worked": a valid store, hand-carried values, and the real finding (the manifest and the box disagree about what the app is called) buried. capture now refuses on the resource, names what does exist, and only reports per-name MISSING for resources it actually found — where it means what it says. #19 — cast inventory: see the box before you adopt it. The missing first step. cast could describe a box it built, change one, and take values off one for names a manifest declares — but not tell you what is on a box you did not build, which is the first thing adoption needs. Every mismatch above surfaced as a refusal from a verb already committed to a course of action, and the tempting fix for two of them was to bend the manifest toward the legacy box. inventory reads resources and env var KEYS (never values), sorts them into on-both / manifest-only / box-only, and needs no store, no age key and no recipient — it runs before adoption exists. Its output is a document: inventory → human reads → manifest PR → capture → apply That boundary is what lets capture stay strict. inventory may read everything, because a person reads its output. capture may only write what the manifest declares, because `apply` reads its output. Same box, two consumers, two contracts. A manifest-draft emitter is deliberately NOT included: it would be one `cp` away from becoming desired state, which is the failure this design exists to prevent. Zero drift against a hand-built box is reported as suspicious, not as a pass. npm run check + build clean; 164 tests passing, 18 files (was 151/16). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
if (m.sharedKeys.length > 0) {
lines.push(` both: ${m.sharedKeys.join(", ")}`);
}
if (m.manifestOnlyKeys.length > 0) {
lines.push(` manifest only: ${m.manifestOnlyKeys.join(", ")}`);
}
if (m.boxOnlyKeys.length > 0) {
lines.push(` box only: ${m.boxOnlyKeys.join(", ")}`);
}
}
lines.push("", "in the manifest, NOT on the box");
if (rec.manifestOnly.length === 0) lines.push(" (none)");
for (const m of rec.manifestOnly) {
lines.push(bullet(m.kind, m.name));
if (m.envKeys.length > 0) {
lines.push(` declares: ${sorted(m.envKeys).join(", ")}`);
}
}
lines.push("", "on the box, NOT in the manifest");
if (rec.boxOnly.length === 0) lines.push(" (none)");
for (const l of rec.boxOnly) {
lines.push(bullet(l.kind, l.name));
// A resource the manifest has never heard of: EVERY key on it is box-only,
// and they are the most interesting keys in the report — this is where a
// resource that the manifest calls something else shows up, carrying the
// values `capture` went looking for and could not find.
if (l.envKeys.length > 0) {
lines.push(` carries: ${sorted(l.envKeys).join(", ")}`);
}
}
const drift =
rec.manifestOnly.length +
rec.boxOnly.length +
rec.matched.reduce(
(n, m) => n + m.manifestOnlyKeys.length + m.boxOnlyKeys.length,
0,
);
feat: --resource, the third name a hand-built box does not share with you #20 shipped the refusal without shipping the resolution: capture correctly refuses when a manifest resource does not exist on the source, and then there was no way to say "it's over there, under another name." Found immediately, on the box that motivated it. The manifest says `core`, `landing`, `postgres`, `redis`, `umami`. The box says `Incubator Stack v2`, `Incubator Landing`, `Incubator Database v2`, `Incubator Redis v2`, `Incubator Umami`. Neither is wrong — one names things for a human reading a UI, the other for a machine reading a diff — and neither gets to overwrite the other. --resource core="Incubator Stack v2" (repeatable) Applied at the boundary: live resources are renamed to the manifest's vocabulary once, immediately after the lookup, so computeDiff / classify / reconcile all match by name exactly as before and none of them needs to know a hand-built box was involved. Read-side only, and `apply` refuses it up front — before a clone, a decrypt or a single call. apply CREATES under the manifest's names, so an alias there could only mean "adopt the existing one instead": a different operation nobody has asked for, whose silent failure mode is a duplicate resource created beside the one you were pointing at. diff needed this as much as capture did. Without it, a --full diff against a box whose resources are named differently reports every manifest resource as "to create" and never mentions the live ones — the D-237 lie by another route, a confident full-create plan against a box that has all of it under other names. That diff is the staleness gate of a live migration. Two smaller things, both about not laundering a naming gap into a pass: - inventory, when NOTHING matched and yet the box has resources, now says so and prints the --resource lines to paste. "The box is empty" is exactly the wrong conclusion, and it was the easy one to draw. - the absent-resource refusal prints the same, per absent resource. An alias whose left side names no manifest resource is an error, not a no-op: a typo would otherwise map nothing, leave the real resource looked-up under its own name, and refuse with no hint that the flag had missed. inventory keeps the box's own name beside ours in the report (`core ← "Incubator Stack v2" on the box`) — a document that renamed the box's resources to our vocabulary and never mentioned theirs would be unusable against the UI it describes. npm run check + build clean; 169 tests passing (was 164). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:50:06 +00:00
// Nothing matched, yet the box is full of resources: that is a NAMING gap, not
// an empty box — and it is the single most likely thing to be looking at you
// here. Say so, rather than leaving a reader to conclude the box has nothing
// (which is how "create everything" gets laundered into a pass).
if (rec.matched.length === 0 && rec.boxOnly.length > 0) {
lines.push(
"",
"NOTHING matched — and yet this box has resources. That is almost always a",
"naming difference, not an empty box: a box built by hand names things for a",
"human reading a UI, not for a manifest. Map them and re-run:",
"",
...rec.manifestOnly.map(
(m) => ` --resource ${m.name}="<what this box calls it>"`,
),
);
}
feat: read-side coordinates (#17, #18) + cast inventory (#19) Three fixes at one seam: cast could not READ a box it did not build. #17 — the environment had no read-side coordinate. `--project` exists because a hand-built project is called whatever someone typed. The environment has the identical problem and had no flag, so reading a legacy box forced a choice between mutating that box's UI and renaming OUR environment to match it. The second is what happened: `prod` became `production` across the manifest and environments.yaml — a box being deleted next week naming the environment of the box that replaces it, permanently (apply creates the environment from --env), moving the store to incubator.production.env.age and invalidating every runbook. Reverted. `--environment` is now the coordinate. `--env` stays OURS: manifest block, binding, age key, store path, team assert. `--environment` is theirs, on the wire, and nothing else. #18 — an absent RESOURCE reported as N missing secrets. The D-237 lie, one level deeper. A resource that is absent reads back exactly like one present with no env vars, so capture reported all 15 required names as individually MISSING — from a box that was serving production and sending mail at that moment — and offered --override as the remedy. Taking that offer would have "worked": a valid store, hand-carried values, and the real finding (the manifest and the box disagree about what the app is called) buried. capture now refuses on the resource, names what does exist, and only reports per-name MISSING for resources it actually found — where it means what it says. #19 — cast inventory: see the box before you adopt it. The missing first step. cast could describe a box it built, change one, and take values off one for names a manifest declares — but not tell you what is on a box you did not build, which is the first thing adoption needs. Every mismatch above surfaced as a refusal from a verb already committed to a course of action, and the tempting fix for two of them was to bend the manifest toward the legacy box. inventory reads resources and env var KEYS (never values), sorts them into on-both / manifest-only / box-only, and needs no store, no age key and no recipient — it runs before adoption exists. Its output is a document: inventory → human reads → manifest PR → capture → apply That boundary is what lets capture stay strict. inventory may read everything, because a person reads its output. capture may only write what the manifest declares, because `apply` reads its output. Same box, two consumers, two contracts. A manifest-draft emitter is deliberately NOT included: it would be one `cp` away from becoming desired state, which is the failure this design exists to prevent. Zero drift against a hand-built box is reported as suspicious, not as a pass. npm run check + build clean; 164 tests passing, 18 files (was 151/16). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
lines.push(
"",
drift === 0
? "The manifest and this box name the same things. (On a hand-built box, treat"
: `${drift} difference(s) between the manifest and this box.`,
);
if (drift === 0) {
lines.push(
"that with suspicion rather than relief — a box nobody declared agreeing",
"perfectly with a manifest nobody applied is more often a wrong lookup than",
"a true match.)",
);
}
lines.push(
"",
"This is a document, not desired state. Nothing here is read by `apply` — the",
"path from here is: decide what the manifest should GAIN and what is cruft that",
"must not travel, land that as a manifest PR, then `capture` and `apply`.",
);
return lines.join("\n");
}