fix: a manifest with no ${…} refs applies without a store (#104) #106

Merged
dan-claude-bot merged 1 commit from fix/greenfield-empty-store into main 2026-07-19 13:08:27 +00:00
dan-claude-bot commented 2026-07-19 12:26:40 +00:00 (Migrated from github.com)

Fixes #104.

This is option 2 from the issue (semantic, loud) — the maintainer may well prefer option 1 (a --bootstrap flag) or option 3 (capture --init); this PR is the working proposal, happy to pivot.

What

The greenfield manifest-first bootstrap was a chicken-and-egg with no exit, measured in the 2026-07-19 release drill against two fresh Coolify 4.1.2 instances: registered project, a manifest declaring databases only and resolving zero ${…} refs, and no path to the first apply.

  • cast applyno secret store for <org>/<repo> in <env> — an unconditional existsSync(store) check in runProject (src/cli.ts), before anything looked at whether the store would ever be read.
  • cast capture → rightly refuses a project that is absent on the box (capture describes what exists) — but apply is the verb that would bring it into existence.

The drill unblocked with printf '' | age -r <recipient> -o secrets/<repo>.<env>.env.age, documented nowhere.

How

The refusal in the shared diff/apply path (runProject) is now gated on the manifest actually referencing a secret, asked via requiredSecrets — the same template parser resolution itself uses, so the gate and the resolver cannot disagree about what "no refs" means. No second parser.

  • Store present → decrypted exactly as before, key demanded as before. No change.
  • Store absent, ≥1 ${…} ref → the original refusal, byte-identical, before any key resolution — never a raw "missing key X" error.
  • Store absent, zero refs → proceeds on {}, printing a loud one-line NOTE: naming the path the store would live at and saying it was not needed. Since there is nothing to decrypt, keyFileFor is never called — no store, no age key is the whole greenfield win. The store appears the first time capture writes it or a template gains a placeholder.

capture and destroy keep their own store handling untouched. README's "The bootstrap is two-pass" section gains the greenfield paragraph; CHANGELOG gets a ### Fixed entry.

Tests

New test/greenfield-cli.test.ts (end to end, stub Coolify, spawned CLI):

  • zero-refs manifest + absent store → proceeds to the full create plan, prints the note, and is spawned with no key in the environment at all (no CAST_AGE_KEY_FILE_STAGING, empty HOME), so any key demand would fail the run;
  • the same environment the moment a template gains one ${…} ref → the original refusal, word for word, same exit code;
  • zero-refs + present store → still decrypts (key injected as before), same plan, no note.

test/fleet-cli.test.ts: the missing-store-is-UNREACHABLE test now plants a ${…} ref in the affected repo (the refusal it exercises is gated on one), plus a new fleet test that a zero-refs project with no store is read, not failed.

npm run check, npm run build, npm test: 600/600 green.

🤖 Generated with Claude Code

Fixes #104. This is option 2 from the issue (semantic, loud) — the maintainer may well prefer option 1 (a `--bootstrap` flag) or option 3 (`capture --init`); this PR is the working proposal, happy to pivot. ## What The greenfield manifest-first bootstrap was a chicken-and-egg with no exit, measured in the 2026-07-19 release drill against two fresh Coolify 4.1.2 instances: registered project, a manifest declaring databases only and resolving **zero `${…}` refs**, and no path to the first apply. - `cast apply` → `no secret store for <org>/<repo> in <env>` — an unconditional `existsSync(store)` check in `runProject` (src/cli.ts), before anything looked at whether the store would ever be read. - `cast capture` → rightly refuses a project that is absent on the box (capture describes what exists) — but apply is the verb that would bring it into existence. The drill unblocked with `printf '' | age -r <recipient> -o secrets/<repo>.<env>.env.age`, documented nowhere. ## How The refusal in the shared diff/apply path (`runProject`) is now gated on the manifest actually **referencing** a secret, asked via `requiredSecrets` — the same template parser resolution itself uses, so the gate and the resolver cannot disagree about what "no refs" means. No second parser. - **Store present** → decrypted exactly as before, key demanded as before. No change. - **Store absent, ≥1 `${…}` ref** → the original refusal, byte-identical, before any key resolution — never a raw "missing key X" error. - **Store absent, zero refs** → proceeds on `{}`, printing a loud one-line `NOTE:` naming the path the store would live at and saying it was not needed. Since there is nothing to decrypt, `keyFileFor` is never called — **no store, no age key** is the whole greenfield win. The store appears the first time capture writes it or a template gains a placeholder. `capture` and `destroy` keep their own store handling untouched. README's "The bootstrap is two-pass" section gains the greenfield paragraph; CHANGELOG gets a `### Fixed` entry. ## Tests New `test/greenfield-cli.test.ts` (end to end, stub Coolify, spawned CLI): - zero-refs manifest + absent store → proceeds to the full create plan, prints the note, and is spawned with **no key in the environment at all** (no `CAST_AGE_KEY_FILE_STAGING`, empty `HOME`), so any key demand would fail the run; - the same environment the moment a template gains one `${…}` ref → the original refusal, word for word, same exit code; - zero-refs + **present** store → still decrypts (key injected as before), same plan, no note. `test/fleet-cli.test.ts`: the missing-store-is-UNREACHABLE test now plants a `${…}` ref in the affected repo (the refusal it exercises is gated on one), plus a new fleet test that a zero-refs project with no store is read, not failed. `npm run check`, `npm run build`, `npm test`: 600/600 green. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
danmt (Migrated from github.com) reviewed 2026-07-19 12:26:40 +00:00
claude-bot-andresmgsl (Migrated from github.com) reviewed 2026-07-19 12:39:06 +00:00
claude-bot-andresmgsl (Migrated from github.com) left a comment

Reviewed — I agree with all of this, no concerns.

Gating the missing-store refusal on requiredSecrets(...).required.length > 0 — the same resolution the resolver uses — is the correct way to keep the gate and the resolver from disagreeing about what "no refs" means; no second parser. The three-way branch reads cleanly: store present → decrypt exactly as before; absent + ≥1 ref → the byte-identical refusal before any key resolution; absent + zero refs → {} with the loud NOTE and keyFileFor never called. Tests are thorough — the greenfield run spawned with no key in the env at all (so any key demand fails the run), the refusal returning word-for-word the moment a template gains a ref, and the fleet --all path both refusing on a ref'd missing store and reading a zero-ref one. The fleet-fixture update (planting a ${…} ref where the UNREACHABLE test needs one, since the refusal is now gated on one) is correct.

automated review by claude-bot-andresmgsl · heavy-duty-review-bot

✅ **Reviewed — I agree with all of this, no concerns.** Gating the missing-store refusal on `requiredSecrets(...).required.length > 0` — the same resolution the resolver uses — is the correct way to keep the gate and the resolver from disagreeing about what "no refs" means; no second parser. The three-way branch reads cleanly: store present → decrypt exactly as before; absent + ≥1 ref → the byte-identical refusal *before* any key resolution; absent + zero refs → `{}` with the loud NOTE and `keyFileFor` never called. Tests are thorough — the greenfield run spawned with no key in the env at all (so any key demand fails the run), the refusal returning word-for-word the moment a template gains a ref, and the fleet `--all` path both refusing on a ref'd missing store and *reading* a zero-ref one. The fleet-fixture update (planting a `${…}` ref where the UNREACHABLE test needs one, since the refusal is now gated on one) is correct. — _automated review by `claude-bot-andresmgsl` · heavy-duty-review-bot_
claude-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-19 12:45:12 +00:00
claude-bot-andresmgsl (Migrated from github.com) left a comment

Approve — the gate reuses the resolver's own parse (requiredSecretsparseTemplate, the same grammar resolveTemplate reads), so it cannot classify "no refs" differently than resolution does; the store-present path is untouched (src/cli.ts:1176-1177 is the old decrypt call verbatim, same keyFileFor timing), and I verified the full suite (600/600, biome clean) plus hand-run edge probes.

What I checked beyond the diff:

  • Edge syntax: the grammar has no escapes — a ref counts only when the whole RHS is exactly ${UPPER_SNAKE} (src/envtemplate.ts:97), and both the gate and the resolver read that through one parseTemplate, so embedded ${X} inside a longer value is a literal for both. Probed: DSN=postgres://u:${PASS}@h/db with no store proceeds and writes the literal through, exactly as it would with a store. Consistent by construction.
  • Non-secret refs: a template holding only ${resource:…} / ${domain:…} refs also passes the gate with no store — correct, since those resolve from the live box / manifest, never the store (probed both).
  • Fleet path: refIn fixture change is right — the UNREACHABLE test's refusal is now gated on a ref, so the fixture must plant one; the new zero-refs --all test pins the read-not-failed behavior.

Two small things, neither blocking:

  1. src/cli.ts:1191 (nit): the NOTE says "templates hold no ${…} refs", but the gate really means no store-secret refs — a resource/domain-ref-only template (or an embedded-${X} literal) prints a NOTE that is literally false while doing the right thing. Suggest "no ${…} secret refs" or "nothing the store would resolve".
  2. src/cli.ts:1178 (observation): requiredSecrets runs before the refusal, so missing-store + broken-manifest (missing template file, dead generated_secrets) now surfaces the manifest error instead of the refusal — "byte-identical" holds only for valid manifests. Arguably the better diagnosis; just noting the claim is slightly stronger than the code.

Follow-up worth filing: a greenfield manifest whose only secrets are generated_secrets still has required.length > 0 and hits the capture chicken-and-egg — out of #104's zero-refs scope, but the same drill would trip on it.

automated review by claude-bot-andresmgsl · heavy-duty-review-bot

**Approve** — the gate reuses the resolver's own parse (`requiredSecrets` → `parseTemplate`, the same grammar `resolveTemplate` reads), so it cannot classify "no refs" differently than resolution does; the store-present path is untouched (`src/cli.ts:1176-1177` is the old decrypt call verbatim, same `keyFileFor` timing), and I verified the full suite (600/600, biome clean) plus hand-run edge probes. What I checked beyond the diff: - **Edge syntax**: the grammar has no escapes — a ref counts only when the whole RHS is exactly `${UPPER_SNAKE}` (`src/envtemplate.ts:97`), and both the gate and the resolver read that through one `parseTemplate`, so embedded `${X}` inside a longer value is a literal for both. Probed: `DSN=postgres://u:${PASS}@h/db` with no store proceeds and writes the literal through, exactly as it would with a store. Consistent by construction. - **Non-secret refs**: a template holding only `${resource:…}` / `${domain:…}` refs also passes the gate with no store — correct, since those resolve from the live box / manifest, never the store (probed both). - **Fleet path**: `refIn` fixture change is right — the UNREACHABLE test's refusal is now gated on a ref, so the fixture must plant one; the new zero-refs `--all` test pins the read-not-failed behavior. Two small things, neither blocking: 1. `src/cli.ts:1191` (nit): the NOTE says "templates hold no `${…}` refs", but the gate really means no *store-secret* refs — a resource/domain-ref-only template (or an embedded-`${X}` literal) prints a NOTE that is literally false while doing the right thing. Suggest "no `${…}` secret refs" or "nothing the store would resolve". 2. `src/cli.ts:1178` (observation): `requiredSecrets` runs before the refusal, so missing-store + broken-manifest (missing template file, dead `generated_secrets`) now surfaces the manifest error instead of the refusal — "byte-identical" holds only for valid manifests. Arguably the better diagnosis; just noting the claim is slightly stronger than the code. Follow-up worth filing: a greenfield manifest whose only secrets are `generated_secrets` still has `required.length > 0` and hits the capture chicken-and-egg — out of #104's zero-refs scope, but the same drill would trip on it. — _automated review by `claude-bot-andresmgsl` · heavy-duty-review-bot_
grok-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-19 12:49:35 +00:00
grok-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: Approve — I agree with this as-is.

Why

  • The chicken-and-egg is real: apply demanded a store, capture correctly refuses a project that does not exist yet, and the only escape was an undocumented hand-rolled empty age file. Gating the refusal on whether anything would be read is the right semantic.
  • Gate reuses requiredSecrets(...).required.length — same parser path as resolution (templateRefs / parseTemplate). Resource and domain refs never enter required, so a databases-only or ${resource:…}-only / ${domain:…}-only manifest is treated as store-free correctly; store secrets still refuse byte-identically when the store is missing.
  • Absent store + zero refs → {}, loud NOTE: naming the path, and no keyFileFor call — the greenfield win is complete (no store, no age key).
  • Present store still decrypts as before (covered). capture / destroy untouched.
  • Tests are the right shape: greenfield CLI e2e with key deliberately absent from the spawn env; one-ref refusal word-for-word; fleet UNREACHABLE still pinned once a ref exists; zero-refs missing store is READ not failed.

Notes (non-blocking)

  • Labels reconcile job on the latest run was cancelled, not a product failure; build/install green.
  • Option-2 (semantic) is the better default than a --bootstrap flag for this failure mode; the NOTE keeps the operator aware the store path still exists.

No blockers.

**Verdict: Approve** — I agree with this as-is. ### Why - The chicken-and-egg is real: `apply` demanded a store, `capture` correctly refuses a project that does not exist yet, and the only escape was an undocumented hand-rolled empty age file. Gating the refusal on whether anything would be *read* is the right semantic. - Gate reuses `requiredSecrets(...).required.length` — same parser path as resolution (`templateRefs` / `parseTemplate`). Resource and domain refs never enter `required`, so a databases-only or `${resource:…}`-only / `${domain:…}`-only manifest is treated as store-free correctly; store secrets still refuse byte-identically when the store is missing. - Absent store + zero refs → `{}`, loud `NOTE:` naming the path, and **no** `keyFileFor` call — the greenfield win is complete (no store, no age key). - Present store still decrypts as before (covered). `capture` / `destroy` untouched. - Tests are the right shape: greenfield CLI e2e with key deliberately absent from the spawn env; one-ref refusal word-for-word; fleet UNREACHABLE still pinned once a ref exists; zero-refs missing store is READ not failed. ### Notes (non-blocking) - Labels `reconcile` job on the latest run was *cancelled*, not a product failure; build/install green. - Option-2 (semantic) is the better default than a `--bootstrap` flag for this failure mode; the NOTE keeps the operator aware the store path still exists. No blockers.
codex-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-19 12:49:51 +00:00
codex-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: I agree with everything and have no additional feedback.

The missing-store gate reuses requiredSecrets, preserves decryption whenever a store exists, retains the refusal when a store-backed secret is referenced, and allows the zero-secret greenfield path without demanding an age key. The single-project and fleet tests cover both sides of the boundary, and current checks are green.

Verdict: I agree with everything and have no additional feedback. The missing-store gate reuses requiredSecrets, preserves decryption whenever a store exists, retains the refusal when a store-backed secret is referenced, and allows the zero-secret greenfield path without demanding an age key. The single-project and fleet tests cover both sides of the boundary, and current checks are green.
dan-claude-bot commented 2026-07-19 12:56:27 +00:00 (Migrated from github.com)

@danmt — handing this one to you. Final state, and one design note worth your eyes:

What it fixes: cast#104 — greenfield manifest-first bootstrap was a chicken-and-egg: apply demanded a secret store unconditionally, while capture (the documented way to create one) correctly refuses a project that doesn't exist on the box yet. A fresh environment with a databases-only manifest had no path to its first apply except a hand-rolled printf '' | age … documented nowhere — which is exactly how the release drill got unstuck.

The fix (option 2 of the three in the issue): the missing-store refusal is now gated on requiredSecrets(...) — the same parser resolution itself uses, no second grammar. Store present → decrypt exactly as before. Absent + ≥1 ${…} ref → the byte-identical refusal, before any age-key resolution. Absent + zero refs → proceed on {} with a loud NOTE naming the store path, and keyFileFor is never called (no store, no key demanded — that's the greenfield win). README's two-pass bootstrap section gains the greenfield paragraph.

Design note: the issue lists two alternatives — message-only (option 1) and a cast init-store verb (option 3). All three bots endorsed option 2's semantics, but it is a semantic change to a refusal, so it's flagged for your judgment rather than presented as the only answer.

Review history: one round — all three bots approved, no changes requested.

Verification: npm test 600/0 · biome clean. The greenfield e2e test spawns with no key in the environment at all (any key demand fails the run); the refusal returns word-for-word the moment a template gains a ref; fleet --all both refuses a ref'd missing store and reads a zero-ref one.

Ready for your review.

🤖 Generated with Claude Code

@danmt — handing this one to you. Final state, and one design note worth your eyes: **What it fixes:** cast#104 — greenfield manifest-first bootstrap was a chicken-and-egg: `apply` demanded a secret store unconditionally, while `capture` (the documented way to create one) correctly refuses a project that doesn't exist on the box yet. A fresh environment with a databases-only manifest had no path to its first apply except a hand-rolled `printf '' | age …` documented nowhere — which is exactly how the release drill got unstuck. **The fix (option 2 of the three in the issue):** the missing-store refusal is now gated on `requiredSecrets(...)` — the same parser resolution itself uses, no second grammar. Store present → decrypt exactly as before. Absent + ≥1 `${…}` ref → the byte-identical refusal, before any age-key resolution. Absent + zero refs → proceed on `{}` with a loud NOTE naming the store path, and `keyFileFor` is never called (no store, no key demanded — that's the greenfield win). README's two-pass bootstrap section gains the greenfield paragraph. **Design note:** the issue lists two alternatives — message-only (option 1) and a `cast init-store` verb (option 3). All three bots endorsed option 2's semantics, but it is a semantic change to a refusal, so it's flagged for your judgment rather than presented as the only answer. **Review history:** one round — all three bots approved, no changes requested. **Verification:** `npm test` 600/0 · biome clean. The greenfield e2e test spawns with no key in the environment at all (any key demand fails the run); the refusal returns word-for-word the moment a template gains a ref; fleet `--all` both refuses a ref'd missing store and reads a zero-ref one. Ready for your review. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
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#106
No description provided.