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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,18 @@ export default (ct) => {
// in this config. Omit it to leave a group's hierarchy unmanaged; edges to unmanaged
// groups stay invisible. (`parent:` is unrelated — an ordering hint only, not hierarchy.)
ct.group({ key: "mainz_kids_lead", name: "Mainz · Kids Leitung", groupTypeId: 2, parents: ["mainz_area"] });
// Assign a group to a campus by its numeric id (CT stores it at `information.campusId`).
// `campusId: null` clears the assignment. A *logical* `campus: "mainz"` reference — resolving a
// same-run campus by key — is deferred to #20; use the existing campus's numeric id for now.
ct.group({ key: "mainz_kids", name: "Mainz · Kids", groupTypeId: 2, campusId: 3, parents: ["mainz_kids_lead"] });
};
```

`campusId` is a managed group field: `ct plan` shows a campus assign/move/clear
as a normal field update, and `ct adopt group <id>` captures it. Which group
fields are managed vs. deliberately left to the CT UI is recorded in
[`docs/group-field-decisions.md`](docs/group-field-decisions.md).

Machine-readable output goes to **stdout** (pipe/`jq` it); human status lines go
to **stderr**.

Expand Down
15 changes: 15 additions & 0 deletions docs/blueprints.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,21 @@ 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.

### Assigning the blueprint's groups to their campus

A group is linked to a campus with a numeric `campusId: <existing campus id>`
(CT stores it at `information.campusId`; `campusId: null` clears it). `ct plan`
diffs a campus assign/move/clear as a normal field update — see
[`docs/group-field-decisions.md`](group-field-decisions.md).

The catch for a per-campus blueprint: the blueprint usually *creates* the campus
in the same apply, and a numeric id doesn't exist until after that create. So
linking a group to a **same-run** campus by key (`campus: "mainz"`) needs the
logical-reference resolver and is deferred to
[#20](https://github.com/eqrm/ct-cli/issues/20). Until then, assign to an
**existing** campus by hardcoding its numeric id, or apply the campuses first and
fill in the ids on a second pass.

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

Because every declared resource needs a config-wide-unique `key`
Expand Down
69 changes: 69 additions & 0 deletions docs/group-field-decisions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Group field decisions (#21)

`ct-cli` manages the rights-bearing *structure* of a group, not everything the
ChurchTools group object carries. This table records the deliberate decision for
each field seen on the live group / group `PATCH` body: **managed** (diffed and
applied like `name`), **opt-in synthetic** (its own DSL block + endpoint, not the
plain field bag), or **out of scope** (left to the CT admin UI, never touched).

Adding a *managed* field is a real commitment — it needs a registry entry, a diff
test, an adopt round-trip, and a note on state-snapshot migration. So this issue
promotes exactly one new field (`campusId`) and triages the rest rather than
silently widening `managedFields`.

## Where the fields live

CT nests a group's structural ids under an `information` object on the live GET
body (`information.groupTypeId`, `information.groupStatusId`,
`information.campusId`), but accepts them as **top-level** keys on `PATCH`. The
registry mirrors this: it reads via `fromInformation(...)` (nested, with a
top-level fallback) and writes the field-agnostic way the executor writes every
field — a plain top-level key. `campusId` is wired the same deliberate way as
`groupTypeId`.

## Decision table

| Field | Decision | Rationale |
|---|---|---|
| `name` | **managed** | Core identity; already managed. |
| `groupTypeId` | **managed** | Determines the group's kind and role template; already managed. |
| `groupStatusId` | **managed** | Lifecycle status; already managed. |
| **`campusId`** | **managed (new, #21)** | The campus link is the tool's core "instantiate this area per campus" requirement. Numeric escape hatch only — an existing CT campus id (or `null` to clear). A *logical* `campus: "key"` reference (resolving a same-run campus by key) is **deferred to [#20](https://github.com/eqrm/ct-cli/issues/20)**; the DSL rejects a `campus` field with a pointer to #20 so it can't slip through as an un-diffable phantom. |
| `parents` (hierarchy) | **opt-in synthetic** | Group→group hierarchy is reconciled through its own endpoint, not the group body — see `src/engine/synthetic.ts`. Opt-in via `parents: [...]`. |
| `dynamic` (auto-group ruleset) | **opt-in synthetic** | Ruleset + status live behind a dedicated endpoint (#14); opt-in via the `dynamic` block. See `docs/dynamic-groups.md`. |
| `visibility` | **out of scope** | Not rights-bearing structure; instance-/policy-specific and easily changed in the UI. No demand in #21 to manage it. Promote later only with its own registry entry + tests. |
| `note` | **out of scope** | Free-text annotation, not structure. Managing it would fight human edits in the UI for no structural benefit. |
| `autoAccept` / open-for-members settings | **out of scope** | Membership-request policy — adjacent to *who is in a group*, which the tool never manages (`assertNotPeople`, `src/engine/guard.ts`). Left to the UI. |
| chat status | **out of scope** | Chat/messaging toggle, outside the structural mandate (README: "campuses, structural groups, hierarchies, group types/roles, permission & auto-groups"). |
| sort key | **out of scope** | Presentation ordering, not structure. (Note: `sortKey` *is* managed on the master-data types `age-group`/`target-group`, where ordering is the resource's point; on a group it is cosmetic.) |

## State-snapshot migration

`campusId` is an **additive** managed field, so a group adopted before this issue
has a state snapshot that simply lacks the key. That produces **no phantom drift
and needs no migration**, because:

- the diff is **desired-driven** — `diffFields` only walks the config's fields, so
a config that doesn't declare `campusId` never invents a change for it;
- drift is **snapshot-driven** — `driftFields` only walks the *old* snapshot's
keys, so a key absent there is never surfaced;
- the write body comes from the **fetched actual** ([#27](https://github.com/eqrm/ct-cli/issues/27)),
so an unrelated field update never omits or reverts `campusId`, and the
post-write snapshot self-heals to include it.

Contrast the `shortName → shorty` case (#17): a *renamed* key lingers in the old
snapshot and drifts forever, so it needs an explicit `migrateState` rename. An
*added* key cannot. See the comment on `migrateState` in `src/state/state.ts`.

**Re-adopt guidance:** to capture the campus of an already-managed group into
state immediately (rather than waiting for the next apply to self-heal the
snapshot), re-run `ct adopt group <id>` — `upsert` refreshes the snapshot in
place. This is optional; it does not change plan output.

## Assigning a group to a same-run campus

With numeric ids only, a group can be assigned to an **existing** campus by its
id today. Assigning a group to a campus **created in the same `ct apply`** is not
possible yet: the new campus's id is unknowable at config-eval time. That link
(`campus: "mainz_key"`) is the logical-reference resolver's job and lands with
[#20](https://github.com/eqrm/ct-cli/issues/20).
12 changes: 7 additions & 5 deletions docs/runbook-manual-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ this doc's structure.

| Item | What it is | Tracking issue | Manual workaround today |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Group ↔ campus assignment | `campusId` on a group (`information.campusId` in the live object) — not in `managedFields`, not in the DSL | [#21](https://github.com/eqrm/ct-cli/issues/21) | Set the campus by hand in the group's CT admin page after `ct apply` creates it; re-running `plan` will not detect drift because the field isn't diffed yet |
| Group/group-type field decision table | Fields seen live but not yet triaged (managed / opt-in synthetic / explicitly out of scope): visibility, note, `autoAccept`/open-for-members, chat status, sort key | [#21](https://github.com/eqrm/ct-cli/issues/21) | Set by hand; treat any such field as unmanaged until #21's decision table ships (do not assume `ct` will preserve or diff it) |
| Portable/logical references | Config still hardcodes numeric CT ids (`groupTypeId`, `groupStatusId`, permission `domainId`, dynamic-group ruleset `var` values like `q.eq("ctgroup.campusId", 4)`) instead of resolving keys/names per host | [#20](https://github.com/eqrm/ct-cli/issues/20) | Hand-resolve each id per target host (`ct get group-types`, `ct get campuses`, etc.) and hardcode it in config; a config authored against one instance will not plan correctly against another until this lands |
| Group ↔ campus assignment (same-run) | Assigning a group to a campus **created in the same `ct apply`** — needs the new campus's id at eval time, which is unknowable without the logical-reference resolver. Assignment to an **existing** campus by numeric `campusId` **is managed now** (#21) | [#20](https://github.com/eqrm/ct-cli/issues/20) | Apply the campus first, look up its id (`ct get campuses`), then set `campusId: <id>` on the group and apply again; or assign by hand in the CT admin page. `plan` diffs the numeric `campusId` as a normal field update |
| Group/group-type field decision table | Fields deliberately left unmanaged (decided out of scope): visibility, note, `autoAccept`/open-for-members, chat status, sort key. The triage **shipped** as a committed decision table ([`docs/group-field-decisions.md`](group-field-decisions.md)) | [#21](https://github.com/eqrm/ct-cli/issues/21) (decided) | Set by hand; these fields are intentionally not diffed — `ct` will neither preserve nor revert them. Promote one later only with its own registry entry + tests |
| Portable/logical references | Config still hardcodes numeric CT ids (`groupTypeId`, `groupStatusId`, `campusId`, permission `domainId`, dynamic-group ruleset `var` values like `q.eq("ctgroup.campusId", 4)`) instead of resolving keys/names per host | [#20](https://github.com/eqrm/ct-cli/issues/20) | Hand-resolve each id per target host (`ct get group-types`, `ct get campuses`, etc.) and hardcode it in config; a config authored against one instance will not plan correctly against another until this lands |
| Environments (dev → prod promotion) | Named `(host, token, state file)` profiles and a `--env` flag; today one config + one state file = one host | [#22](https://github.com/eqrm/ct-cli/issues/22) | Point `CT_HOST`/state file manually at each target and re-run; keep dev and prod state files apart yourself, and be careful — nothing stops you from applying a dev-shaped config against prod today |
| Permission `domainId` by reference | `ct.groupRole`/`ct.groupTypeRole` require the numeric `domainId` supplied by hand — for `group_role` this is CT's internal (group, role) _pairing_ id, with **no CLI lookup helper** | [#25](https://github.com/eqrm/ct-cli/issues/25) | Find the pairing id via the CT permission editor, or an existing `GET /permissions/group_role` response for a group+role you already have, and hardcode it ([`docs/permissions.md`](permissions.md) "domainId semantics") |
| Grant adoption | No `ct adopt grants <domain>` — existing rights structures on a live instance must be hand-transcribed into `grants: [...]` config blocks | [#25](https://github.com/eqrm/ct-cli/issues/25) | `ct get raw /permissions/group_role/<id>` (or `group_type_role`), read off the non-inherited, non-baseline rows (`isInherited: false`, `meta.modifiedPid !== -1`), and hand-author the equivalent `grants:` array |
Expand Down Expand Up @@ -101,8 +101,10 @@ in where they'd otherwise be silently skipped:

1. `ct apply` the structural config (campuses, group types, age/target
groups, groups, hierarchy, dynamic groups, permission grants).
2. **Group ↔ campus assignment** — set by hand per group (#21, not yet
diffed).
2. **Group ↔ campus assignment** — declare `campusId: <existing campus id>`
on the group in config; `ct` diffs and applies it like any field (#21).
Only assignment to a campus created in the *same* apply is still manual
(#20) — apply the campus first, then set its numeric id.
3. **Member statuses** — confirm the expected set exists via
`ct get raw /group/memberstatus`; create any missing ones by hand in the
CT admin UI.
Expand Down
9 changes: 8 additions & 1 deletion examples/campus-blueprint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@ import { q, churchQuery } from "../src/config/context.js";

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

/** One campus's Kids area: a lead group with three ministry teams under it, plus a dynamic "all members" group. */
/**
* One campus's Kids area: a lead group with three ministry teams under it, plus a dynamic "all members" group.
*
* Campus assignment note (#21): a group is assigned to a campus with a numeric `campusId: <existing id>`.
* These campuses are created in this same apply, so their ids are unknowable at eval time — linking a group
* to a *same-run* campus by key (`campus: "mainz"`) is the logical-reference resolver's job and lands with #20.
* To assign to an existing campus today, pass its numeric id, e.g. `campusId: 3`.
*/
function kidsArea(ct: ConfigContext, campus: string): void {
const lead = `${campus}_kids_lead`;
ct.group({ key: lead, name: `${campus} · Kids Leitung`, groupTypeId: 2, parents: [] });
Expand Down
13 changes: 13 additions & 0 deletions src/config/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,19 @@ function toDesired(type: string, input: ResourceInput): DesiredResource {
if (parents !== undefined && (!Array.isArray(parents) || parents.some((p) => typeof p !== "string"))) {
throw new Error(`${type} "${key}": "parents" must be an array of string group keys.`);
}
// Campus assignment is a numeric escape hatch only (mirrors `groupTypeId`): `campusId: <id>`.
// A logical `campus: "mainz"` reference is #20's resolver, not built yet — reject it up front
// rather than let an un-diffable `campus` field slip into the bag and drift against the
// managed-only actual forever. Also pin `campusId`'s type so a stray string fails at eval time.
if (fields.campus !== undefined) {
throw new Error(
`${type} "${key}": logical campus references ("campus") are not supported yet — ` +
`use a numeric "campusId" (the existing CT campus id). Logical references land with #20.`,
);
}
if (fields.campusId !== undefined && fields.campusId !== null && typeof fields.campusId !== "number") {
throw new Error(`${type} "${key}": "campusId" must be a number (the CT campus id) or null to clear.`);
}
// `dynamic` is a synthetic field for auto-groups, handled separately from the plain diffed
// field bag. Opt-in: `undefined` means "not a dynamic group" (mirrors `parents`).
let dynamicSpec: DynamicSpec | undefined;
Expand Down
12 changes: 11 additions & 1 deletion src/resources/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,17 @@ export const RESOURCES: Record<string, AdoptableResource> = {
updateMethod: "PATCH",
tier: 1,
deriveKey: (r) => slug(str(r, "name")),
// Campus lives on the live group at `information.campusId` (same nesting as groupTypeId /
// groupStatusId), and PATCH accepts it as a top-level `campusId` — so it is read via
// `fromInformation` and written the same field-agnostic way the executor writes every field.
// Numeric escape hatch only: a *logical* `campus: "key"` reference is #20's resolver, not this.
// Normalise an unset campus to `null` (never `undefined`) so the actual side is deterministic —
// an assign/change/clear all diff against a concrete `null`, and campus id `0` (Mainz) survives.
managedFields: (r) => ({
name: r.name,
groupTypeId: fromInformation(r, "groupTypeId"),
groupStatusId: fromInformation(r, "groupStatusId"),
campusId: fromInformation(r, "campusId") ?? null,
}),
}),
"group-type": define({
Expand Down Expand Up @@ -154,8 +161,11 @@ export function configSnippet(type: string, key: string, fields: Record<string,
}

function tsObject(obj: Record<string, unknown>): string {
// null-valued fields are omitted, not emitted: pasting `campusId: null` would actively
// MANAGE "no campus" (planning a later UI-assigned campus back to null), whereas omission
// leaves the field unmanaged — the safer default for a freshly adopted resource.
const parts = Object.entries(obj)
.filter(([, v]) => v !== undefined)
.filter(([, v]) => v !== undefined && v !== null)
.map(([k, v]) => `${isIdentifier(k) ? k : JSON.stringify(k)}: ${JSON.stringify(v)}`);
return `{ ${parts.join(", ")} }`;
}
Expand Down
8 changes: 8 additions & 0 deletions src/state/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,14 @@ function validateState(parsed: unknown, path: string): State {
* create-required `shorty`. Rename the key on load — only when `shorty` is absent,
* so a post-rename snapshot is never clobbered. The next apply re-writes the real
* value; this just clears the phantom drift so the diff can converge.
*
* Contrast with an *additive* managed field (e.g. group `campusId`, #21): a snapshot from
* before the field was managed simply lacks the key. That produces NO phantom drift and needs
* no migration here, because the diff is desired-driven (`diffFields` only walks the config's
* fields) and drift is snapshot-driven (`driftFields` only walks the old snapshot's keys) — a
* key absent from both sides is never surfaced. The write body comes from the fetched actual
* (#27), so an unrelated update never omits or reverts the new field, and the post-write snapshot
* self-heals to include it. Only a *renamed/removed* key can drift forever; an added one cannot.
*/
function migrateState(state: State): State {
for (const resource of Object.values(state.resources)) {
Expand Down
Loading
Loading