Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
08e65bb
docs: design spec for Phase 5 — permissions, auto-groups, blueprints …
2000game Jul 8, 2026
e34fe39
docs: correct spec with authoritative OpenAPI shapes from live probe
2000game Jul 8, 2026
96a5134
docs: implementation plan for auto-groups (#14)
2000game Jul 8, 2026
0f34955
refactor(engine): generic synthetic-field seam; migrate parents into it
2000game Jul 8, 2026
51c5a18
test(dynamic): capture prod ruleset fixtures (#14 Task 1); correct pl…
2000game Jul 8, 2026
e6f7549
fix(engine): restore warn() on hierarchy-fetch failure in synthetic fold
2000game Jul 8, 2026
3f2c4c6
feat(dynamic): accept and validate the dynamic block in the config DSL
2000game Jul 8, 2026
43f7a7d
fix(dynamic): reject null/non-object dynamic block with a clean error
2000game Jul 8, 2026
4cab7b1
feat(dynamic): ruleset normalizer (labels, scalars, timestamps, envel…
2000game Jul 8, 2026
b446a62
fix(dynamic): scope ruleset normalization to the query subtree only
2000game Jul 8, 2026
e423fa8
feat(dynamic): synthetic-field fold + apply for ruleset & status
2000game Jul 8, 2026
21526b8
feat(dynamic): end-to-end apply, opt-in --refresh, live round-trip test
2000game Jul 8, 2026
658ad2c
feat(dynamic): typed query builder + ChurchQuery envelope (Phase 2)
2000game Jul 8, 2026
0c2ade9
fix(dynamic): churchQuery envelope + q.not array-wrap match real rule…
2000game Jul 8, 2026
6f7e57f
docs(plan): correct Task 8 example — description on ruleset, not in c…
2000game Jul 8, 2026
dd00012
docs(dynamic): auto-groups guide + runnable example config
2000game Jul 8, 2026
852d4cb
fix(dynamic): tolerate 404 for non-dynamic groups; skip refresh on de…
2000game Jul 8, 2026
069cea7
feat(permissions): ship static name→authId catalog (#13 prep)
2000game Jul 8, 2026
88525cb
docs: implementation plan for permissions as code (#13)
2000game Jul 8, 2026
6c48bc1
feat(permissions): catalog resolver (name→authId, scope, revocable)
2000game Jul 8, 2026
1d38834
feat(permissions): grant-tuple model + set reconciliation (baseline-t…
2000game Jul 8, 2026
def75e4
feat(permissions): groupRole/groupTypeRole DSL + threaded permission …
2000game Jul 8, 2026
66fd011
feat(permissions): scope-key → group dataId resolver
2000game Jul 8, 2026
42fd446
feat(permissions): build permission plan (tuples, bulk actuals, manag…
2000game Jul 8, 2026
7b67894
feat(permissions): apply grants (PUT/DELETE) + plan/apply wiring + ca…
2000game Jul 8, 2026
2d82145
test(permissions): gated DEV-only round-trip
2000game Jul 8, 2026
a73ce3a
docs(permissions): guide + example; ensure catalog ships in build
2000game Jul 8, 2026
a761d23
fix(permissions): fan out multi-scope grants for idempotency; dup-dom…
2000game Jul 8, 2026
8e62128
docs: implementation plan for blueprints (#7)
2000game Jul 8, 2026
f5edaf5
feat(blueprints): example parametrized campus blueprint (kids area × …
2000game Jul 8, 2026
b1ed042
test(blueprints): lock ordered-plan guarantee for a blueprint across …
2000game Jul 8, 2026
0419547
docs(blueprints): parametrized campus blueprint guide; Phase 5 complete
2000game Jul 8, 2026
4d4348a
fix(review): pre-merge findings — scoped-grant validation, revoke-row…
2000game Jul 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 69 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,13 @@ Early scaffold. See the [epic (#1)](https://github.com/eqrm/ct-cli/issues/1) and
- ✅ **Phase 2 — Read/Adopt** ([#4](https://github.com/eqrm/ct-cli/issues/4)): `ct adopt` + JSON state file.
- ✅ **Phase 3 — Declarative engine** ([#5](https://github.com/eqrm/ct-cli/issues/5)): config DSL, `plan`/diff, dependency graph, group hierarchy.
- ✅ **Phase 4 — Apply + guardrails** ([#6](https://github.com/eqrm/ct-cli/issues/6)): `ct apply` / `ct destroy`, confirmation, backup, `preventDestroy`.
- ⬜ Phase 5: blueprints.
- ✅ **Phase 5 — Blueprints** ([#7](https://github.com/eqrm/ct-cli/issues/7)):
all three planned features landed — auto-groups / dynamic groups
([#14](https://github.com/eqrm/ct-cli/issues/14)), permissions
([#13](https://github.com/eqrm/ct-cli/issues/13)), and blueprints
([#7](https://github.com/eqrm/ct-cli/issues/7)) — see
[Auto-groups](#auto-groups), [Permissions](#permissions), and
[Blueprints](#blueprints) below.

## Requirements

Expand Down Expand Up @@ -95,6 +101,68 @@ export default (ct) => {
Machine-readable output goes to **stdout** (pipe/`jq` it); human status lines go
to **stderr**.

### Blueprints

A "blueprint" is a plain function over the injected `ConfigContext` — no
special machinery. Pull a repeated structure (e.g. a campus's Kids area)
into a function and call it once per campus, prefixing every key with
`${campus}_` to keep each instantiation's resources unique and its managed
hierarchy (`parents`) scoped to that campus. `ct plan`/`ct apply` order
campuses → groups → hierarchy automatically via the dependency graph, and
an undeclared `parents` reference throws at config-load time (typo guard).
See [`docs/blueprints.md`](docs/blueprints.md) for the full guide and
[`examples/campus-blueprint.config.ts`](examples/campus-blueprint.config.ts)
for a runnable example.

### Auto-groups

A group can opt in to a `dynamic` block to manage its ChurchTools "dynamic
group" (auto-group) ruleset + status alongside its plain fields:

```ts
ct.group({
key: "all_mainz",
name: "Alle Mainz",
groupTypeId: 1,
dynamic: { status: "manual", ruleset: churchQueryRuleset /* inline object | { ref } | q + churchQuery */ },
});
```

`ct apply --refresh` opts in to a post-apply, per-group membership
recompute for every dynamic group that changed. See
[`docs/dynamic-groups.md`](docs/dynamic-groups.md) for the full guide (the
`status` states, the three ways to supply a ruleset, the typed query DSL,
and how drift normalization avoids cosmetic false diffs) and
[`examples/dynamic-group.config.ts`](examples/dynamic-group.config.ts) for a
runnable example.

### Permissions

`ct.groupRole` / `ct.groupTypeRole` declare ChurchTools permission grants
(group-role and group-type-role rights) as code, reconciled with the same
`plan`/`apply` workflow:

```ts
ct.groupTypeRole({
key: "leiter_tpl",
id: 1, // the domainId — the group type's own id for group_type_role
grants: [
"churchgroup:view", // unscoped
{ right: "churchgroup:view group", scope: ["kids_area"] }, // scoped to a managed group
],
});
```

Right names (`"module:right"`) are validated against a static, offline
catalog — discover them with `ct get permissions-catalog`. Grants you remove
from the config are diffed as deletions and reconciled via `ct apply`, same
as any other resource; system-default and inherited grants are never touched
or surfaced. See [`docs/permissions.md`](docs/permissions.md) for the full
guide (domainId semantics, scope resolution, domain rules, the
baseline-tolerance model) and
[`examples/permissions.config.ts`](examples/permissions.config.ts) for a
runnable example.

## Auth model

The personal login token authenticates via a session handshake, **not** an
Expand Down
213 changes: 213 additions & 0 deletions docs/blueprints.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
# Blueprints (parametrized, reusable config)

A "blueprint" is not a feature the tool implements — it's a naming for a
pattern the existing config DSL already supports for free. `ct.config.ts`
default-exports a plain function that receives the injected
[`ConfigContext`](../src/config/context.ts) (`ct.campus`, `ct.group`,
`ct.groupTypeRole`, ...) and calls it however you like: in a loop, from a
helper function, across multiple files. There is no special "blueprint"
API, decorator, or registration step — just TypeScript functions closing
over `ct` (issue #7).

See [`examples/campus-blueprint.config.ts`](../examples/campus-blueprint.config.ts)
for a complete, runnable example; this doc walks through what it does and
why.

## A blueprint is a function over `ConfigContext`

Pull the campus-specific parts of a config out into a function that takes
`ct: ConfigContext` plus whatever varies per instantiation (a campus key, a
label, an id):

```ts
import type { ConfigContext } from "../src/config/context.js";

function kidsArea(ct: ConfigContext, campus: string): void {
const lead = `${campus}_kids_lead`;
ct.group({ key: lead, name: `${campus} · Kids Leitung`, groupTypeId: 2, parents: [] });
// ...more ct.group(...) calls scoped to this campus
}
```

Calling `kidsArea(ct, "mainz")` and `kidsArea(ct, "berlin")` from the same
default export declares two independent Kids-area structures — one per
campus — using the same code. That's the whole mechanism: no templating
language, no generated files, just a function called twice.

## The loop-over-campuses pattern and `${campus}_`-prefixed keys

Because every declared resource needs a config-wide-unique `key`
(`createContext`'s `define` throws on a duplicate), a blueprint that's
instantiated per campus must prefix every key it declares with the thing
that makes it unique — typically the campus key:

```ts
const CAMPUSES = ["mainz", "berlin"] as const;

export default (ct: ConfigContext): void => {
for (const campus of CAMPUSES) {
ct.campus({ key: campus, name: `Campus ${campus}`, shorty: campus.slice(0, 3).toUpperCase() });
kidsArea(ct, campus); // declares mainz_kids_lead, mainz_kids_0_3, ... / berlin_kids_lead, ...
}
};
```

`${campus}_kids_lead`, `${campus}_kids_0_3`, `${campus}_kids_checkin`, and so
on give every campus's copy of the structure its own non-colliding key
namespace, while the *shape* (lead group + N ministry teams) stays defined
once in `kidsArea`. Add a third campus to `CAMPUSES` and the same function
produces a third, fully independent structure — no changes to `kidsArea`
itself.

## `parents` scopes hierarchy per campus

Group hierarchy (`parents`) is opt-in and references other groups **by
key** (see [`docs/dynamic-groups.md`](dynamic-groups.md) and the DSL doc
comment in [`src/config/context.ts`](../src/config/context.ts)). Because
each campus's group keys are prefixed, a blueprint's `parents: [lead]`
inside `kidsArea` naturally scopes hierarchy to that one campus's own
`lead` variable — `berlin_kids_0_3`'s `parents` can only ever point at
`berlin_kids_lead` (the closure captured `lead = "berlin_kids_lead"` for
that call), never at Mainz's tree:

```ts
function kidsArea(ct: ConfigContext, campus: string): void {
const lead = `${campus}_kids_lead`;
ct.group({ key: lead, name: `${campus} · Kids Leitung`, groupTypeId: 2, parents: [] });
for (const [suffix, label] of [["0_3", "0–3"], ["4_6", "4–6"], ["checkin", "Check-in"]] as const) {
ct.group({
key: `${campus}_kids_${suffix}`,
name: `${campus} · Kids ${label}`,
groupTypeId: 2,
parents: [lead], // always this campus's own lead group
});
}
}
```

No cross-campus hierarchy edges can leak in by accident — the prefixing
convention and lexical scoping do that for you.

## Composing an auto-group and a permission grant inside a blueprint

Because a blueprint is just code calling `ct.*`, it can freely mix resource
types. `kidsArea` declares a `dynamic` block (an auto-group, #14 — see
[`docs/dynamic-groups.md`](dynamic-groups.md)) on the "all members" group
right alongside the plain groups:

```ts
ct.group({
key: `${campus}_kids_all`,
name: `${campus} · Kids (alle)`,
groupTypeId: 2,
parents: [lead],
dynamic: {
status: "manual",
ruleset: {
description: `Alle aktiven Kids-Mitarbeiter ${campus}`,
importance: 0,
personIdFieldName: "person.id",
process: {},
query: churchQuery(q.eq("person.isArchived", false)),
},
},
});
```

And the outer default export layers a permission grant (#13 — see
[`docs/permissions.md`](permissions.md)) on a shared `groupTypeRole`
template, declared once and applying across every campus's groups of that
type:

```ts
export default (ct: ConfigContext): void => {
for (const campus of CAMPUSES) {
ct.campus({ key: campus, name: `Campus ${campus}`, shorty: campus.slice(0, 3).toUpperCase() });
kidsArea(ct, campus);
}
const kidsLeads = CAMPUSES.map((c) => `${c}_kids_lead`);
ct.groupTypeRole({
key: "kids_lead_tpl",
id: 2,
grants: [
{ right: "churchgroup:view group", scope: kidsLeads },
{ right: "churchgroup:edit group memberships of group", scope: kidsLeads },
],
});
};
```

There's nothing blueprint-specific about `dynamic` or `groupTypeRole` here
— they're the same DSL calls documented in
[`docs/dynamic-groups.md`](dynamic-groups.md) and
[`docs/permissions.md`](permissions.md), just invoked from inside a
reusable function instead of inline at the top level.

## Ordering is automatic

`ct plan` / `ct apply` order every declared resource with
[`orderKeys`](../src/engine/graph.ts), which sorts by a fixed type tier
(`campus` / `group-type` / ... at tier 0, `group` at tier 1) and then, within
a tier, by dependency edges — honouring ties by original declaration order.
A blueprint doesn't need to worry about sequencing:

- **Campuses before groups.** `ct.campus(...)` is tier 0; `ct.group(...)` is
tier 1, so every campus in the loop is created before any of that campus's
groups, regardless of call order inside the loop body.
- **Parent groups before child groups.** `parents: [lead]` adds `lead` as a
dependency edge on the child (`toDesired` folds `parents` into
`dependsOn`), so `orderKeys` always places `${campus}_kids_lead` before
`${campus}_kids_0_3`/`_4_6`/`_checkin`/`_all` — the topological sort
guarantees it structurally, not by declaration order.
- **Hierarchy and auto-group state ride along with their group.** `parents`
and `dynamic` are *synthetic fields* on the `group` resource itself (see
`SYNTHETIC_FIELDS` in [`src/engine/synthetic.ts`](../src/engine/synthetic.ts)),
not separate resources with their own tier — they're diffed and applied as
part of that same group's create/update, once the group (and, for
`parents`, its referenced parent groups) already exist.
- **Permissions apply after the structural plan.** `ct.groupTypeRole` /
`ct.groupRole` declarations aren't part of `orderKeys`'s dependency graph
at all — they go through a separate plan/apply pass
(`buildPermissionPlan` / `applyPermissionPlan`) that `ct apply` runs only
after the structural plan (campuses, groups, hierarchy, auto-groups) has
been applied. So a scoped grant's `scope: ["some_group_key"]` can safely
reference a group declared earlier in the same blueprint, even one created
in this very run.

Net effect for a two-campus blueprint like the example: `ct plan` always
produces campuses → lead groups → ministry-team groups (in per-campus,
per-group declaration order) → permission grants, for as many campuses as
the loop instantiates, with no manual `parent:`/`dependsOn` bookkeeping
beyond the `parents: [lead]` you'd write anyway.

## The managed-parent typo guard

`parents` references are validated **at config-evaluation time**, before
any plan or diff is computed (`validateReferences` in
[`src/config/context.ts`](../src/config/context.ts), run by
`evaluateConfig`). Every key listed in a `parents` array must resolve to a
`group` declared *somewhere in the same config* — including inside a
blueprint function called from the top-level export. A typo, a forgotten
`kidsArea(ct, campus)` call, or a `parents` key pointing at a non-group
resource throws immediately:

```
Group "berlin_kids_0_3" declares hierarchy parent "berlin_kids_laed", which is not declared in this config.
Managed parents must reference a group by its key (omit unmanaged parents entirely).
```

This matters more in a blueprint than in a hand-written flat config,
because the `${campus}_`-prefixed key is itself computed
(`` `${campus}_kids_lead` ``, not a literal string) — a copy-paste slip in
one branch of a blueprint (e.g. reusing `mainz`'s lead key inside the
`berlin` iteration) is exactly the kind of mistake this guard exists to
catch before it ever reaches `ct plan` against a live instance.

## Full example

See [`examples/campus-blueprint.config.ts`](../examples/campus-blueprint.config.ts)
for the complete, runnable config this doc describes: a `kidsArea(ct,
campus)` blueprint instantiated over two campuses (`mainz`, `berlin`),
each producing a lead group, three ministry-team groups (managed hierarchy
under the lead), and a dynamic "all members" auto-group — plus one
`groupTypeRole` permission grant shared across both campuses.
Loading
Loading