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
2 changes: 1 addition & 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/application/operations/adopt-group.ts
sources_hash: e4e2d552d54802f9
sources_hash: e38b8c0f6032d5cc
reviewed: 2026-08-28
---

Expand Down
38 changes: 29 additions & 9 deletions docs/handbuch/group-member-fields.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
sources_hash: 740acdc4e12e5d8e
sources_hash: c18b710bff24503d
title: Group member fields
sources:
- src/engine/member-fields.ts
Expand Down Expand Up @@ -96,11 +96,27 @@ Two readable/writable properties need special handling:
- **`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.
different. It is never silently PATCHed.

A live field whose `referenceName` **differs** from the declared one makes the
plan **INCOMPLETE** and offers two ways out: manage the existing field by
declaring its `referenceName` in config, or replace it — destructively, the
field and its member values go — with
`ct destroy --member-field <group>::<local-key>` followed by plan/apply. The
same refusal covers a live identity that differs only in punctuation or case
(`stand_bewerbung` next to a declared `stand-bewerbung`): ct neither renames it
nor mints a near-duplicate beside it.

A live field that carries **no** `referenceName` at all — a legacy row, or one
created in the ChurchTools UI on a version that mints none — is matched by its
slugged `name`, reconciled on its mutable properties, and left without a
reference name, exactly as before. There is no competing identity to refuse.

A live field that merely shares a declaration's display **name** while carrying
its own ChurchTools identity (`eigenesfeld_3`) is a coincidence, not a
contradiction: `ct plan` warns, names the `referenceName` to declare if that
field was meant, and otherwise proposes the create. `name` is mutable display
text, and refusing here would abort the run for every other resource too.

A property outside the managed list still passes through to ChurchTools
unchanged — it only earns a warning, and it is never diffed.
Expand Down Expand Up @@ -165,7 +181,9 @@ already adopted in that run.
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.**
as a no-op.** (A row that carries no reference name reports the declared one:
ct never PATCHes `referenceName`, so diffing it there would propose the same
update on every run.)

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 @@ -227,8 +245,10 @@ 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.
`referenceName` is a separate value and is always compared exactly wherever a
declaration states one; local-key normalisation never applies to it. A group
that is adopted but declares no `memberFields` states none, so a reference into
it keeps matching the live row on the normalised local key.

Three things follow:

Expand Down
2 changes: 1 addition & 1 deletion docs/handbuch/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ sources:
- src/resolve/resolver.ts
- src/resolve/refs.ts
- src/config/context.ts
sources_hash: 469fab5b33c1e5c4
sources_hash: c3a6740c4d4bc134
reviewed: 2026-08-28
---

Expand Down
26 changes: 21 additions & 5 deletions src/application/operations/destroy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import { writeBackup } from "../../engine/backup.js";
import {
groupScopedRows,
memberFieldStateKey,
matchesLocalKey,
knownMemberFieldId,
matchingMemberFieldRows,
memberFieldItemPath,
memberFieldRowId,
memberFieldsReadPath,
Expand Down Expand Up @@ -294,8 +295,8 @@ export async function runMemberFieldDeleteLoop(ctx: {
const forget = async (): Promise<void> => {
const managed = state.resources[target.groupKey];
// Slugged, exactly as the apply that wrote it keyed the entry (`memberFieldStateKey`) and as
// `matchesLocalKey` matched the live row — otherwise `--member-field g::Wahl` deletes the
// field in ChurchTools but leaves `memberFields.wahl` pointing at the id it just destroyed.
// the live row was resolved above — otherwise `--member-field g::Wahl` deletes the field in
// ChurchTools but leaves `memberFields.wahl` pointing at the id it just destroyed.
const stateKey = memberFieldStateKey(target.fieldKey);
if (managed?.memberFields && stateKey in managed.memberFields) {
const rest = { ...managed.memberFields };
Expand All @@ -308,7 +309,16 @@ export async function runMemberFieldDeleteLoop(ctx: {
let fieldId: number | undefined;
try {
const rows = groupScopedRows(await client.get(memberFieldsReadPath(target.groupId)));
const matches = rows.filter((row) => matchesLocalKey(row, target.fieldKey));
// The state binding wins where there is one. `--member-field g::wahl` is exactly the command
// every identity-mismatch message hands the operator, and those are the cases where the live
// row's name or referenceName has drifted away from the local key: matching on the key alone
// would report "already absent", drop the binding, and let the next apply POST a duplicate.
const matches = matchingMemberFieldRows(
rows,
target.fieldKey,
undefined,
knownMemberFieldId(state, target.groupKey, target.fieldKey),
);
if (matches.length > 1) {
record(outcomes, ctx.observer, {
kind: "member-field",
Expand Down Expand Up @@ -446,7 +456,13 @@ export async function prepareDestroy(
for (const target of memberFieldTargets) {
try {
const rows = groupScopedRows(await client.get(memberFieldsReadPath(target.groupId)));
const match = rows.find((row) => matchesLocalKey(row, target.fieldKey));
// Same resolution as the delete loop below, so the backup holds the row that is deleted.
const match = matchingMemberFieldRows(
rows,
target.fieldKey,
undefined,
knownMemberFieldId(state, target.groupKey, target.fieldKey),
)[0];
if (match) actual.set(target.identity, match);
} catch (err) {
throw new CtApplicationError(
Expand Down
22 changes: 21 additions & 1 deletion src/engine/member-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,11 +197,17 @@ export function knownMemberFieldId(state: State, groupKey: string, localKey: str
* 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.
*
* `referenceName === undefined` means the caller has NO declared exact identity to compare against —
* a ref into a group that is adopted but does not declare `memberFields`, or a `ct destroy
* --member-field` target. Those callers keep the pre-#158 local-key affinity ({@link
* matchesLocalKey}), because there is no config to state the exact string and demanding one would
* turn a working reference into a hard error.
*/
export function matchingMemberFieldRows(
rows: MemberFieldRow[],
localKey: string,
referenceName: string,
referenceName: string | undefined,
knownId?: number,
): MemberFieldRow[] {
if (knownId !== undefined) {
Expand All @@ -210,6 +216,7 @@ export function matchingMemberFieldRows(
// when a response variant was parsed incompletely.
return rows.filter((row) => memberFieldRowId(row) === knownId);
}
if (referenceName === undefined) return rows.filter((row) => matchesLocalKey(row, localKey));
return rows.filter((row) => {
const liveReference = memberFieldReferenceName(row);
if (liveReference !== undefined) return liveReference === referenceName;
Expand All @@ -218,6 +225,19 @@ export function matchingMemberFieldRows(
});
}

/**
* The live exact identity that CONTRADICTS `referenceName`, or `undefined` when nothing does.
*
* A row carrying no `referenceName` at all contradicts nothing: it is the legacy/UI row the name
* fallback in {@link matchingMemberFieldRows} exists for, and since ct never PATCHes
* `referenceName` there is no rename to refuse. Treating "missing" as "different" would make every
* such row unreconcilable — no update, no create, just a permanent error.
*/
export function conflictingReferenceName(row: MemberFieldRow, referenceName: string): string | undefined {
const live = memberFieldReferenceName(row);
return live !== undefined && live !== referenceName ? live : undefined;
}

/**
* The exact identity-bearing ChurchTools reference name, when the row carries one.
*/
Expand Down
Loading
Loading