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: 8 additions & 2 deletions docs/handbuch/blueprints.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ sources:
- src/config/context.ts
- src/engine/graph.ts
- src/engine/hierarchy.ts
sources_hash: bb6f89956ace833d
reviewed: 2026-08-26
sources_hash: 6f4be8d3a93113ce
reviewed: 2026-08-28
---

# Blueprints (parametrized, reusable config)
Expand Down Expand Up @@ -249,6 +249,12 @@ 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_.

The reference's `<field>` is the portable local ct-cli key. Its declaration may
map that key to a different, exact ChurchTools `referenceName` (#158), for
example `key: "stand_bewerbung"` plus `referenceName: "stand-bewerbung"`.
Ruleset resolution follows that mapping; it never treats `-` and `_` as the
same API identity.

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
28 changes: 18 additions & 10 deletions docs/handbuch/dynamic-groups.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ sources:
- src/engine/dynamic.ts
- src/engine/synthetic.ts
- src/application/operations/adopt-group.ts
sources_hash: c49d089ed6662ea7
reviewed: 2026-08-17
sources_hash: e4e2d552d54802f9
reviewed: 2026-08-28
---

# Auto-groups (dynamic groups)
Expand Down Expand Up @@ -87,16 +87,24 @@ 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");
ref.groupMemberField("ojbp_2026_27_praktikum_1", "stand_bewerbung");
```

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
The second argument is the portable local ct-cli key, not ChurchTools' API
identity. Its field declaration may map it explicitly to an exact
`referenceName`, for example `key: "stand_bewerbung"` and
`referenceName: "stand-bewerbung"`. The resolver uses that declaration to find
the host's numeric id from `GET /groups/{groupId}/memberfields`; the live
`referenceName` is compared byte-for-byte, so `stand-bewerbung` and
`stand_bewerbung` are not interchangeable (#158).

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. Local keys are compared in their
normalised form, so `"Stand Bewerbung"` finds a declaration keyed
`stand_bewerbung`; that normalization never changes the declaration's exact API
`referenceName`. See
[Group member fields](group-member-fields.md).

## Supplying a ruleset — three ways
Expand Down
56 changes: 33 additions & 23 deletions docs/handbuch/group-member-fields.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
---
sources_hash: 984c67a4f952f9fc
sources_hash: 740acdc4e12e5d8e
title: Group member fields
sources:
- src/engine/member-fields.ts
- src/engine/synthetic.ts
- src/config/context.ts
- src/application/operations/adopt-group.ts
- src/application/operations/destroy.ts
reviewed: 2026-08-26
reviewed: 2026-08-28
---

# Group member fields (#135)
# Group member fields (#135, #158)

A ChurchTools group can ask its members for extra information — "Wahl",
"Praktikumsplatz", a free-text note. Those are **group member fields**: field
Expand Down Expand Up @@ -60,8 +60,9 @@ ct.group({
groupTypeId: 5,
memberFields: [
{
key: "wahl",
name: "Wahl",
key: "stand_bewerbung",
referenceName: "stand-bewerbung",
name: "Stand",
fieldTypeCode: "text",
requiredInRegistrationForm: true,
},
Expand All @@ -77,24 +78,29 @@ ct.group({
| `memberFields: []` | managed, none declared — still never deletes an existing field |
| `memberFields: [ … ]` | these fields are created/updated; anything else is left in place |

`key` is the group-local key and is unique within the group. Everything else is
a ChurchTools member-field property.
`key` is the group-local ct-cli key and is unique within the group.
`referenceName` is the exact ChurchTools identity used by dynamic rulesets. The
two are deliberately separate: `key: "stand_bewerbung"` may carry
`referenceName: "stand-bewerbung"`. If `referenceName` is omitted it defaults
to `key` for backwards compatibility.

### Managed properties

`name`, `fieldTypeCode`, `defaultValue`, `options`, `nameInSignupForm`, `note`,
`noteInSignupForm`, `requiredInRegistrationForm`, `useInRegistrationForm`,
`securityLevel`, `sortKey`.

Two readable/writable properties are deliberately **not** managed:
Two readable/writable properties need special handling:

- **`id`** — host-specific; see above.
- **`referenceName`** — this _is_ the local identity, not a diffable property.
It is what a create sends and, when no state-bound id exists, what a later run
matches on. Managing it would let a rename silently re-key the resource and
re-create the field instead of updating it. (A field created in the
ChurchTools UI, where CT may mint its own `referenceName`, is matched by its
slugged `name` as a fallback.)
- **`referenceName`** — exact ChurchTools identity, kept separate from the local
key. It is sent unchanged on create and compared byte-for-byte on every later
plan; punctuation and case are significant, so `foo-bar` and `foo_bar` are
different. It is never silently PATCHed. A mismatch makes the plan
**INCOMPLETE** and tells the operator to perform an explicit replacement with
`ct destroy --member-field <group>::<local-key>` followed by plan/apply. A
name fallback is used only for legacy/UI rows that genuinely carry no
`referenceName`; an existing different value is never ignored.

A property outside the managed list still passes through to ChurchTools
unchanged — it only earns a warning, and it is never diffed.
Expand All @@ -108,7 +114,9 @@ ct adopt group --children-of ojbp_2025_26 --with-member-fields

The emitted snippet carries a `memberFields:` block with every group-scoped
field and **no ChurchTools ids** — paste it, re-key it for the next year, and
`ct plan` proposes fresh groups and fresh fields.
`ct plan` proposes fresh groups and fresh fields. Adoption keeps every non-empty
live `referenceName` byte-for-byte in the snippet while deriving the separate
local `key` as a portable slug.

The same adoption stores each live field id in the owning group's
instance-specific `memberFields` state map. This is deliberately separate from
Expand Down Expand Up @@ -154,9 +162,10 @@ already adopted in that run.
memberField:wahl: {"name":"Wahl"} -> {"name":"Wahl (neu)"}
```

The actual side is narrowed to exactly the properties the declaration names, so
a server default ChurchTools returns can never make the two sides differ
forever: **a clean apply re-plans as a no-op.**
The actual side includes the exact `referenceName` and is otherwise narrowed to
the mutable properties the declaration names, so a server default ChurchTools
returns can never make the two sides differ forever: **a clean apply re-plans
as a no-op.**

The same projection applies inside `options`: ChurchTools assigns host-specific
ids to select options, while a portable config can declare `{ name }`. Those
Expand Down Expand Up @@ -214,11 +223,12 @@ elsewhere, so a member field is referenced by its portable identity:
ref.groupMemberField("ojbp_2026_27_praktikum_1", "wahl");
```

Local keys are compared in their **normalised** form throughout — `"Wahl"` and
`"wahl"` are the same field, whether they appear in a declaration, in a
reference, in `ct destroy --member-field`, or as ChurchTools' own
`referenceName` on the live row. Two declarations in one group that differ only
in case are therefore rejected as duplicates.
Local ct-cli keys are compared in their **normalised** form throughout —
`"Wahl"` and `"wahl"` are the same local key in declarations, typed references,
state and `ct destroy --member-field`. Two declarations in one group that
differ only in case are therefore rejected as duplicates. ChurchTools
`referenceName` is a separate value and is always compared exactly; local-key
normalisation never applies to it.

Three things follow:

Expand Down
8 changes: 5 additions & 3 deletions docs/handbuch/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ sources:
- src/resolve/resolver.ts
- src/resolve/refs.ts
- src/config/context.ts
sources_hash: b5486f5d014a5912
reviewed: 2026-08-26
sources_hash: 469fab5b33c1e5c4
reviewed: 2026-08-28
---

# Permissions (`ct.groupRole` / `ct.groupTypeRole` / `ct.status`)
Expand Down Expand Up @@ -479,7 +479,9 @@ group-scoped member-field definition, addressed by its portable
`(group key, local field key)` pair — #135). They share this file's resolver and
its "managed state first, then live lookup, else a hard error at plan time"
rules, but they are referenced from **dynamic-group rulesets**, not from grant
scopes. See [Group member fields](group-member-fields.md).
scopes. For `group-member-field`, the logical pair selects a declaration; that
declaration's exact ChurchTools `referenceName` selects the live row (#158).
See [Group member fields](group-member-fields.md).

**Why this matters:** campus ids are host-specific — Mainz is `0` on eqrm prod
and `6` on eqrm dev. A campus-scoped grant written as a numeric literal is
Expand Down
7 changes: 5 additions & 2 deletions src/application/operations/adopt-group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,8 +224,8 @@ interface MemberFieldsCapture {
* Emits `{ key, ...managed properties }` per field and **never a ChurchTools id** — the field's
* identity in config is the group key plus its local key (`ojbp_2026_27_praktikum_1::wahl`), so the
* same blueprint applied to another group, or another host, mints its own fields rather than
* resolving against this host's numbering. The local key comes from CT's own `referenceName`
* (slugged), falling back to the slugged name for a field created in the ChurchTools UI.
* resolving against this host's numbering. The local key is a slug for ct-cli/state only; the live
* `referenceName` is emitted separately and byte-for-byte so punctuation remains API identity.
*
* Opt-in only (`--with-member-fields`) — and that opt-in is TRANSITIONAL, not a statement that
* member fields are optional: they are a category-2 owned structural child, so the flip to
Expand Down Expand Up @@ -296,6 +296,9 @@ async function captureMemberFields(
}
ids[canonical] = fieldId;
const declaration: Record<string, unknown> = { key: localKeyOf(row) };
if (typeof row.referenceName === "string" && row.referenceName.length > 0) {
declaration.referenceName = row.referenceName;
}
for (const prop of MEMBER_FIELD_PROPS) {
if (row[prop] !== undefined) declaration[prop] = row[prop];
}
Expand Down
29 changes: 25 additions & 4 deletions src/config/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ export type DynamicInput = boolean | string | { status: DynamicStatus; ruleset:
*/
export interface MemberFieldInput {
key: string;
/** Exact ChurchTools/ruleset identity; defaults to the local ct-cli key. Never silently renamed. */
referenceName?: string;
[prop: string]: unknown;
}

Expand Down Expand Up @@ -132,6 +134,8 @@ export interface ResourceInput {
* (`ojbp_2026_27_praktikum_1::wahl`), because a member field belongs to exactly one group and is
* not globally reusable. Two groups declaring `wahl` stay independent fields with different
* ChurchTools ids. A declaration may never carry a ChurchTools field id.
* `referenceName` is separate exact ChurchTools identity and defaults to `key`; hyphens and
* underscores are not equivalent there because dynamic rulesets use that string verbatim.
*
* A field dropped from this list is NEVER deleted — see `ct destroy --member-field`.
*/
Expand Down Expand Up @@ -430,11 +434,12 @@ function normalizeMemberFields(
}
const specs: MemberFieldSpec[] = [];
const seen = new Set<string>();
const seenReferenceNames = new Set<string>();
for (const raw of input) {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
throw new Error(`group "${key}": each entry of "memberFields" must be an object with a "key".`);
}
const { key: localKey, ...props } = raw as MemberFieldInput;
const { key: localKey, referenceName: configuredReferenceName, ...props } = raw as MemberFieldInput;
if (typeof localKey !== "string" || localKey.length === 0) {
throw new Error(`group "${key}": each entry of "memberFields" needs a non-empty string "key".`);
}
Expand All @@ -450,6 +455,22 @@ function normalizeMemberFields(
);
}
seen.add(canonical);
if (
configuredReferenceName !== undefined &&
(typeof configuredReferenceName !== "string" || configuredReferenceName.length === 0)
) {
throw new Error(
`group "${key}" member field "${localKey}": "referenceName" must be a non-empty string when provided.`,
);
}
const referenceName = configuredReferenceName ?? localKey;
if (seenReferenceNames.has(referenceName)) {
throw new Error(
`group "${key}": duplicate member field referenceName ${JSON.stringify(referenceName)}. ` +
`ChurchTools reference names are exact identities and must be unique within a group.`,
);
}
seenReferenceNames.add(referenceName);
for (const forbidden of MEMBER_FIELD_FORBIDDEN_PROPS) {
if (props[forbidden] !== undefined) {
throw new Error(
Expand All @@ -476,7 +497,7 @@ function normalizeMemberFields(
);
}
}
specs.push({ key: localKey, props });
specs.push({ key: localKey, referenceName, props });
}
return specs;
}
Expand Down Expand Up @@ -685,8 +706,8 @@ function validateMemberFieldRefs(resources: DesiredResource[], byKey: Map<string
`does not manage member fields. Add a "memberFields" list declaring "${ref.field}" to it.`,
);
}
// Normalised on both sides, exactly as the live row is matched (`matchesLocalKey`) and as the
// created id is keyed in state (`memberFieldStateKey`) — one spelling of identity everywhere.
// A ref names the local ct-cli key. That key remains normalised for config portability, while
// the matched declaration carries the separate exact ChurchTools referenceName (#158).
if (!target.memberFields.some((f) => memberFieldStateKey(f.key) === memberFieldStateKey(ref.field))) {
const declared = target.memberFields.map((f) => f.key).join(", ");
throw new Error(
Expand Down
47 changes: 34 additions & 13 deletions src/engine/member-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,9 @@ export function parseMemberFieldIdentity(raw: string): { group: string; field: s
* writable, minus the two that must never be managed:
*
* - `id` — host-specific, and the entire portability guarantee is that it never appears in config.
* - `referenceName` — this IS the local identity (see {@link matchesLocalKey}), not a diffable
* property. Managing it would let a plain rename silently re-key the resource, and the field
* would then be re-created rather than updated on the next apply.
* - `referenceName` — exact ChurchTools identity, kept separately on {@link MemberFieldSpec}.
* It is compared exactly and sent on create, but never PATCHed: changing it requires an explicit
* replacement because dynamic-group rulesets address this exact string.
*
* A declared property outside this list still passes through to ChurchTools untouched (the escape
* hatch, mirroring the registry's unknown-field behaviour) — it only earns a warning.
Expand Down Expand Up @@ -101,7 +101,9 @@ export function isManagedMemberFieldProp(name: string): boolean {
export interface MemberFieldSpec {
/** Local key, unique within the owning group. Half of the portable `<group>::<key>` identity. */
key: string;
/** The declared properties — never an id, never `referenceName`. Diffed as one unit. */
/** Exact identity used by ChurchTools and by ruleset `groupMemberFields` assignments (#158). */
referenceName: string;
/** The declared mutable properties — never an id, never `referenceName`. Diffed as one unit. */
props: Record<string, unknown>;
}

Expand Down Expand Up @@ -191,10 +193,15 @@ export function knownMemberFieldId(state: State, groupKey: string, localKey: str
return group?.memberFields?.[memberFieldStateKey(localKey)];
}

/** Prefer a state-bound id; use the portable live key only when no known id is present in the response. */
/**
* Prefer a state-bound id; otherwise match ChurchTools' identity-bearing `referenceName` EXACTLY.
* A name fallback is permitted only for old/UI rows that genuinely carry no referenceName. Once CT
* supplies one, punctuation and case are data: `foo-bar` and `foo_bar` are different identities.
*/
export function matchingMemberFieldRows(
rows: MemberFieldRow[],
localKey: string,
referenceName: string,
knownId?: number,
): MemberFieldRow[] {
if (knownId !== undefined) {
Expand All @@ -203,23 +210,37 @@ export function matchingMemberFieldRows(
// when a response variant was parsed incompletely.
return rows.filter((row) => memberFieldRowId(row) === knownId);
}
return rows.filter((row) => matchesLocalKey(row, localKey));
return rows.filter((row) => {
const liveReference = memberFieldReferenceName(row);
if (liveReference !== undefined) return liveReference === referenceName;
const name = row.name;
return typeof name === "string" && slug(name) === slug(localKey);
});
}

/**
* The local key a live row answers to. `referenceName` is CT's own stable, non-numeric handle
* within the group and is what a create sends, so it wins; `name` is the fallback for a row created
* in the ChurchTools UI, where CT may mint its own referenceName. Slugged on both sides so a key
* derived from a German name (`Wahl` → `wahl`) matches either spelling.
* The exact identity-bearing ChurchTools reference name, when the row carries one.
*/
export function localKeyOf(row: MemberFieldRow): string {
export function memberFieldReferenceName(row: MemberFieldRow): string | undefined {
const reference = row.referenceName;
if (typeof reference === "string" && reference.length > 0) return slug(reference);
return typeof reference === "string" && reference.length > 0 ? reference : undefined;
}

/**
* A stable local key for adoption/delete-candidate display. This is deliberately NOT API identity:
* it remains a ct-cli slug, while {@link memberFieldReferenceName} preserves the exact CT string.
*/
export function localKeyOf(row: MemberFieldRow): string {
const reference = memberFieldReferenceName(row);
if (reference !== undefined) return slug(reference);
const name = row.name;
return typeof name === "string" ? slug(name) : "";
}

/** Does this live row carry the declared local key? (See {@link localKeyOf}.) */
/**
* Legacy/local-key affinity used only for diagnostics and destructive target lookup. Never use it
* to establish API identity for plan/apply; use {@link matchingMemberFieldRows} there.
*/
export function matchesLocalKey(row: MemberFieldRow, localKey: string): boolean {
const wanted = slug(localKey);
if (localKeyOf(row) === wanted) return true;
Expand Down
Loading
Loading