read-side coordinates (#17, #18) + cast inventory (#19) #20

Merged
dan-claude-bot merged 1 commit from feat/inventory-and-read-side-coordinates into main 2026-07-13 18:22:30 +00:00
dan-claude-bot commented 2026-07-13 18:21:38 +00:00 (Migrated from github.com)

Closes #17, closes #18, closes #19.

Three fixes at one seam: cast could not read a box it did not build. All
three came out of one attended session pointing cast at a live, hand-built
Coolify for the first time — and each one failed differently, one at a time,
after the previous had been worked around.

#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, because that is what the box called it — a box that is
being deleted next week, naming the environment of the box that replaces it,
permanently
(apply creates the Coolify environment from --env). It also
silently moved the store to secrets/incubator.production.env.age and
invalidated every --env prod in three repos' runbooks. It was reverted, and
this is the flag that means nobody has to make that trade again.

The split the fix rests on:

--env ours — the manifest block, the environments.yaml binding, the age key, the store path, the team to assert
--environment theirs — the name on the wire, and nothing else

Absent, it defaults to --env, so nothing changes for boxes cast built. The
absent-environment refusal now names --environment rather than telling you to
"re-run with --env naming the environment as it exists here" — which was the
sentence that invited the rename.

#18 — an absent RESOURCE reported as N missing secrets

The #12/D-237 lie, one level deeper in the tree, and it fails the same way: a
resource that is absent reads back exactly like one that is present with no
env vars set.

So capture, pointed at the right project and the right environment, reported
all 15 required names as individually MISSING — from a box that was serving
production and sending mail through Mailgun at that moment — and offered
--override as the remedy.

Taking that offer would have "worked". The operator hand-supplies fifteen
secrets through $CAST_CAPTURE_*, capture writes a perfectly valid store, and
the actual finding — the manifest and the box disagree about what the app is
called
— is never discovered. The tool would have talked the operator into
hand-carrying the exact data it exists to stop them hand-carrying, and buried the
real problem underneath a clean result.

capture now refuses on the resource, names what does exist, and reports
per-name MISSING only for resources it actually found — where it means what it
says.

#19cast inventory: see the box before you adopt it

The missing first step. cast could describe a box it built (diff),
change one (apply), and take values off one for names a manifest already
declares (capture) — but it could not tell you what is on a box you did not
build
, which is the first thing adoption needs.

That blindness is why the three failures above arrived as a sequence of surprises
rather than as one document, and why the tempting fix for two of them was to bend
the manifest toward the legacy box.

cast inventory heavy-duty/incubator --env prod --instance legacy \
  --project Incubator --environment production
inventory — heavy-duty/incubator prod

  source:       instance box-b
  project:      Incubator
  environment:  production

  Env var KEYS only — no values are read or printed.

on the box, and in the manifest
    service      umami
      both:          APP_SECRET, DATABASE_URL
      box only:      FORCE_SSL

in the manifest, NOT on the box
    application  core
      declares:      ADMIN_EMAIL, DATABASE_URL, MAILGUN_API_KEY, NODE_ENV, REDIS_URL, REPORTING_ENABLED
    database     postgres

on the box, NOT in the manifest
    application  incubator-stack
      carries:       ADMIN_EMAIL, DATABASE_URL, LEFTOVER_FROM_2019, MAILGUN_API_KEY, NODE_ENV, REDIS_URL, SOURCE_COMMIT
    application  landing

5 difference(s) between the manifest and this box.

This is a document, not desired state. […]

That output is the finding #18 refuses on, stated in a form a human can act on:
core and incubator-stack carry the same keys. It's a rename — plus two
box-only leftovers to decide about.

It needs no store, no age key, and no recipient: it runs before adoption
exists, which is the point of it. A read token is enough.

The boundary that keeps capture strict

inventory → human reads → manifest PR → capture → apply

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.

That is also why there is no manifest-draft emitter, though it was the
obvious next feature and was explicitly asked about. A YAML draft that Coolify's
own live state generated is one cp away from becoming desired state — and a box
that nobody declared becoming the spec is the precise failure this whole design
exists to prevent. The report is prose on purpose.

Last detail, in the same spirit as the existing sanity floor: zero drift against
a hand-built box is reported as suspicious, not as a pass.

For the reviewer

  1. --environment also applies to apply, not just the read verbs. This is
    deliberate: it is what lets an apply adopt an existing hand-named
    environment rather than create a second one beside it. It changes only the
    name on the wire.
  2. #18's guard is on capture, not diff. For diff, a resource the box
    lacks is legitimate drift and reporting it as a create is correct. For
    capture it is a lookup failure wearing the costume of a content finding.
  3. resolveCheckout's --path/--env prod ban applies to inventory too. It
    reads a manifest, and a feature branch must not get to decide what prod's
    adoption sees.

Checks

npm run check (biome), npm run build (tsc), npm test164 passing, 18
files
(was 151/16). The new e2e suite runs the real CLI against a stub Coolify
shaped like the actual box: project Incubator, environment production,
application incubator-stack.

🤖 Generated with Claude Code

Closes #17, closes #18, closes #19. Three fixes at one seam: **cast could not read a box it did not build.** All three came out of one attended session pointing cast at a live, hand-built Coolify for the first time — and each one failed differently, one at a time, after the previous had been worked around. ## #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`, because that is what the box called it — **a box that is being deleted next week, naming the environment of the box that replaces it, permanently** (`apply` creates the Coolify environment from `--env`). It also silently moved the store to `secrets/incubator.production.env.age` and invalidated every `--env prod` in three repos' runbooks. It was reverted, and this is the flag that means nobody has to make that trade again. The split the fix rests on: | | | | --- | --- | | **`--env`** | **ours** — the manifest block, the `environments.yaml` binding, the age key, the store path, the team to assert | | **`--environment`** | **theirs** — the name on the wire, and nothing else | Absent, it defaults to `--env`, so nothing changes for boxes cast built. The absent-environment refusal now names `--environment` rather than telling you to "re-run with `--env` naming the environment as it exists here" — which was the sentence that invited the rename. ## #18 — an absent RESOURCE reported as N missing secrets The #12/D-237 lie, one level deeper in the tree, and it fails the same way: a resource that is **absent** reads back exactly like one that is **present with no env vars set.** So `capture`, pointed at the right project and the right environment, reported **all 15 required names as individually MISSING** — from a box that was serving production and sending mail through Mailgun at that moment — and offered `--override` as the remedy. **Taking that offer would have "worked".** The operator hand-supplies fifteen secrets through `$CAST_CAPTURE_*`, capture writes a perfectly valid store, and the actual finding — *the manifest and the box disagree about what the app is called* — is never discovered. The tool would have talked the operator into hand-carrying the exact data it exists to stop them hand-carrying, and buried the real problem underneath a clean result. `capture` now refuses on the **resource**, names what does exist, and reports per-name `MISSING` only 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 (`diff`), **change** one (`apply`), and **take values** off one for names a manifest already declares (`capture`) — but it could not tell you **what is on a box you did not build**, which is the first thing adoption needs. That blindness is why the three failures above arrived as a sequence of surprises rather than as one document, and why the tempting fix for two of them was to bend the manifest toward the legacy box. ```sh cast inventory heavy-duty/incubator --env prod --instance legacy \ --project Incubator --environment production ``` ``` inventory — heavy-duty/incubator prod source: instance box-b project: Incubator environment: production Env var KEYS only — no values are read or printed. on the box, and in the manifest service umami both: APP_SECRET, DATABASE_URL box only: FORCE_SSL in the manifest, NOT on the box application core declares: ADMIN_EMAIL, DATABASE_URL, MAILGUN_API_KEY, NODE_ENV, REDIS_URL, REPORTING_ENABLED database postgres on the box, NOT in the manifest application incubator-stack carries: ADMIN_EMAIL, DATABASE_URL, LEFTOVER_FROM_2019, MAILGUN_API_KEY, NODE_ENV, REDIS_URL, SOURCE_COMMIT application landing 5 difference(s) between the manifest and this box. This is a document, not desired state. […] ``` That output *is* the finding #18 refuses on, stated in a form a human can act on: `core` and `incubator-stack` carry the same keys. It's a rename — plus two box-only leftovers to decide about. It needs **no store, no age key, and no recipient**: it runs *before* adoption exists, which is the point of it. A read token is enough. ### The boundary that keeps `capture` strict ``` inventory → human reads → manifest PR → capture → apply ``` **`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. That is also why there is **no manifest-draft emitter**, though it was the obvious next feature and was explicitly asked about. A YAML draft that Coolify's own live state generated is one `cp` away from becoming desired state — and a box that nobody declared becoming the spec is the precise failure this whole design exists to prevent. The report is prose on purpose. Last detail, in the same spirit as the existing sanity floor: **zero drift against a hand-built box is reported as suspicious, not as a pass.** ## For the reviewer 1. **`--environment` also applies to `apply`**, not just the read verbs. This is deliberate: it is what lets an apply *adopt* an existing hand-named environment rather than create a second one beside it. It changes only the name on the wire. 2. **#18's guard is on `capture`, not `diff`.** For `diff`, a resource the box lacks is legitimate drift and reporting it as a create is correct. For `capture` it is a lookup failure wearing the costume of a content finding. 3. `resolveCheckout`'s `--path`/`--env prod` ban applies to `inventory` too. It reads a manifest, and a feature branch must not get to decide what prod's adoption sees. ## Checks `npm run check` (biome), `npm run build` (tsc), `npm test` — **164 passing, 18 files** (was 151/16). The new e2e suite runs the real CLI against a stub Coolify shaped like the actual box: project `Incubator`, environment `production`, application `incubator-stack`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/cast#20
No description provided.