Four verified plan/apply robustness gaps from the 2026-07-08 whole-codebase review — grouped because each is a small, self-contained hardening fix. Roughly severity-ordered.
1. The minimum-CT-version gate is never enforced (CONFIRMED)
src/api/version.ts:3-4 claims "the CLI asserts a minimum before it will plan/apply" and ct auth login tells the user "plan/apply will refuse" (commands/auth.ts:43) — but meetsMinVersion is only ever called inside auth login as a non-fatal warn(). plan.ts / apply.ts / destroy.ts never fetch /info or check anything.
Failure: against a CT < 3.96, ct apply proceeds; tier-0/1 writes succeed, then hierarchy/dynamic endpoints fail — the exact half-applied structure the gate exists to prevent.
Fix: assert the version in authedSession (one /info GET, cached) or at the top of plan/apply/destroy; hard-fail below minimum as documented.
2. Transient fetch errors render as "[recreate — missing in ChurchTools]" (CONFIRMED)
A non-404 error fetching an actual records a fetchError but leaves the key out of actual (src/engine/build.ts:49-57); computePlan then emits a create noted recreate (plan.ts:127-137) or a stale — prune state no-op (:166-177) — indistinguishable from a real 404. ct plan renders the factually wrong label with only a generic INCOMPLETE warning appended (commands/plan.ts:40-52); only apply aborts.
Failure: a transient 500 makes plan advise pruning/recreating a healthy resource; a future buildPlan caller that forgets the fetchErrors guard would POST a duplicate.
Fix: thread fetch-failed keys into computePlan (e.g. an unresolved set) so those resources render as ? <key> — fetch failed (500) and are excluded from create/stale classification.
3. Permission plan not re-resolved after recreate — grants written with stale dataIds (CONFIRMED)
buildPermissionPlan resolves scope keys → dataIds from pre-apply state (commands/apply.ts:100), but applyPermissionPlan replays those tuples after executePlan has upserted new ids (:149 → :159). A scope-target group recreated during the same apply gets its grant PUT with the old, dangling id; self-heals only on the next full apply.
Fix: re-resolve scope dataIds (and domainIds, once #25 makes them reference-based) against the post-execute state just before applyPermissionPlan — the same re-resolution point the scope-bootstrap-deadlock fix needs.
4. Unguarded res.json() on 2xx responses (PLAUSIBLE)
204 is handled (src/api/ctClient.ts:103-104), but any other 2xx with an empty/non-JSON body hits the bare await res.json() (:106; safeBody only covers !res.ok). All DELETE call sites (permission grants, hierarchy edges, rulesets) route through request().
Failure: a 200-with-empty-body surfaces as a raw SyntaxError: Unexpected end of JSON input naming no method/path/resource, aborting apply mid-run with no clue which request succeeded.
Fix: guard on content-length/content-type (or try/catch the parse) and return undefined for empty 2xx bodies; wrap parse failures in CtApiError with method+path.
Acceptance
Four verified plan/apply robustness gaps from the 2026-07-08 whole-codebase review — grouped because each is a small, self-contained hardening fix. Roughly severity-ordered.
1. The minimum-CT-version gate is never enforced (CONFIRMED)
src/api/version.ts:3-4claims "the CLI asserts a minimum before it will plan/apply" andct auth logintells the user "plan/apply will refuse" (commands/auth.ts:43) — butmeetsMinVersionis only ever called insideauth loginas a non-fatalwarn().plan.ts/apply.ts/destroy.tsnever fetch/infoor check anything.Failure: against a CT < 3.96,
ct applyproceeds; tier-0/1 writes succeed, then hierarchy/dynamic endpoints fail — the exact half-applied structure the gate exists to prevent.Fix: assert the version in
authedSession(one/infoGET, cached) or at the top of plan/apply/destroy; hard-fail below minimum as documented.2. Transient fetch errors render as "[recreate — missing in ChurchTools]" (CONFIRMED)
A non-404 error fetching an actual records a
fetchErrorbut leaves the key out ofactual(src/engine/build.ts:49-57);computePlanthen emits a create notedrecreate(plan.ts:127-137) or astale — prune stateno-op (:166-177) — indistinguishable from a real 404.ct planrenders the factually wrong label with only a generic INCOMPLETE warning appended (commands/plan.ts:40-52); only apply aborts.Failure: a transient 500 makes plan advise pruning/recreating a healthy resource; a future
buildPlancaller that forgets thefetchErrorsguard would POST a duplicate.Fix: thread fetch-failed keys into
computePlan(e.g. anunresolvedset) so those resources render as? <key> — fetch failed (500)and are excluded from create/stale classification.3. Permission plan not re-resolved after recreate — grants written with stale dataIds (CONFIRMED)
buildPermissionPlanresolves scope keys →dataIds from pre-apply state (commands/apply.ts:100), butapplyPermissionPlanreplays those tuples afterexecutePlanhas upserted new ids (:149→:159). A scope-target group recreated during the same apply gets its grant PUT with the old, dangling id; self-heals only on the next full apply.Fix: re-resolve scope dataIds (and domainIds, once #25 makes them reference-based) against the post-execute state just before
applyPermissionPlan— the same re-resolution point the scope-bootstrap-deadlock fix needs.4. Unguarded
res.json()on 2xx responses (PLAUSIBLE)204 is handled (
src/api/ctClient.ts:103-104), but any other 2xx with an empty/non-JSON body hits the bareawait res.json()(:106;safeBodyonly covers!res.ok). All DELETE call sites (permission grants, hierarchy edges, rulesets) route throughrequest().Failure: a 200-with-empty-body surfaces as a raw
SyntaxError: Unexpected end of JSON inputnaming no method/path/resource, aborting apply mid-run with no clue which request succeeded.Fix: guard on content-length/content-type (or try/catch the parse) and return
undefinedfor empty 2xx bodies; wrap parse failures inCtApiErrorwith method+path.Acceptance
undefined/CtApiError, never a raw SyntaxError (test).