The bootstrap is two-pass and cast has no pass 2 — nothing can fill a generated secret after apply creates it #48

Closed
opened 2026-07-14 20:54:43 +00:00 by dan-claude-bot · 1 comment
dan-claude-bot commented 2026-07-14 20:54:43 +00:00 (Migrated from github.com)

The gap

The bootstrap of a project with generated_secrets is two-pass by construction:

  1. apply creates the database; Coolify generates the real DATABASE_URL.
  2. …the store must learn that value, or it stays a lie forever.

cast has no pass 2. capture cannot do it — a name in generated_secrets is placeheld with the pending-coolify-generated literal by design (capture.ts:130-137), and re-running capture after the apply placeholds it again, with no flag to say otherwise (semantics.md:509). draft does the same (draft.ts:672). Both are right to: they are pre-apply verbs, and copying a source box's connection URL into a new box's store is exactly the silent, unrecoverable failure their name-pattern rule exists to prevent (draft.ts:73-75).

But nothing takes the post-apply path, so the operator does. This is what pass 2 looks like today, out of the heavy-duty/incubator runbook — a hand age re-encrypt, against a store holding 14 names, with the prod key in a process substitution and the plaintext kept off disk by hand:

curl -s -H "Authorization: Bearer $COOLIFY_ACCESS_TOKEN" \
  "$COOLIFY_BASE_URL/api/v1/databases" | jq -r '.[] | "\(.name)\t\(.internal_db_url)"'
# …then: decrypt, drop the two placeholder lines, append the real ones, re-encrypt to the
#        environment's age_recipient, assert 14 names / zero placeholders, commit upstream.

