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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,21 @@ ct get groups # JSON to stdout — pipe into jq (every page, not
ct adopt campus 0 # bring ONE existing resource under management
ct coverage # what the instance has that the config does not manage
ct state list # what is managed
ct state rm campus mainz # un-adopt: drop it from state. Never touches ChurchTools.
ct plan # diff the config against ChurchTools (read-only)
ct apply # create + update in dependency order (confirm + backup first)
ct refresh --group <key> # make ChurchTools re-evaluate one auto-group now
```

`state rm` is the inverse of `adopt`, and only of `adopt`: it removes the entry
from the state file, makes no HTTP call, and leaves the resource in place in
ChurchTools, now unmanaged. It refuses a key the config still declares — that
would make the next plan propose creating a resource that already exists — so
delete the declaration first, or pass `--force` to do both in one change.
"Declares" covers permission declarations too, not only resources: a key named
by a `ct.groupRole` domain or a group scope is just as broken to remove, and the
refusal is what keeps that from surfacing one command later as a plan error.

`apply` reconciles **creates and updates** only, saving state after each action
(crash-safe / resumable). It **never deletes**: a resource dropped from the
config is surfaced as a notice pointing at `destroy`. `destroy --target <key>`
Expand Down
111 changes: 101 additions & 10 deletions 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: 9a8c3091484e9258
sources_hash: d67f76530e841833
reviewed: 2026-08-17
---

Expand Down Expand Up @@ -140,20 +140,40 @@ ct adopt group <id> --with-dynamic # portablized (the d
ct adopt group <id> --with-dynamic --no-portable-rulesets # verbatim, this host's ids
```

> **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
> valid when exactly one group is resolved"). Any resource whose adopted key
> differed from the derived key therefore used to be **silently re-keyed** by a
> routine refresh, so the config's declaration matched nothing in state and the
> next plan read as "one to create, one to destroy" for a resource that was fine
> and untouched. An already-managed resource now keeps its key, and says what it
> would have become:
>
> ```text
> ! merkmal_alle_2_5_mz: key would change to "alle_2_bis_5_mz" (derived from the live name).
> Keeping the adopted key. Pass --rekey to change it.
> ```
>
> Because the ruleset filename follows the key, this also means the refresh
> overwrites `rulesets/merkmal_alle_2_5_mz.json` — the file the config already
> points at — instead of writing a second one beside it.

`ct adopt group --with-dynamic` runs the captured (normalized)
ruleset through `portablizeRuleset` (`src/config/query-refs.ts`) before writing
`rulesets/<key>.json`: every numeric id sitting in a known ChurchQuery `var`
position that maps to a **managed** logical key is rewritten to its `{ __ctRef }`
marker; every other id is left numeric. The `var → RefKind` catalog it keys off
(`VAR_REF_KINDS`) is:

