feat: Phase 3 — declarative engine (config, plan/diff, dependency graph) - #11
Merged
Merged
Conversation
2000game
added a commit
that referenced
this pull request
Jul 7, 2026
Key actual-state map by logical key, not CT id CT ids are unique only within a type (the Mainz campus is id 0), so a numeric-id map collided across types and diffed one resource against another's fields. computePlan now takes actual keyed by logical key, and the plan command populates it that way. Structural deep-equal instead of JSON.stringify Order-independent object comparison, so a mere key-order difference (e.g. group-type.nameTranslated) no longer shows a perpetual, never- converging update. Reject a config/state type mismatch on the same logical key Diffing a campus against a group under a reused key silently corrupted the plan; now a clear error. Distinguish an unresolvable managed type from a 404 A managed type with no registry entry is left untouched (surfaced as "unresolved-type"), not falsely proposed for recreation. Tolerate partial fetch failures in the read-only plan A single non-404 error no longer aborts the whole plan; failures are reported, the rest of the diff is shown, and exit code is 1. Fetch managed resources concurrently (bounded pool) instead of serially. Surface stale state entries (gone from CT + dropped from config) instead of a silent no-op that printed "No changes". Reject a desired type with no apply tier, and lock the DSL type list to TYPE_TIER with a test (no more silent tier-0 default). Friendly "config not found" error instead of a raw jiti stack. Re-add `ct get group-types` to the README (command still exists).
Member
Author
Review fixes applied (
|
| # | Finding | Fix |
|---|---|---|
| 1 | actualById keyed by numeric CT id → cross-type id collision |
computePlan now takes actual keyed by logical key (globally unique); the command populates it that way. |
| 2 | JSON.stringify deep-equal → spurious non-converging updates |
Replaced with a structural, key-order-independent deepEqual. |
| 3 | Deletes ordered by tier only, not reverse dependency | Deletes now reuse orderKeys (reversed) — the same topo sort as creates. |
| 4 | Desired↔state matched by key without checking type | Type mismatch on a shared key now throws a clear error. |
| 5 | Registry-missing type conflated with a 404 → false recreate | Unresolvable types are tracked and left untouched (surfaced as unresolved-type), never recreated. |
| 6 | One non-404 fetch error aborts the whole read-only plan | Per-resource errors are collected; the rest of the plan renders; exit code 1 on partial failure. |
| 7 | Type names in three unsynchronized lists; DSL type without a tier silently tier-0 | computePlan rejects a desired type with no tier; a test locks the DSL type list to TYPE_TIER. |
| 8 | Managed resources fetched serially | Bounded-concurrency fetch pool (mapConcurrent, limit 8). |
| 9 | Dropped-and-vanished resource silently no-op → state never pruned |
Surfaced as a stale state entry section instead of "No changes". |
| 10 | Missing config → cryptic jiti error | Friendly "config not found" message. |
Also re-added ct get group-types to the README (the command still exists).
Known limitation (noted, not a regression): full intra-tier reverse-topo delete ordering needs the state file to record dependency edges — deferred to Phase 4 (apply/destroy). Today deletes are deterministic reverse-tier order via the unified mechanism.
TS-as-code config, a diff/plan engine, and dependency ordering.
- Config DSL (src/config/*): a config file default-exports (ct) => { ... }
and declares resources via an injected context (no global state). Loaded
at runtime with jiti so .ts configs run directly. Blueprints are plain
functions + loops; duplicate/missing keys are rejected.
- Dependency graph (src/engine/graph.ts): tiered topological sort — metadata
→ group → hierarchy → role → permission → dynamic-group, parents before
children — with cycle detection. Deletes run in reverse.
- Plan/diff (src/engine/plan.ts): pure computePlan(desired, state, actualById)
→ create/update/delete/no-op, drift (last-known → actual), recreate for
vanished resources. Unmanaged resources stay invisible (managed-guard).
IDs compared with null checks (id 0). Terraform-style renderPlan.
- ct plan [--config --state --json]: loads config + state, fetches actual via
the Phase 2 registry (404 → recreate), renders the diff. Read-only.
- 24 new tests (graph tiers/parents/cycle, plan create/update/delete/no-op/
drift/recreate/unmanaged/ordering, context, jiti load, render) = 63.
Refs #5
Key actual-state map by logical key, not CT id CT ids are unique only within a type (the Mainz campus is id 0), so a numeric-id map collided across types and diffed one resource against another's fields. computePlan now takes actual keyed by logical key, and the plan command populates it that way. Structural deep-equal instead of JSON.stringify Order-independent object comparison, so a mere key-order difference (e.g. group-type.nameTranslated) no longer shows a perpetual, never- converging update. Reject a config/state type mismatch on the same logical key Diffing a campus against a group under a reused key silently corrupted the plan; now a clear error. Distinguish an unresolvable managed type from a 404 A managed type with no registry entry is left untouched (surfaced as "unresolved-type"), not falsely proposed for recreation. Tolerate partial fetch failures in the read-only plan A single non-404 error no longer aborts the whole plan; failures are reported, the rest of the diff is shown, and exit code is 1. Fetch managed resources concurrently (bounded pool) instead of serially. Surface stale state entries (gone from CT + dropped from config) instead of a silent no-op that printed "No changes". Reject a desired type with no apply tier, and lock the DSL type list to TYPE_TIER with a test (no more silent tier-0 default). Friendly "config not found" error instead of a raw jiti stack. Re-add `ct get group-types` to the README (command still exists).
2000game
force-pushed
the
feat/phase-3-engine
branch
from
July 7, 2026 12:43
5ab3d9f to
b292e2f
Compare
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Config DSL (
src/config/*)A config file default-exports
(ct) => { … }and declares resources via an injected context (no global state — trivially testable). Loaded at runtime withjitiso.tsconfigs run directly. Blueprints are plain functions + loops:Duplicate/missing keys are rejected.
Dependency graph (
src/engine/graph.ts)Tiered topological sort: metadata → group → hierarchy → role → permission → dynamic-group, parents before children, with cycle detection. Deletes run in reverse. Unit-tested with a synthetic graph (the #5 DoD).
Plan/diff (
src/engine/plan.ts)Pure
computePlan(desired, state, actualById)→ create / update / delete / no-op, plus drift (last-known → actual) and recreate for resources vanished from CT. Unmanaged resources stay invisible (managed-guard). IDs compared with null checks (id 0). Terraform-stylerenderPlan.ct plan [--config --state --json]Loads config + state, fetches actual via the Phase 2 registry (404 → recreate), renders the diff. Read-only.
Tests — 63 (+24)
graph (tiers/parents/cycle), plan (every action + drift + recreate + unmanaged + ordering), context (DSL/blueprints/dedup), jiti load, render. typecheck · lint · build green.
Live-verified ✅
Against the instance (CT 3.134.0): adopt campus 0 → config matches ⇒ "No changes"; change shortName ⇒ 1 update (
~ campus.mz (#0) shortName: "MZ" -> "MZ-NEW"); add un-adopted campus ⇒ 1 create,mzstays no-op.Refs #5