Every step of that is a step cast already knows how to do, and it is being done by a human, against production, holding a decryption key, with a jq filter that must not pick the third row (umami's bundled Postgres is not ours). A mis-step here is not a typo, it is prod's credentials.

Why it matters beyond ergonomics

Until pass 2 runs, the store's value for DATABASE_URL is a placeholder while the live value is real — which is precisely the state that makes the next routine apply overwrite a working secret (#47). So this is not a nice-to-have: the absence of pass 2 is what leaves the gun loaded. #47 stops the shot; this issue removes the gun. Neither is sufficient alone — a refusal an operator cannot act on except by hand-crafting ciphertext is a refusal they will eventually route around.

And it is on the DR path: "rebuild the control plane from state" means apply-from-nothing, which means every generated secret in every store is a placeholder again, which means this hand-dance repeated per environment under pressure.

Suggested shape

cast capture <org>/<repo> --env <env> --generated-only [--state <dir>]

Same verb, same ceremony, same store-writing code path — the flag simply inverts capture's disposition rule: names in generated_secrets are the ones it fills, everything else is left exactly as the store has it.

  • Reads the value from the resource that owns it, not from an app's env. A generated URL never appears on the consuming application's env — it lives on the database (internal_db_url on GET /databases, which the runbook's curl above proves the live API returns; the vendored 4.1.2 spec documents the route's body as "Content is very complex. Will be implemented later.", so as with #46 the spec's silence is not evidence of absence).
  • Resolves the database inside the project + environment cast is applying — never instance-wide. GET /databases returns every database on the box, including other projects' and umami's bundled one; picking by name across that list is the #29 bug wearing a different hat.
  • Refuses to overwrite a non-placeholder store value unless told to (--force), so it cannot silently rotate a secret an operator set by hand.
  • Prints a disposition table, names onlycapture.ts:166's rule holds: the only value-shaped thing printed is the placeholder literal it is replacing.
  • Asserts the postcondition it is for: after the write, zero pending-coolify-generated remain in the store for that environment, and the name count is unchanged. That assertion is currently a line in a human runbook.

Worth considering, and deliberately not proposed here: making apply do this automatically after a create. It would close the window entirely — but it makes the verb that mutates Coolify also mutate the encrypted store and hence the git repo, which is a much bigger blast radius for a verb people run on a schedule. A separate, explicit, operator-run verb is the right first step; automating it is a decision for after this has been used a few times.

  • #47apply overwrites a live generated secret with the placeholder (the guard; this is the fix that makes it actionable).
  • The live instance and the full hand-procedure: heavy-duty/incubator#10.
## The gap The bootstrap of a project with `generated_secrets` is **two-pass by construction**: 1. `apply` creates the database; Coolify generates the real `DATABASE_URL`. 2. …the store must learn that value, or it stays a lie forever. **cast has no pass 2.** `capture` cannot do it — a name in `generated_secrets` is placeheld with the `pending-coolify-generated` literal *by design* (`capture.ts:130-137`), and re-running `capture` after the apply placeholds it **again**, with no flag to say otherwise (`semantics.md:509`). `draft` does the same (`draft.ts:672`). Both are right to: they are pre-apply verbs, and copying a *source* box's connection URL into a *new* box's store is exactly the silent, unrecoverable failure their name-pattern rule exists to prevent (`draft.ts:73-75`). But nothing takes the *post-apply* path, so the operator does. This is what pass 2 looks like today, out of the `heavy-duty/incubator` runbook — a hand `age` re-encrypt, against a store holding 14 names, with the prod key in a process substitution and the plaintext kept off disk by hand: ```sh curl -s -H "Authorization: Bearer $COOLIFY_ACCESS_TOKEN" \ "$COOLIFY_BASE_URL/api/v1/databases" | jq -r '.[] | "\(.name)\t\(.internal_db_url)"' # …then: decrypt, drop the two placeholder lines, append the real ones, re-encrypt to the # environment's age_recipient, assert 14 names / zero placeholders, commit upstream. ``` Every step of that is a step cast already knows how to do, and it is being done by a human, against production, holding a decryption key, with a `jq` filter that must not pick the **third** row (umami's bundled Postgres is not ours). A mis-step here is not a typo, it is prod's credentials. ## Why it matters beyond ergonomics Until pass 2 runs, the store's value for `DATABASE_URL` is a placeholder while the live value is real — which is precisely the state that makes the next routine `apply` overwrite a working secret (#47). So this is not a nice-to-have: **the absence of pass 2 is what leaves the gun loaded.** #47 stops the shot; this issue removes the gun. Neither is sufficient alone — a refusal an operator cannot act on except by hand-crafting ciphertext is a refusal they will eventually route around. And it is on the DR path: *"rebuild the control plane from state"* means apply-from-nothing, which means every generated secret in every store is a placeholder again, which means this hand-dance repeated per environment under pressure. ## Suggested shape ```sh cast capture <org>/<repo> --env <env> --generated-only [--state <dir>] ``` Same verb, same ceremony, same store-writing code path — the flag simply **inverts** capture's disposition rule: names in `generated_secrets` are the ones it fills, everything else is left exactly as the store has it. - **Reads the value from the resource that owns it, not from an app's env.** A generated URL never appears on the consuming application's env — it lives on the **database** (`internal_db_url` on `GET /databases`, which the runbook's curl above proves the live API returns; the vendored 4.1.2 spec documents the route's body as *"Content is very complex. Will be implemented later."*, so as with #46 the spec's silence is not evidence of absence). - **Resolves the database inside the project + environment cast is applying** — never instance-wide. `GET /databases` returns every database on the box, including other projects' and umami's bundled one; picking by name across that list is the #29 bug wearing a different hat. - **Refuses to overwrite a non-placeholder store value** unless told to (`--force`), so it cannot silently rotate a secret an operator set by hand. - **Prints a disposition table, names only** — `capture.ts:166`'s rule holds: the only value-shaped thing printed is the placeholder literal it is replacing. - **Asserts the postcondition it is for:** after the write, zero `pending-coolify-generated` remain in the store for that environment, and the name count is unchanged. That assertion is currently a line in a human runbook. Worth considering, and deliberately *not* proposed here: making `apply` do this automatically after a create. It would close the window entirely — but it makes the verb that mutates Coolify also mutate the encrypted store and hence the git repo, which is a much bigger blast radius for a verb people run on a schedule. A separate, explicit, operator-run verb is the right first step; automating it is a decision for after this has been used a few times. ## Related - #47 — `apply` overwrites a live generated secret with the placeholder (the guard; this is the fix that makes it actionable). - The live instance and the full hand-procedure: [heavy-duty/incubator#10](https://github.com/heavy-duty/incubator/pull/10).
dan-claude-bot commented 2026-07-14 22:35:26 +00:00 (Migrated from github.com)

Superseded in approach by #60.

This issue asks for a pass 2 — a verb that fills the generated secret into the store after apply creates it. #60 argues there should be no pass 2: DATABASE_URL is not a secret anyone authored, it is a fact about a resource cast itself created and can ask for at any time. Derive it from the declared database instead of storing a copy of it.

That deletes the hand-age dance quoted in this issue rather than automating it, and takes the two names out of the store entirely (incubator prod: 14 → 12). The DR argument here gets stronger, not weaker — a from-nothing rebuild resolves the URL live instead of needing a second attended pass per environment.

Leaving this open as the problem statement; #60 is the proposed resolution.

Superseded in approach by #60. This issue asks for a **pass 2** — a verb that fills the generated secret into the store after `apply` creates it. #60 argues there should be no pass 2: `DATABASE_URL` is not a secret anyone authored, it is a fact about a resource cast itself created and can ask for at any time. Derive it from the declared database instead of storing a copy of it. That deletes the hand-`age` dance quoted in this issue rather than automating it, and takes the two names out of the store entirely (incubator prod: 14 → 12). The DR argument here gets stronger, not weaker — a from-nothing rebuild resolves the URL live instead of needing a second attended pass per environment. Leaving this open as the problem statement; #60 is the proposed resolution.
Sign in to join this conversation.
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#48
No description provided.