| ChurchQuery `var` | marker `kind` | source catalog / state |
| --------------------- | ----------------- | ---------------------------------------- |
| `ctgroup.id` | `group` | managed state (no REST catalog) |
| `ctgroup.campusId` | `campus` | `/campuses` |
| `person.campusId` | `campus` | `/campuses` |
| `ctgroup.groupTypeId` | `group-type` | `/group/grouptypes` |
| `role.id` | `group-type-role` | `/group/roles` (by `groupTypeId` + name) |
| ChurchQuery `var` | marker `kind` | source catalog / state |
| --------------------- | ----------------- | -------------------------------------------------- |
| `ctgroup.id` | `group` | managed state (no REST catalog) |
| `ctgroup.campusId` | `campus` | `/campuses` |
| `person.campusId` | `campus` | `/campuses` |
| `ctgroup.groupTypeId` | `group-type` | `/group/grouptypes` |
| `role.id` | `group-type-role` | `/group/roles` (by `groupTypeId` + name) |
| `role.id` | `role-def` | managed state — only when the pair collides (#125) |

The same `group-type-role` rewrite also covers the **out-of-query** integer
field `process.*.handleMembership.groupTypeRoleId` (the target role a
Expand All @@ -172,7 +192,53 @@ is a **groupTypeRoleId**: a role scoped to a group **type**. Role names are
marker carries the group-type key + role name, and the resolver picks the one
`/group/roles` row whose `groupTypeId` matches this host's group type and whose
name slugs to the role. This corrects the earlier `role-def` mapping (#86),
which was unresolvable on the real instance.
which read the role catalog by bare name and was unresolvable on the real
instance.

##### When the pair collides too — adopt the role (#125)

"Unique in practice" is not "unique". Two rows **can** share a name on one group
type, and then the pair is a hard error:

```text
✗ Ambiguous group-type-role(groupType=community, role=leader) referenced at group "4_teamactive":
2 roles on group type #30 match — "leader" (#87), "leader" (#207).
```

Neither obvious remedy works for a config shared across hosts. _Renaming_
means editing ChurchTools master data to work around a config limitation — and
in the observed case one of the two rows is CT's own stock `leader`. _Passing a
numeric id_ cannot work at all: the roles have **different ids per host**
(`#127` on one, `#207` on the other), and `ConfigContext` deliberately exposes
no env or host, so there is nowhere to branch.

The fix is the same "adopt the target to portablize it" move that already works
for groups, campuses and departments — **adopt the role**, under the same
logical key on every host:

```bash
ct adopt group-role 127 --env prod -k community_leader
ct adopt group-role 207 --env dev -k community_leader
```

Capture then emits the managed form, which this host resolves from state rather
than from a name lookup:

```json
{ "__ctRef": true, "kind": "role-def", "key": "community_leader" }
```

**Only when the pair actually collides, though.** The `(groupType, role)` pair
stays the default for every role whose pair is unique — even one the config
owns. Off this host `role-def` is the **weaker** reference of the two: the
resolver falls back to `resolveFromCatalog`, which keys `/group/roles` by
`slug(name)` **alone**. On a host where the role was never adopted under the
shared key, a name matching exactly one row therefore resolves _silently_ to a
role on a different group type (only a multi-row match errors) — the same bare
name lookup that #76 reverted #86 for. The pair cannot fail that way, because it
matches on `(groupTypeId, name)`. So the safe reference is never traded away for
the weaker one; `role-def` is reserved for the ids the pair genuinely cannot
name, where there is no safe reference to lose.

#### What could not be portablized is REPORTED, never swallowed (#101)

Expand All @@ -193,11 +259,36 @@ At **capture** time (`ct adopt … --with-dynamic`) the state file and the
`/group/roles` catalog are both in hand, so the reason is a checked fact:

```text
! rulesets/jugend.json keeps 3 host-specific id(s) — NOT portable to another host:
! rulesets/jugend.json keeps 5 host-specific id(s) — NOT portable to another host:
ctgroup.id: 1246 left numeric — not under management — `ct adopt group <id>` for each (then re-adopt) makes them portable
ctgroup.groupStatusId: 1, 2 left numeric — group statuses have no REST catalog (#67) — no logical form exists
person.id: 5703, 4389 left numeric — person ids are NEVER portable — ct does not manage people, so this ruleset names DIFFERENT people on another host. Remove the clause or accept the divergence
```

##### `person.id` is reported, and it is not fixable (#127)

A ruleset that includes or excludes specific people by id is common — four of
five auto-groups captured in one week did it:

```json
{ "oneof": [{ "var": "person.id" }, [5703, 4389]] }
{ "!": [{ "oneof": [{ "var": "person.id" }, [12, 1]] }] }
```

Those are the source host's person ids written verbatim into the other host's
ruleset, where they name entirely different people — the same failure mode as a
raw `ctgroup.id`. `person.id 1` is the unluckiest case: it exists on every
ChurchTools instance and is almost always an administrator, so an exclusion
aimed at one person on the source host lands on someone real on the target host
rather than harmlessly matching nothing.

Unlike every other entry in the report, this one is **not fixable in config**,
and the wording says so rather than offering a command. `ct` correctly does not
manage people, so there is no person catalog to resolve against and no
`__ctRef` kind that could express it. The ask is only that the tool say so:
before #127 it was silent, and the absence of a warning actively implied there
was nothing to find.

At **plan** time the same scan runs over every declared dynamic group, but with
no state, no catalogs and no network — it can prove that an id sits in an entity
position and nothing more. So it says exactly that, rather than asserting a
Expand Down
101 changes: 76 additions & 25 deletions docs/handbuch/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ sources:
- src/resolve/resolver.ts
- src/resolve/refs.ts
- src/config/context.ts
sources_hash: 5f9ed09f1e3793b0
sources_hash: 87fe6c847d70d89c
reviewed: 2026-08-17
---

Expand Down Expand Up @@ -631,18 +631,29 @@ In bulk mode:
- a role instance blocked by an undeclarable scope dimension is skipped and
named, with the dimension to pass to [`preserveUnknown`](#partial-ownership-preserveunknown-opt-in-102).
The single-domain form still emits it, deliberately, one domain at a time;
- both of those decisions are made over the **effective** rows — the same set the
block will emit (#114/#119), not just the ones `ct` owns. That matters in both
directions: a domain carrying its rights only as _inherited_ rows is still
emitted (judging owned rows would call it empty, skip it, and leave those
rights for the other host's plan to revoke), and an inherited grant on a
dimension with no logical form still blocks the domain (judging owned rows
would hide it from the gate and emit a host-specific numeric `dataId` into a
bulk paste). Ownership itself is unchanged — that is still decided by
`normalizeActual` at plan time, never by what adoption prints;
- nothing is capped silently — the run prints how many blocks it emitted **and**
how many it skipped, with the reason for each.

### The single-domain form

It fetches `GET /permissions/<domainType>/<domainId>`, runs the rows through the
**same** normalization the planner uses (`normalizeActual`), and prints a
`ct.groupRole` / `ct.groupTypeRole` block whose every emitted grant is guaranteed
to be accepted by `ct plan` (the round trip is locked by tests):

- **Excluded, as reconciliation excludes them:** the system baseline
(`meta.modifiedPid === -1`) and inherited rows.
**same** normalization the planner reconciles against (`normalizeEffective`), and
prints a `ct.groupRole` / `ct.groupTypeRole` block whose every emitted grant is
guaranteed to be accepted by `ct plan` (the round trip is locked by tests):

- **Everything the host grants is emitted** — direct, inherited and
system-baseline rows alike (#114/#119). See [Provenance and
portability](#provenance-and-portability-114119) for why this is broader than
what `ct` owns.
- **Revoke/deny rows are preserved, not emitted.** The reconciler never deletes a
deny it did not author; if any exist, the block ends with a `NOTE` comment
saying so (authoring denies as config is a separate, unshipped feature).
Expand Down Expand Up @@ -673,12 +684,14 @@ to be accepted by `ct plan` (the round trip is locked by tests):
- A scoped right granted **globally** in CT (row with no `dataId`) is a
`WARNING` comment either way — the DSL deliberately cannot declare a
global grant of a scoped right.
- **System-baseline and inherited rows are dropped, not emitted.** Adoption runs
the live rows through `normalizeActual`, which excludes the self-re-adding
system baseline (`modifiedPid === -1`) and any `isInherited` row. Admin-authored
direct grants — **including** the writable `authId >= 10000` `churchdb:+…`
member rights CT lets you set on `group_type_role` — are emitted as active
grants (no authId cutoff; #65).
- **The `-1` "alle" sentinel is not an id** (#115). A grant scoped to "alle"
comes back as `dataId: -1`, meaning _every_ value of the dimension on whatever
host reads it. It is therefore already portable, and it is emitted with a
one-line comment saying what it is — never with an adoption hint, because
`ct adopt department -1` names a resource that cannot exist.
- **Admin-authored member rights are emitted as active grants** — **including**
the writable `authId >= 10000` `churchdb:+…` member rights CT lets you set on
`group_type_role`. There is no authId cutoff (#65).
- **Only `group_role` / `group_type_role` / `status`** are valid; people domains
are refused (the same hard boundary as everywhere else). A `status` block is
emitted with a numeric `id:` — rename it to the portable
Expand All @@ -703,11 +716,13 @@ and `ct plan`.
- **No authId cutoff.** Admin-authored member rights (`authId >= 10000`, the
`churchdb:+…` family) ARE writable on `group_type_role` and can be declared
under `ct.groupTypeRole` — verified live (eqrm prod `group_type_role/9` carries
24 such admin-set rows). What ct never reconciles is decided by the live row's
24 such admin-set rows). What ct never _writes_ is decided by the live row's
flags, not its authId: `normalizeActual` drops the system baseline
(`modifiedPid === -1`) and every `isInherited` row, so those are neither adopted
nor revoked (#65). Earlier versions blocked `authId >= 10000` outright — that was
too broad and is removed.
(`modifiedPid === -1`) and every `isInherited` row, so those are never authored
and never revoked (#65). They are still **emitted by adoption and honoured as
satisfying a declaration** — see [Provenance and
portability](#provenance-and-portability-114119). Earlier versions blocked
`authId >= 10000` outright — that was too broad and is removed.
- **Revocation is a later extension, not exposed yet.** `GrantTuple.type` is
typed as `"grant" | "revoke"`, but the DSL and `desiredTuples` currently
only ever _emit_ `"grant"` tuples — there is no config-level way to declare
Expand All @@ -719,17 +734,53 @@ and `ct plan`.

## The baseline-tolerance model

`ct plan` and `ct apply` reconcile only the grants **you author** on a
domainId — never the platform's own bookkeeping. `normalizeActual`
(`src/permissions/grants.ts`) filters two kinds of rows out of every actual
fetch before diffing, making both invisible to reconciliation:
`ct plan` and `ct apply` **write** only the grants you author on a domainId —
never the platform's own bookkeeping. Two kinds of row are never authored and
never revoked (`normalizeActual` in `src/permissions/grants.ts`):

- **System baseline rows** — any row with `meta.modifiedPid === -1`. These
are ChurchTools' own self-re-adding defaults; they are never proposed for
deletion and never conflict with a desired grant.
are ChurchTools' own self-re-adding defaults.
- **Inherited rows** — any row with `isInherited: true`. These come from
hierarchy/role inheritance, not this domainId's own grant table; they are
not owned here either.
hierarchy/role inheritance, not this domainId's own grant table.

### Provenance and portability (#114/#119)

Those two rules decide what `ct` **owns**. They do _not_ decide whether a
declared grant is already **satisfied** — that is judged against the
**effective** set: every right the host grants, by any route
(`normalizeEffective`).

The distinction matters because provenance is not stable across hosts of the
same instance, even when the effective permissions are identical:

| | one host | the other host |
| ------------------------------------------------------ | ------------------------------- | --------------------------- |
| **#114** — same right, same role | `meta.modifiedPid: -1` (system) | `modifiedPid: 1` (a person) |
| **#119** — 15 group-member rights the TYPE also grants | `isInherited: true` | `isInherited: false` |

Neither divergence is hand-made: the second host is a copy, and the copy
stamped a person id onto rows that are system rows upstream. Measured across
two hosts on CT 3.135.2: 18 of 63 `group_role` domains carried inherited rows
on one, and **zero anywhere** on the other.

Deciding satisfaction from ownership made such a config impossible to write.
Omit the right (as adoption used to emit it) and the other host **revokes** it;
declare it and this host plans `+1 grant` forever. So the domain had to be left
out of adoption entirely, even though nothing about it is genuinely
undeclarable.

Reconciling on the effective set removes the dilemma, because the two hosts
agree on effective permissions even when they disagree on provenance:

- A declared right the host already grants by any route needs **no PUT**.
- A revoke is still computed from the **owned** set only, so `ct` never revokes
a baseline or inherited row.
- `ct adopt grants` emits the effective set, and names in a footer how many of
the emitted grants are inherited or baseline on this host.

The "never fight the platform" property is unchanged. The only thing that
changed is that `ct` stopped proposing to re-author what the platform already
grants.

Combined with the **managed-guard** (`buildPermissionPlan` only ever surfaces
the `domainId`s you've declared — a bulk `GET /permissions/{domainType}`
Expand Down
Loading
Loading