fix: diff refuses an absent target instead of reporting it as empty (#11, #6) #12

Merged
dan-claude-bot merged 1 commit from fix/slug-keying into main 2026-07-13 14:57:13 +00:00
dan-claude-bot commented 2026-07-13 14:53:15 +00:00 (Migrated from github.com)

Closes #11. Closes #6.

The bug (#11)

diff could not tell "this project does not exist" from "this project is empty" — both came back as [] from fetchLive. That is correct for apply (a first apply legitimately creates the project and its environment) and quietly wrong for diff: computeDiff(desired, []) means every desired resource is missing, which renders as a confident full-create plan.

So a diff aimed at a name that does not exist reported a clean-looking plan that verified nothing.

This is the same shape of lie #10 closed — an unverifiable read that answers absent and invites a create — reached through the project name instead of the team. And it matters more now than it did then: with a single Root Team, every token is stamped team_id = 0, so assertTeam can never fire. It is no longer guarding this class of bug at all.

Two roads, not one

While fixing it, the environment turned out to be the same trap. cast names environments after --env, but a project built by hand in the Coolify UI is named whatever someone typed — and Coolify's own default environment is production, not prod. A real project with a differently-named environment 404s on GET /projects/{uuid}/{env}, which also collapsed to [].

Both are now gated.

What changed

  • fetchLive returns a LiveLookup union. Absence is its own answer, not a value that happens to equal empty. Keeping the distinction in the type is what lets diff and apply take opposite — and both correct — positions on it.
  • diff refuses (exit 2) and names what it looked for, where that name came from, and what exists instead. apply keeps today's tolerant behaviour. That split is the entire point: apply is allowed to be the thing that brings a project into existence; diff is only ever a claim about something that already does.
  • --project <name> overrides the repo-derived project name for an instance that names it differently. It overrides only that — secrets stay keyed by the repo, a state-repo convention we own. Previously repoShort silently did four unrelated jobs.

#6, same root cause

github_apps is now resolved by full <org>/<repo> slug, falling back to a bare <repo> key so existing state files keep working.

A short name is unique only within an org, so two orgs' same-named repos collapsed onto one entry — and whichever App was bound there would be used to clone both, silently, because a wrong-but-existing App still resolves to a real uuid and the create succeeds.

github_apps:
  heavy-duty/incubator: hdb-coolify     # full slug wins
  acme/incubator: acme-coolify          # no longer collides
  incubator: legacy-app                 # bare key still resolves

Note: #6's original motivation is moot — it was written when staging cloned the fork and prod cloned upstream, and D-220/D-221 ended that. The underlying collision is real anyway, gets likelier as projects are added, and shares a root cause with #11, so it is fixed here rather than closed as stale.

Verification

Unit tests pin both halves (12 new; 98 pass, npm run check clean). But the gate is only worth having if the binary refuses, so it was driven end-to-end against a fake Coolify serving box B's shape — resources that exist, under a project name cast would never guess from the repo slug:

A. The project does not exist → refuses, exit 2:

team id=0 name="Root Team" ✓
refusing to diff: no project named "widget" exists in this team

  looked for:  project "widget"  (derived from the repo slug acme/widget)
  exists here: incubator-prod, umami

An absent target reads back exactly like an empty one, so continuing would diff
it as "nothing exists — create everything": a clean-looking report that verified
nothing. `apply` may create a target; `diff` may only ever describe one that is
already there.

Pass --project <name> if this instance names it differently.

B. Same command, pointed at the real name → a real diff, exit 0:

$ cast diff acme/widget --env staging --project incubator-prod
team id=0 name="Root Team" ✓
clean

C. Project exists, environment is production not staging → refuses, exit 2:

refusing to diff: project "incubator-prod" has no environment "staging"

  looked for:  environment "staging" in project "incubator-prod"
  note:        cast names environments after --env, so a project built by
               hand in the Coolify UI may well use a different name for the
               same tier (Coolify's own default is `production`).

Before this change, all three printed a full-create plan and exited 0.

Why this is not a weakening of #10

The team: binding stays required and the assert still runs before the first read on every command. An inert gate is cheap, and it is what catches the first mis-minted token the day a second team exists. This adds the second gate that a single-team topology leaves uncovered — it does not replace the first.

Not in scope

Moving the project name into a bindings-level projects: map (keyed by full slug, alongside github_apps) is probably the better long-term home and is what a multi-project state repo will want. --project is deliberately the smaller step: it is non-breaking, it needs no environments.yaml schema change, and it can stay as the escape hatch afterwards.

🤖 Generated with Claude Code

Closes #11. Closes #6. ## The bug (#11) `diff` could not tell **"this project does not exist"** from **"this project is empty"** — both came back as `[]` from `fetchLive`. That is correct for `apply` (a first apply legitimately *creates* the project and its environment) and quietly wrong for `diff`: `computeDiff(desired, [])` means *every desired resource is missing*, which renders as a confident full-create plan. So a `diff` aimed at a name that does not exist reported a **clean-looking plan that verified nothing**. This is the same shape of lie #10 closed — an unverifiable read that answers *absent* and invites a **create** — reached through the **project name** instead of the **team**. And it matters more now than it did then: with a single Root Team, every token is stamped `team_id = 0`, so `assertTeam` can never fire. It is no longer guarding this class of bug at all. ## Two roads, not one While fixing it, the *environment* turned out to be the same trap. cast names environments after `--env`, but a project built by hand in the Coolify UI is named whatever someone typed — and **Coolify's own default environment is `production`, not `prod`**. A real project with a differently-named environment 404s on `GET /projects/{uuid}/{env}`, which also collapsed to `[]`. Both are now gated. ## What changed - **`fetchLive` returns a `LiveLookup` union.** Absence is its own answer, not a value that happens to equal *empty*. Keeping the distinction in the *type* is what lets `diff` and `apply` take opposite — and both correct — positions on it. - **`diff` refuses** (exit 2) and names what it looked for, where that name came from, and what exists instead. **`apply` keeps today's tolerant behaviour.** That split is the entire point: `apply` is *allowed* to be the thing that brings a project into existence; `diff` is only ever a claim about something that already does. - **`--project <name>`** overrides the repo-derived project name for an instance that names it differently. It overrides *only* that — secrets stay keyed by the repo, a state-repo convention we own. Previously `repoShort` silently did four unrelated jobs. ## #6, same root cause `github_apps` is now resolved by full `<org>/<repo>` slug, falling back to a bare `<repo>` key so **existing state files keep working**. A short name is unique only *within* an org, so two orgs' same-named repos collapsed onto one entry — and whichever App was bound there would be used to clone **both**, silently, because a wrong-but-existing App still resolves to a real uuid and the create succeeds. ```yaml github_apps: heavy-duty/incubator: hdb-coolify # full slug wins acme/incubator: acme-coolify # no longer collides incubator: legacy-app # bare key still resolves ``` > Note: #6's *original* motivation is moot — it was written when staging cloned the fork and prod cloned upstream, and D-220/D-221 ended that. The underlying collision is real anyway, gets likelier as projects are added, and shares a root cause with #11, so it is fixed here rather than closed as stale. ## Verification Unit tests pin both halves (12 new; **98 pass**, `npm run check` clean). But the gate is only worth having if the *binary* refuses, so it was driven end-to-end against a fake Coolify serving box B's shape — resources that exist, under a project name cast would never guess from the repo slug: **A. The project does not exist** → refuses, exit 2: ``` team id=0 name="Root Team" ✓ refusing to diff: no project named "widget" exists in this team looked for: project "widget" (derived from the repo slug acme/widget) exists here: incubator-prod, umami An absent target reads back exactly like an empty one, so continuing would diff it as "nothing exists — create everything": a clean-looking report that verified nothing. `apply` may create a target; `diff` may only ever describe one that is already there. Pass --project <name> if this instance names it differently. ``` **B. Same command, pointed at the real name** → a real diff, exit 0: ``` $ cast diff acme/widget --env staging --project incubator-prod team id=0 name="Root Team" ✓ clean ``` **C. Project exists, environment is `production` not `staging`** → refuses, exit 2: ``` refusing to diff: project "incubator-prod" has no environment "staging" looked for: environment "staging" in project "incubator-prod" note: cast names environments after --env, so a project built by hand in the Coolify UI may well use a different name for the same tier (Coolify's own default is `production`). ``` Before this change, **all three** printed a full-create plan and exited 0. ## Why this is not a weakening of #10 The `team:` binding stays **required** and the assert still runs before the first read on every command. An inert gate is cheap, and it is what catches the first mis-minted token the day a second team exists. This adds the *second* gate that a single-team topology leaves uncovered — it does not replace the first. ## Not in scope Moving the project name into a bindings-level `projects:` map (keyed by full slug, alongside `github_apps`) is probably the better long-term home and is what a multi-project state repo will want. `--project` is deliberately the smaller step: it is non-breaking, it needs no `environments.yaml` schema change, and it can stay as the escape hatch afterwards. 🤖 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#12
No description provided.