Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,14 +169,15 @@ confirmation — and a declaration marked `preventDestroy: true` blocks even tha

## What it manages

| Resource | DSL | Guide |
| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| Campuses | `ct.campus` | [config guide](docs/configuration.md) |
| Groups (fields, campus, multi-parent hierarchy) | `ct.group` | [config guide](docs/configuration.md) |
| Group types, roles, age/target groups, relationship types, person statuses | `ct.groupType`, `ct.roleDefinition`, `ct.ageGroup`, `ct.targetGroup`, `ct.relationshipType`, `ct.personStatus` | reusable building blocks |
| Permissions (group-role, group-type-role, person-status) | `ct.groupRole`, `ct.groupTypeRole`, `ct.status` | [permissions](docs/handbuch/permissions.md) |
| Auto-groups (dynamic groups) | the `dynamic` block on a group | [dynamic groups](docs/handbuch/dynamic-groups.md) |
| Repeated structure, parametrized | a plain function over the DSL | [blueprints](docs/handbuch/blueprints.md) |
| Resource | DSL | Guide |
| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| Campuses | `ct.campus` | [config guide](docs/configuration.md) |
| Groups (fields, campus, multi-parent hierarchy) | `ct.group` | [config guide](docs/configuration.md) |
| Group types, roles, age/target groups, relationship types, person statuses | `ct.groupType`, `ct.roleDefinition`, `ct.ageGroup`, `ct.targetGroup`, `ct.relationshipType`, `ct.personStatus` | reusable building blocks |
| Permissions (group-role, group-type-role, person-status) | `ct.groupRole`, `ct.groupTypeRole`, `ct.status` | [permissions](docs/handbuch/permissions.md) |
| Auto-groups (dynamic groups) | the `dynamic` block on a group | [dynamic groups](docs/handbuch/dynamic-groups.md) |
| Group member field definitions (group-scoped, never deleted implicitly) | the `memberFields` block on a group | [group member fields](docs/handbuch/group-member-fields.md) |
| Repeated structure, parametrized | a plain function over the DSL | [blueprints](docs/handbuch/blueprints.md) |

Read-only by design: the person master-data model, security levels and
custom-field _definitions_ (`ct get person-masterdata`, `ct get data-fields`) —
Expand Down
3 changes: 3 additions & 0 deletions docs/api-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ Methods marked only if they actually exist on the matched path. "Update" = PUT o
| 13 | `ct_person_status` | `/statuses`, `/statuses/{id}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD, live-verified 2026-08-13** (eqrm prod, CT 3.135.2, read from the instance OpenAPI spec): `/statuses` → GET, POST; `/statuses/{id}` → GET, PUT, DELETE. `POST` requires `name`, `shorty`, `isMember`; `PUT` requires ALL of `name`, `shorty`, `isMember`, `isSearchable`, `sortKey`, `securityLevelId` — uniquely strict among managed types (every other managed PUT declares no required fields), and since PUT is a full replace the registry manages all six rather than a subset. The person/community **Status** master data (tag `Status`): "0 - First", "3 - Group Active", …, the domain a `ct.status` permission grant hangs off. Adoptable since #96, which is what makes a config using that domain self-sufficient across hosts. Do NOT conflate with `ct_group_status` (row 8, no endpoint at all) or `/group/memberstatus` (member statuses, string ids). Master data — never a person record; the people guard is unaffected. |
| 14 | `ct_department` | `/departments` | list ✅ / by-id ❌ | ❌ | ❌ | ❌ | **No REST write path — a ref catalog here, not a managed resource.** Live-probed 2026-08-13 (eqrm prod, CT 3.135.2): `GET /departments` returns `[{id, name, nameTranslated, sortKey, shorty}]`; no `POST`/`PUT`/`DELETE` on `/departments` exists in the spec, and there is no `/departments/{id}` path at all. Bereiche are the `cdb_bereich` permission scope dimension (`churchdb:view alldata`), so `ct` resolves them BY NAME for a `scope: [{ department: "…" }]` reference (#98) and surfaces them via `ct get departments` — but `ct.department` declares one and `ct adopt department <id>` adopts one. **Managed since #108** — `ct` writes Bereiche through the legacy `POST /index.php?q=churchdb/ajax` `func=saveMasterData` interface the admin UI uses (create/update/delete all verified live on eqrm-dev 2026-08-14). It appears in no OpenAPI spec, which is why an OpenAPI-only audit could never have found it (#111). Note there is no `/departments/{id}` at all, so a single Bereich is read by filtering the collection. |

| 15 | `ct_group_member_field` | `/groups/{groupId}/memberfields` (GET), `/groups/{groupId}/memberfields/group` (POST), `/groups/{groupId}/memberfields/group/{groupMemberFieldId}` (PATCH/PUT/DELETE) | list ✅ (per group) / by-id ❌ | ✅ | ✅ PATCH (PUT fallback) | ✅ | **Full CRUD, but GROUP-SCOPED — not a standalone resource (#135).** A member field belongs to exactly one group and is not globally reusable, so it has no collection path of its own and no entry in the resource registry: it is a _synthetic owned sub-resource_ of `ct_group`, declared inside `ct.group({ memberFields: [...] })`, exactly like hierarchy `parents` and the dynamic ruleset. Its portable identity is the managed group key plus a local field key (`ojbp_2026_27_praktikum_1::wahl`); a ChurchTools field id never reaches authored config or an adopted blueprint. Reads are narrowed to rows the `/memberfields/group` endpoints own — the same GET also returns fields sourced from person master data / group-type defaults, which are neither adoptable nor writable here. `apply` NEVER deletes one (a field dropped from config produces no desired diff key at all); removal is the explicit `ct destroy --member-field <group>::<field>`. Update uses `PATCH` (partial, so unmanaged siblings survive) and falls back to `PUT` on a 405/501. Definitions only — never a per-person value; `assertNotPeople` still guards every path. Distinct from the group CUSTOM fields of #48/#60 (`/dbfields`, `fieldCategory.table == "cdb_gruppe"`), which describe the group RECORD rather than what its members are asked. |

> **Create-time required fields (#73).** The "POST ✅" marks above were spec-derived, not live-exercised for
> create. CT's POST validators require fields the tool does not manage for diffing: `group-type` needs
> `namePlural`/`shorty`/`color`/`permissionDepth`/`isLeaderNecessary`/`availableForNewPerson` (+ `sortKey`/`postsEnabled`),
Expand Down Expand Up @@ -61,6 +63,7 @@ No collection POST; state is set/removed through PUT/DELETE on the item path. Mo

- `ct_group_hierarchy` — manage parent links via `PUT`/`DELETE /groups/{groupId}/parents/{parentGroupId}`
- `ct_permission` — assign/revoke via `PUT`/`DELETE /permissions/{domainType}/{domainId}`
- `ct_group_member_field` — full CRUD verbs, but only ever **under a group** (#135), so it is a synthetic owned sub-resource rather than a registry type: declared as `memberFields` on `ct.group(...)`, applied inline with that group (after it exists, before its ruleset), and deleted only by the explicit `ct destroy --member-field <group>::<field>`

### Partial — 1 resource

Expand Down
21 changes: 16 additions & 5 deletions docs/handbuch/blueprints.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ sources:
- src/config/context.ts
- src/engine/graph.ts
- src/engine/hierarchy.ts
sources_hash: 37db6b8730470f73
sources_hash: ed5171960e9206fa
reviewed: 2026-08-17
---

Expand Down Expand Up @@ -201,12 +201,15 @@ A blueprint doesn't need to worry about sequencing:
`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`](https://github.com/eqrm/ct-cli/blob/main/src/engine/synthetic.ts)),
- **Hierarchy, member fields and auto-group state ride along with their
group.** `parents`, `memberFields` and `dynamic` are _synthetic fields_ on
the `group` resource itself (see `SYNTHETIC_FIELDS` in
[`src/engine/synthetic.ts`](https://github.com/eqrm/ct-cli/blob/main/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.
`parents`, its referenced parent groups) already exist. Within one group
they are written in that registered order, so a group's member fields exist
before a ruleset that references them is installed.
- **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
Expand Down Expand Up @@ -238,6 +241,14 @@ Group "berlin_kids_0_3" declares hierarchy parent "berlin_kids_laed", which is n
Managed parents must reference a group by its key (omit unmanaged parents entirely).
```

