Skip to content

Phase 5 — auto-groups (#14) + permissions (#13) + blueprints (#7) - #18

Merged
2000game merged 33 commits into
mainfrom
feat/phase-5-permissions-autogroups-blueprints
Jul 9, 2026
Merged

2000game merged 33 commits into
mainfrom
feat/phase-5-permissions-autogroups-blueprints

Conversation

@2000game

@2000game 2000game commented Jul 8, 2026 •

Copy link
Copy Markdown
Member

Phase 5 — Auto-groups (#14) + Permissions (#13) + Blueprints (#7)

Declarative, idempotent management of ChurchTools' rights-bearing structure, built on a shared synthetic-field engine seam. One branch, sequenced #14 → #13 → #7. All three features complete.

✅ #14 — Auto-groups (dynamic groups) as code

  • Opt-in dynamic: { status, ruleset } block on a group. status ∈ active | manual | inactive | none (none demotes back to a normal group).
  • Generalized the one-off parents hierarchy fold into a reusable synthetic-field registry (src/engine/synthetic.ts); parents and dynamic are its first two entries — build.ts/execute.ts gained no per-feature branches.
  • Normalizer (src/engine/dynamic.ts) makes drift real, not spurious: unwraps the [RuleSet] GET array + dynamicGroupRuleSet PUT envelope, drops read-only timestamps, and (scoped to the query subtree) strips cosmetic dterm labels + coerces int/string ids. Validated against 3 real production rulesets.
  • Typed query DSL (src/config/query.ts): q.and/or/not/eq/oneof/isnull/var + churchQuery(), matched to the real ChurchQuery envelope shape.
  • ct apply --refresh (opt-in, per-group only — never the all-groups endpoint).

✅ #13 — Permissions as code

  • ct.groupRole({ id, grants }) / ct.groupTypeRole({ id, grants }) — grants are human-readable module:right names or { right, scope: [groupKeys] }.
  • Ships a static name→authId catalog (src/permissions/catalog.json, 187 rights) — not in the REST API, so captured reference data (with scopeField + revocable). ct get permissions-catalog lists it.
  • Set reconciliation keyed by (domainType, domainId, authId, dataId), tolerant of the self-re-adding system baseline (modifiedPid: -1) and inherited grants. Scoped grants fan out to one tuple per dataId to match the API's scalar read shape (idempotent, incl. multi-scope). Managed-guard: undeclared domain objects stay invisible.
  • Domain-rule validation (authId < 10000 for group_type_role; scoped rights require a scopeField); duplicate (domainType, domainId) declarations rejected.

✅ #7 — Blueprints

  • Proves the config DSL supports parametrized campus blueprints with no new machinery — a blueprint is a plain function over the injected context, called in a loop. Example: examples/campus-blueprint.config.ts (a Kids area instantiated across 2 campuses, composing parents hierarchy + a dynamic auto-group + a groupTypeRole grant).
  • tests/blueprint.test.ts locks the guarantee: one blueprint across ≥2 campuses yields a correctly dependency-ordered plan (campuses → groups → hierarchy), with the managed-parent typo guard.

Architecture

Both auto-groups and hierarchy attach to a domain object as an opt-in pseudo-field, folded into the diff on both sides and routed at apply to dedicated endpoints — never the resource body. Permissions run as a cohesive src/permissions/ subsystem (its own plan/apply) reusing the state file, assertNotPeople, and the plan/apply commands.

Testing

  • 202 passing / 4 skipped (the 4 are opt-in CT_LIVE-gated round-trip tests), typecheck + lint clean, built binary reads the catalog.
  • Live integration tests are gated and default-skipped; write-based ones are doubly gated with a host-match guard and must target a dev instance (never production).
  • Built via multi-agent subagent-driven development: fresh implementer + independent spec/quality review per task, plus a whole-branch review per feature (which caught, e.g., the multi-scope idempotency bug — fixed and test-locked).

Design docs

  • Spec: docs/superpowers/specs/2026-07-08-phase-5-permissions-autogroups-blueprints-design.md
  • Plans: docs/superpowers/plans/2026-07-08-{auto-groups,permissions,blueprints}.md
  • Feature guides: docs/{dynamic-groups,permissions,blueprints}.md

Closes #14. Closes #13. Closes #7.

https://claude.ai/code/session_017tFJu7SrS5uLdit5FtXiwS

2000game added 30 commits July 8, 2026 12:06
Extracts the one-off `parents` hierarchy fold/apply into a reusable
SYNTHETIC_FIELDS registry (src/engine/synthetic.ts), so build.ts diffs
and execute.ts routes any pseudo-field generically instead of growing
per-feature branches. Behavior-preserving: parents fold/apply logic is
unchanged, just moved behind the registry interface.

Claude-Session: https://claude.ai/code/session_017tFJu7SrS5uLdit5FtXiwS
…an for array-wrapped GET

Captured read-only from eqrm.church.tools (68 real dynamic groups). Key finding:
GET /dynamicgroups/{id}/ruleset returns a single-element [RuleSet] array, not a
bare object — normalizeRuleset unwraps it.

Claude-Session: https://claude.ai/code/session_017tFJu7SrS5uLdit5FtXiwS
The normalizeRuleset function was applying stripCosmeticLabels and
coerceScalars to the entire ruleset tree, which caused numeric-looking
RuleSet-level fields (e.g., description: "2024", shorty: "007") to be
silently retyped to numbers. Since the normalized ruleset is written back
to ChurchTools, this corrupted those fields on write-back.

Move normalization to the query subtree only, where the cosmetic labels
and int/string inconsistencies actually live. This preserves the types
of top-level RuleSet fields while still handling normalization where needed.

Claude-Session: https://claude.ai/code/session_017tFJu7SrS5uLdit5FtXiwS
Adds resolveRulesetRef to dynamic.ts and registers a second
SYNTHETIC_FIELDS entry (dynamicField) that folds the dynamic-group
ruleset+status into both desired.fields and actual, and applies the
resulting change via wrapped PUT /ruleset + PUT /status, or DELETE
/ruleset + PUT status:none when demoting.
ct apply --refresh POSTs the per-group /dynamicgroups/{id}/refresh for each
applied item whose changes touched the dynamic synthetic field, after a
successful apply — never the all-groups /dynamicgroups/refresh endpoint.
The id is resolved from state post-apply with an explicit undefined check
(CT ids can be 0). Adds a gated live round-trip test for the ruleset
normalizer (skips unless CT_LIVE=1).

Claude-Session: https://claude.ai/code/session_017tFJu7SrS5uLdit5FtXiwS
Add src/config/query.ts exporting q (and/or/not/eq/oneof/isnull/var JSONLogic
builder) and churchQuery(filter, opts?) wrapping it in the ChurchQuery envelope.
Envelope defaults (method, groupBy, primaryEntityAlias, responseFields) are
reconciled against the real production fixtures (ruleset-683/2022/1092) rather
than the brief's minimal params:{filter} shape, and overridable via opts.
Re-export q/churchQuery from src/config/context.ts so config files can import
them via the same relative-path convention already used for ConfigContext.
…set shape

- q.not now emits { "!": [n] } (array-wrapped operand), matching every
  real fixture's `!` shape (ruleset-683.get.json "Keine Funktionsuser
  zählen" clause). The bare-node form drifted forever against a live
  read-back since the normalizer doesn't reconcile bare-vs-array.
- churchQuery now returns exactly { method, params } with no
  `description` key inside the query envelope. Real rulesets only ever
  have query = { method, params }; `description` is a sibling field on
  the ruleset object, not inside `query`. Dropped the now-unused
  ChurchQueryOptions.description option and its re-export in
  src/config/context.ts accordingly.

Verified against tests/fixtures/dynamic/ruleset-683.get.json's query
block.
Document the dynamic-group (auto-group) feature built across the prior
tasks: the `dynamic` block, status semantics, the three ways to supply a
ruleset, the typed query DSL, drift normalization, the managed guard, and
`ct apply --refresh`. Add a runnable example config and a short README
subsection.

Claude-Session: https://claude.ai/code/session_017tFJu7SrS5uLdit5FtXiwS
187 rights across 14 modules, captured read-only from eqrm.church.tools via the
churchauth/ajax getMasterData HAR trace (the catalog is not in the REST API).
Carries scopeField (dataId-taking rights) and revocable flags.

Claude-Session: https://claude.ai/code/session_017tFJu7SrS5uLdit5FtXiwS
…declarations

evaluateConfig/loadConfig now return {resources, permissions}; permission
declarations reconcile through their own subsystem so they're collected
separately from DesiredResource[]. Threads permissions through plan/apply/
destroy callers (not yet consumed — later task).

Claude-Session: https://claude.ai/code/session_017tFJu7SrS5uLdit5FtXiwS
…talog helper

- src/permissions/apply.ts: applyPermissionPlan PUTs each toPut tuple and
  DELETEs each toDelete tuple against /permissions/{domainType}/{domainId},
  omitting dataId from the body when unscoped. Every path is guarded through
  assertNotPeople.
- src/permissions/render.ts: renderPermissionPlan, Terraform-style rendering
  matching engine/render.ts's picocolors conventions.
- src/commands/plan.ts / apply.ts: replace the `void permissions;` placeholder
  with buildPermissionPlan(client, state, permissions); render its section;
  fold its fetchErrors into the existing incomplete-plan handling (plan warns
  + exit 1, apply aborts). apply now includes permission changes in the
  confirmation count and calls applyPermissionPlan after resource
  executePlan succeeds, reporting granted/deleted.
- src/commands/get.ts: add `permissions-catalog` subcommand printing the
  static name -> authId catalog (sorted, with scoped/unscoped) to help users
  discover valid grant names.

Tested only against a mocked client (tests/permission-apply.test.ts); no
live ct plan/apply was run against the production instance.
Add tests/permission.integration.test.ts, mirroring
dynamic.integration.test.ts's gating pattern. The read-only block
(CT_LIVE=1) reads a group_type_role's current grants, reconstructs a
config declaring exactly those user-authored grants, and asserts
buildPermissionPlan diffs to a no-op — read -> diff is drift-free. It
issues GETs only, never PUT/DELETE. A second block (apply idempotency)
is doubly gated behind CT_LIVE_WRITE=1 plus an explicit
CT_LIVE_WRITE_HOST match guard so it cannot fire against the dev
machine's current production login.

Claude-Session: https://claude.ai/code/session_017tFJu7SrS5uLdit5FtXiwS
Bundle catalog.json into catalog.ts via a JSON import instead of readFileSync,
so tsup/esbuild inlines the data into dist/index.js — the built binary no
longer depends on a sibling catalog.json that tsup never copies. Add
docs/permissions.md and a runnable examples/permissions.config.ts, and
document the feature in README.

Claude-Session: https://claude.ai/code/session_017tFJu7SrS5uLdit5FtXiwS
@2000game 2000game changed the title Phase 5 — auto-groups (#14) + permissions (#13) [+ blueprints (#7) WIP] Phase 5 — auto-groups (#14) + permissions (#13) + blueprints (#7) Jul 8, 2026
@2000game
2000game marked this pull request as ready for review July 8, 2026 20:39
… preservation, example grant name, ruleset ref resolution

- FIX 1 (security): reject a bare-string declaration of a scoped right
  (has a scopeField) in desiredTuples — it would silently emit dataId:[] and
  grant globally; require { right, scope: [...] } instead.
- FIX 2 (security): never delete pre-existing revoke (deny) rows. diffGrants
  now treats non-"grant" actuals as unmanaged, keeps them out of toDelete, and
  surfaces them as an informational `preserved` note in the plan render.
- FIX 3: correct the invalid grant name "churchgroup:edit group members" to
  "churchgroup:edit group memberships of group" (authId 1112, scoped) in the
  example + docs, scoping the grants so they stay valid under FIX 1; add
  tests/examples.test.ts guarding that every examples/*.config.ts loads and
  every declared permission right resolves in the catalog.
- FIX 4: thread the config file's directory (configDir) through loadConfig →
  buildPlan → foldSynthetic so { ref } rulesets resolve relative to the config,
  not the cwd; wrap missing/unreadable/invalid-JSON ref files in a clear error
  naming the group and resolved path.
- FIX 5: split the ruleset-GET and status-GET try/catch in the dynamic fold —
  only a ruleset 404 fabricates the "none" sentinel; a status-GET failure after
  a good ruleset GET degrades the plan via errors instead of lying.
- FIX 6 (docs/comments): correct permissions.md (unknown right names throw at
  plan/apply time in desiredTuples, not at config eval) and the
  dynamic-group.config.ts comment (dynamic is a synthetic field folded into the
  group's own tier-1 apply, not a separate tier-5 write).
@2000game

2000game commented Jul 9, 2026

Copy link
Copy Markdown
Member Author

Review — three-agent review (engine/dynamic · permissions · blueprints/docs)

Verdict: approve after fixes — all blocking findings fixed in 4d4348a on this branch. Verified: 215 tests passing (up from 202), typecheck + lint clean.

What this PR does well

  • The synthetic-field registry is a clean seam: parents and dynamic route through one SyntheticField abstraction and build.ts/execute.ts stayed genuinely feature-agnostic.
  • Permissions set-reconciliation core (keying by type:authId:sorted-dataId, scalar fan-out, baseline/inherited tolerance, managed-guard) is sound and well-tested on the happy path.
  • tests/blueprint.test.ts genuinely locks the ordered-plan guarantee (campus → own groups → hierarchy, per campus, from one blueprint function).
  • Normalizer envelope handling ([RuleSet] GET unwrap, dynamicGroupRuleSet PUT envelope, status shape) matches the documented API shapes.

Blocking findings — fixed in 4d4348a

  1. (security) Bare-string declaration of a scoped right silently granted globally — desiredTuples emitted dataId: [] with no scopeField check; validation was asymmetric (scope on an unscoped right threw, the reverse didn't). Now throws with a message directing to { right, scope: [...] }. 63 catalog rights were reachable this way.
  2. (security) Reconciliation deleted pre-existing revoke (deny) rows — desired tuples are always type:"grant", so an admin's explicit deny on a managed domain always landed in toDelete. Non-grant rows are now unmanaged: never deleted, surfaced as a preserved note in the plan.
  3. Invalid grant name in example + docs — churchgroup:edit group members doesn't exist (correct: churchgroup:edit group memberships of group, authId 1112); nothing caught it because examples/ was outside the typecheck/test surface. Fixed + new tests/examples.test.ts loads every example and resolves every grant against the catalog.
  4. { ref } rulesets resolved against cwd, not the config file's directory, and a missing file crashed with a raw ENOENT out of foldSynthetic. configDir now threads loadConfig → buildPlan → foldSynthetic; errors name the group and resolved path. Was fully untested; now covered.
  5. Ruleset/status GETs shared one try/catch — a status-GET failure after a successful ruleset GET fabricated the "not dynamic" sentinel, discarding the real ruleset and proposing a spurious re-PUT. Now only a ruleset-404 produces the sentinel; other failures degrade the plan via fetchErrors.
  6. Docs: permissions.md claimed unknown right names throw "at config evaluation time" (actually plan/apply time in desiredTuples); dynamic-group example comment described a nonexistent "tier 5" write (dynamic applies inline in the group's tier-1 apply).

Tracked as issues instead of blocking

@2000game
2000game merged commit dfc8d24 into main Jul 9, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant