fix(diff): Coolify's own generated vars are not orphans (#87) #88

Merged
dan-claude-bot merged 1 commit from fix/generated-vars-not-orphans into main 2026-07-16 17:01:08 +00:00
dan-claude-bot commented 2026-07-16 16:57:22 +00:00 (Migrated from github.com)

Fixes #87. With #86 merged, this is the last thing standing between a correct box and a clean diff.

The problem

A prod box with zero drift reported 2 change(s) — sixteen lines of live-only (orphan var — apply never removes), every one a var Coolify minted: SERVICE_FQDN_API for a compose app's per-container domains, SERVICE_PASSWORD_POSTGRES/POSTGRES_* for the one-click umami service's bundled datastore.

remove-candidate means "a live-only var the manifest does not declare; apply never removes it; read it by eye." For a name cast did not put there, cannot declare in any vocabulary, and will never remove, that's a category error. And a report that can never say clean is how an operator learns to skim it — #78's own Impact section made the argument: "an operator who learns these always show change stops trusting the diff." #78 then sat misdiagnosed for exactly that long.

The fix

cast already held this judgmentdraft.ts has used it since #27 to refuse copying these into a draft. diffEnv just never asked. So the vocabulary moves to reserved.ts, which already owns "names whose meaning belongs to the PLATFORM", and both callers consult it.

The two families stay distinct, because the danger runs opposite ways:

danger cast's response
reserved (SOURCE_COMMIT, COOLIFY_*) manifest → box: declaring one suppresses the platform's value refuse the manifest
generated (SERVICE_FQDN_*, …) box → report: a live one is not an orphan don't call it a remove-candidate

Two widths, deliberately

Over-matching is safe in a draft and unsafe in a diff. A future reader who "unifies" these will silently blind the diff, so reserved.ts says so at the definition:

  • draft — WIDE. Over-matching withholds a value for review (loud, recoverable). Under-matching copies the source box's DATABASE_URL into a new box that boots against the old box's database.
  • diff, applications — NARROW. Over-matching hides a live-only var. A hand-left DATABASE_URL still pointing at a box nobody declares is the single orphan most worth printing — and it matches the wide rule. Not theoretical: probed against prod, the wide bucket on a real application held exactly DATABASE_URL and REDIS_URL — both of them cast's own declared vars.
  • diff, services — WIDE. A Coolify service is a vendored bundle cast doesn't model: type + service_domains + an env_template is the entire vocabulary; the rest is the template's.

A real gap the tests found

The pair-rule missed POSTGRES_DB outright[POSTGRES, DB] is datastore + datastore, with no connection word. A db name is a connection coordinate like any other (host, port, user, password, dbname), so DB joins that set. It's precisely the var a one-click service mints for its bundled Postgres, and the wide rule silently didn't cover it.

Also: correcting #79's record

LiveEnvVar's comment still cited "a stored column Coolify does not recompute on an in-place PATCH — so it goes stale". Falsereal_value is an appended accessor recomputed from value on every read, and it tracks value on every row of a real box. The split #79 made is still right (real_value is an escaped rendering'true' is not true), but its motivation was wrong; the drift it chased was #85's preview shadow.

Tests

  • An application carrying only SERVICE_* reads clean (the exact prod shape).
  • A hand-left DATABASE_URL on an application is still reported — the narrow rule's whole purpose.
  • A service carrying the one-click template's wiring reads clean.
  • A non-generated live-only var on a service (LEGACY_FLAG) is still reported — width must not become "services report nothing".

npm run check clean · 528 tests pass (npm run build && npm test, as CI runs).

Expected effect on the prod box: all 16 lines go, and cast diff --env prod reads clean for the first time.

🤖 Generated with Claude Code

Fixes #87. With #86 merged, this is the last thing standing between a correct box and a clean diff. ## The problem A prod box with **zero drift** reported `2 change(s)` — sixteen lines of `live-only (orphan var — apply never removes)`, every one a var **Coolify minted**: `SERVICE_FQDN_API` for a compose app's per-container domains, `SERVICE_PASSWORD_POSTGRES`/`POSTGRES_*` for the one-click umami service's bundled datastore. `remove-candidate` means *"a live-only var the manifest does not declare; apply never removes it; read it by eye."* For a name cast **did not put there, cannot declare in any vocabulary, and will never remove**, that's a category error. And a report that can never say *clean* is how an operator learns to skim it — #78's own Impact section made the argument: *"an operator who learns these always show change stops trusting the diff."* #78 then sat misdiagnosed for exactly that long. ## The fix cast **already held this judgment** — `draft.ts` has used it since #27 to refuse copying these into a draft. `diffEnv` just never asked. So the vocabulary moves to `reserved.ts`, which already owns *"names whose meaning belongs to the PLATFORM"*, and both callers consult it. The two families stay distinct, because the danger runs opposite ways: | | danger | cast's response | |---|---|---| | **reserved** (`SOURCE_COMMIT`, `COOLIFY_*`) | manifest → box: declaring one **suppresses** the platform's value | refuse the manifest | | **generated** (`SERVICE_FQDN_*`, …) | box → report: a live one is **not an orphan** | don't call it a remove-candidate | ## Two widths, deliberately Over-matching is **safe in a draft** and **unsafe in a diff**. A future reader who "unifies" these will silently blind the diff, so `reserved.ts` says so at the definition: - **draft — WIDE.** Over-matching withholds a *value* for review (loud, recoverable). Under-matching copies the source box's `DATABASE_URL` into a new box that boots against the **old box's database**. - **diff, applications — NARROW.** Over-matching **hides a live-only var**. A hand-left `DATABASE_URL` still pointing at a box nobody declares is the single orphan most worth printing — and it matches the wide rule. **Not theoretical:** probed against prod, the wide bucket on a real application held exactly `DATABASE_URL` and `REDIS_URL` — both of them cast's *own declared vars*. - **diff, services — WIDE.** A Coolify service is a vendored bundle cast doesn't model: `type` + `service_domains` + an env_template is the entire vocabulary; the rest is the template's. ## A real gap the tests found The pair-rule **missed `POSTGRES_DB` outright** — `[POSTGRES, DB]` is datastore + datastore, with no connection word. A db **name** is a connection coordinate like any other (host, port, user, password, **dbname**), so `DB` joins that set. It's precisely the var a one-click service mints for its bundled Postgres, and the wide rule silently didn't cover it. ## Also: correcting #79's record `LiveEnvVar`'s comment still cited *"a stored column Coolify does not recompute on an in-place PATCH — so it goes stale"*. **False** — `real_value` is an appended accessor recomputed from `value` on every read, and it tracks `value` on every row of a real box. The split #79 made is still right (`real_value` is an **escaped rendering** — `'true'` is not `true`), but its motivation was wrong; the drift it chased was #85's preview shadow. ## Tests - An application carrying only `SERVICE_*` reads **clean** (the exact prod shape). - A hand-left `DATABASE_URL` on an application is **still reported** — the narrow rule's whole purpose. - A service carrying the one-click template's wiring reads **clean**. - A non-generated live-only var on a service (`LEGACY_FLAG`) is **still reported** — width must not become "services report nothing". `npm run check` clean · **528 tests pass** (`npm run build && npm test`, as CI runs). > Expected effect on the prod box: all 16 lines go, and `cast diff --env prod` reads clean for the first time. 🤖 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#88
No description provided.