Skip to content

feat: Phase 4 — Apply (idempotent CRUD) + destroy + guardrails - #15

Merged
2000game merged 16 commits into
mainfrom
feat/phase-4-apply-destroy
Jul 8, 2026
Merged

2000game merged 16 commits into
mainfrom
feat/phase-4-apply-destroy

Conversation

@2000game

@2000game 2000game commented Jul 8, 2026 •

Copy link
Copy Markdown
Member

Implements Phase 4 — Apply (idempotent CRUD in dependency order) + guardrails (closes #6). Makes the Phase 3 plan real, safely.

Command surface

  • ct apply — builds the same plan as ct plan, shows it, backs up, confirms, then executes creates + updates only in dependency order, saving state after each action (crash-safe / resumable). Flags: -c/--config, -s/--state, --backup-dir, -y/--auto-approve.
  • ct destroy --target <key…> — explicit targets only (repeatable / comma-separated); no target ⇒ hard error. Reverse-dependency order, backup, typed confirmation, then delete + prune state. --force skips the typed prompt (preventDestroy still enforced).

Guardrails (issue #6)

Guardrail Mechanism
Confirmation before any change confirm (apply) / confirmTyped (destroy)
Automatic backup before apply/destroy JSON snapshot of affected resources → backups/ct-backup-<ts>.json
People/memberships never touched structural-only registry (allowlist) + assertNotPeople denylist on every write
Rate-limit + retry on writes existing fetchWithRetry (429 retried; 5xx/network never blindly re-sent for writes)
State updated after each action saveState after every create/update/edge/delete
Never implicit deletions apply never deletes — dropped resources surface a ct destroy notice
Destroy-protection preventDestroy config lifecycle flag + explicit --target + typed confirm

Type scope

campus, group, group-type + write specs for age-group, target-group, relationship-type, group-role, plus group hierarchy edges (reconciled via PUT/DELETE /groups/{id}/parents/{parentId}). Group parents are a set-field diffed live, never stored in state (no false drift).

New modules

engine/build.ts (shared buildPlan), engine/execute.ts (field-agnostic executePlan), engine/guard.ts (assertNotPeople), engine/backup.ts, ui/prompt.ts; commands/apply.ts, commands/destroy.ts; registry gains collectionPath + updateMethod.

✅ Live-verified end-to-end (eqrm-dev, CT 3.134.1)

The full write path was exercised against a real instance, not just unit tests:

  • create → re-plan shows no drift → update (live GET confirms new value) → re-plan no drift
  • preventDestroy blocks a targeted destroy even with --force; resource stays intact
  • flag removed → destroy deletes it (GET → 404), state pruned, backup written each apply/destroy
  • PUT is merge-safe: a managed-fields PUT preserved an unmanaged field (sortKey), so apply updates don't clobber untouched data

Two field-mapping bugs that only surface against a live API were found and fixed here (couldn't be caught read-only):

  • campus short name is shorty (1–10 chars, required on create), not shortName (a vestigial null sibling) — POST /campuses 400'd until fixed.
  • relationship-type uses degreeNameA/degreeNameB, not the provisionally-guessed degreeForward/degreeReverse.

Both field sets are now locked with tests built from the live payloads.

Testing

133 tests pass, incl. an adopt→modify→apply→re-plan-no-drift integration test, executor unit tests (create/update/hierarchy/skip-delete/stop-on-error), guard denylist, backup, prompts, and destroy protection/ordering. tsc / eslint / build clean.

Design doc: docs/superpowers/specs/2026-07-07-phase-4-apply-destroy-design.md.

https://claude.ai/code/session_017tFJu7SrS5uLdit5FtXiwS

2000game added 2 commits July 8, 2026 11:02
Live write-test against eqrm-dev surfaced this: POST /campuses requires
`shorty` (1–10 chars); `shortName` is a vestigial, usually-null sibling. The
generic executor sends managedFields as the create body, so campus create failed
with a 400 until the model used `shorty`. Switched campus managedFields +
deriveKey to `shorty` and updated the coupled tests + docs.
…efully

apply: a resource that vanished from ChurchTools but remains in state was
replanned as a create; executePlan POSTed a fresh copy, then upsert threw on
the stale same-key entry (old id) and never recorded the new one — so state
was never updated and every re-run leaked another duplicate. A create now
drops the stale entry before upsert, so the new id takes over the key.

destroy: the DELETE loop was unguarded — a mid-list failure threw a raw error
after earlier targets were already deleted and persisted, with no resume
guidance. Wrap each DELETE and stop cleanly, mirroring apply's crash-safe
report (state is saved per target, so re-running with the remaining targets
resumes).
@2000game
2000game merged commit c7d9f49 into main Jul 8, 2026
1 check passed
@2000game
2000game deleted the feat/phase-4-apply-destroy branch July 8, 2026 09:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Phase 4 — Apply (idempotent CRUD in dependency order) + guardrails

1 participant