Repository navigation
Phase 5 — auto-groups (#14) + permissions (#13) + blueprints (#7) - #18
Merged
Merged
Conversation
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
…mote; correct tier doc
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
…ed-guard, validation)
…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
…ain guard; scopeField + json + NaN fixes Claude-Session: https://claude.ai/code/session_017tFJu7SrS5uLdit5FtXiwS
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).
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
Blocking findings — fixed in 4d4348a
Tracked as issues instead of blocking
|
9 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.
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
dynamic: { status, ruleset }block on a group.status∈active | manual | inactive | none(nonedemotes back to a normal group).parentshierarchy fold into a reusable synthetic-field registry (src/engine/synthetic.ts);parentsanddynamicare its first two entries —build.ts/execute.tsgained no per-feature branches.src/engine/dynamic.ts) makes drift real, not spurious: unwraps the[RuleSet]GET array +dynamicGroupRuleSetPUT envelope, drops read-only timestamps, and (scoped to thequerysubtree) strips cosmeticdtermlabels + coerces int/string ids. Validated against 3 real production rulesets.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-readablemodule:rightnames or{ right, scope: [groupKeys] }.src/permissions/catalog.json, 187 rights) — not in the REST API, so captured reference data (withscopeField+revocable).ct get permissions-cataloglists it.(domainType, domainId, authId, dataId), tolerant of the self-re-adding system baseline (modifiedPid: -1) and inherited grants. Scoped grants fan out to one tuple perdataIdto match the API's scalar read shape (idempotent, incl. multi-scope). Managed-guard: undeclared domain objects stay invisible.authId < 10000forgroup_type_role; scoped rights require ascopeField); duplicate(domainType, domainId)declarations rejected.✅ #7 — Blueprints
examples/campus-blueprint.config.ts(a Kids area instantiated across 2 campuses, composingparentshierarchy + adynamicauto-group + agroupTypeRolegrant).tests/blueprint.test.tslocks 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 theplan/applycommands.Testing
CT_LIVE-gated round-trip tests), typecheck + lint clean, built binary reads the catalog.Design docs
docs/superpowers/specs/2026-07-08-phase-5-permissions-autogroups-blueprints-design.mddocs/superpowers/plans/2026-07-08-{auto-groups,permissions,blueprints}.mddocs/{dynamic-groups,permissions,blueprints}.mdCloses #14. Closes #13. Closes #7.
https://claude.ai/code/session_017tFJu7SrS5uLdit5FtXiwS