The same pass validates **group member field references** (#135): a
`ref.groupMemberField("<group>", "<field>")` anywhere in a declaration — a
dynamic ruleset included — must name a group declared in this config that
declares that field, or `evaluateConfig` throws before any plan runs. Member
fields are the surface where this matters most in a blueprint, because a field
is owned by exactly one group: a blueprint instantiated twice creates two
independent `wahl` fields, and a reference must say _which group's_.

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
Expand Down
38 changes: 37 additions & 1 deletion docs/handbuch/dynamic-groups.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ sources:
- src/engine/dynamic.ts
- src/engine/synthetic.ts
- src/commands/adopt-group.ts
sources_hash: 13e4a54533b0e8a2
sources_hash: a0be8585fb6a48dc
reviewed: 2026-08-17
---

Expand Down Expand Up @@ -67,6 +67,38 @@ the group's own tier-1 apply, after the group itself has been
created/updated — so `ct apply` always writes against the group's real
(possibly just-created) id.

That holds on the very first run too, including when the group being made
dynamic is created by that same run and is the _only_ dynamic group in the
config (fixed alongside #135): the desired `dynamic` block is folded whenever
any group declares one, not only when there is already an adopted dynamic group
to read.

The group's own synthetic fields are written **in declaration order**, and that
order is fixed by the synthetic-field registry in `src/engine/synthetic.ts`:
hierarchy `parents`, then `memberField:*`, then `dynamic`. So a group's
[member fields](group-member-fields.md) are created before the ruleset that may
reference them.

### Referencing a group member field portably (#135)

A ruleset that names one of a group's member fields must not carry that host's
numeric field id — it is not portable, and ChurchTools validates none of the ids
inside a ruleset, so the wrong id applies cleanly and silently computes the
wrong membership. Use the group-scoped reference instead:

```ts
ref.groupMemberField("ojbp_2026_27_praktikum_1", "wahl");
```

The resolver maps it to this host's id from
`GET /groups/{groupId}/memberfields`. A field this config declares but that does
not exist on the host yet resolves to a pending marker and is completed during
apply, right after the create that minted it. A reference to a field the target
group does not declare fails at config-eval time, before any network call — the
declared key and the referenced one being compared in their normalised form, so
`"Wahl"` finds a field declared as `wahl`. See
[Group member fields](group-member-fields.md).

## Supplying a ruleset — three ways

1. **Inline `RuleSet` object literal** — write the object by hand (e.g.
Expand Down Expand Up @@ -140,6 +172,10 @@ ct adopt group <id> --with-dynamic # portablized (the d
ct adopt group <id> --with-dynamic --no-portable-rulesets # verbatim, this host's ids
```

`--with-member-fields` is the sibling opt-in for a group's own
[member-field definitions](group-member-fields.md); combine the two to capture a
whole structure — group, fields, ruleset — in one pass.

> **A re-adopt refreshes the snapshot, not the key (#123).** Re-running this over
> a list of ids is the documented way to refresh rulesets once their scope
> targets become managed — and it is the one mode where `-k` is rejected ("only
Expand Down
Loading
Loading