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/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: 0bf15d98760f937b
sources_hash: f0eb68d512a54ebb
reviewed: 2026-08-28
---

Expand Down
37 changes: 36 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/application/operations/adopt-group.ts
sources_hash: 5d0be1f4c92a8efb
sources_hash: 93139d34ad478e84
reviewed: 2026-08-28
---

Expand Down Expand Up @@ -512,6 +512,41 @@ This is pinned by a live-gated test in `tests/dynamic.integration.test.ts`
it fails); it is skipped by default and requires an explicit opt-in against a
dev instance.

## Ruleset-only declaration: `ct.dynamicGroup` (#202)

When another tool owns the group itself (typically OpenTofu's `churchtools_group`), the group
leaves ct's config **and** state, but its ruleset can still be managed from here:

```ts
ct.dynamicGroup({ group: "example_autogroup", dynamic: true });
ct.dynamicGroup({
group: "example_paused",
dynamic: { status: "manual", ruleset: { ref: "./rulesets/example_paused.json" } },
});
```

`dynamic` takes exactly the three forms of `ct.group({ dynamic })`. The group is found by key:
ct's state first, then the OpenTofu id map (`.ct/ids.<host>.json`). A key found in neither, or a
group that no longer exists in ChurchTools, is a plan-time error, never a create.

What it deliberately does **not** do:

- It writes **no** `ct-state` entry and does not appear in `ct state list`.
- It has **no** delete path. Dropping the declaration means "stop managing the ruleset" (the same
as dropping `dynamic` from a `ct.group`); it never demotes the group to `none`, never deletes the
ruleset, never touches the group row.
- It manages no other group field, no `parents` and no member fields.

Plan items render as an update (or no-op) on the group with only the `dynamic` field changing, and
the safety rails above (fetch-failed degradation, unportable-id and `stereotype` warnings,
status-only flips skipping the ruleset PUT) apply unchanged. A key declared with both `ct.group` and
`ct.dynamicGroup`, or with `ct.dynamicGroup` twice, is refused at config load: one owner per ruleset.

`ct refresh --group <key>` and `ct adopt group <id> --with-dynamic` also accept an id-map group; the
adopt output is then a `ct.dynamicGroup` snippet and no state entry is written. The id map's key
wins over `--key`, and `--with-member-fields` does not apply (the group's owner declares those);
adopt warns about either. Without `--with-dynamic`, an id-map group is skipped with a warning.

## Managed guard: undeclared groups stay invisible

Dynamic-group state is only ever fetched for a group that is **both**: (a)
Expand Down
6 changes: 5 additions & 1 deletion docs/handbuch/group-member-fields.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
sources_hash: 6809989e79b1fa43
sources_hash: 50621f5e4d9e5064
title: Group member fields
sources:
- src/engine/member-fields.ts
Expand Down Expand Up @@ -145,6 +145,10 @@ Rows the group's member form shows but that are not group-scoped (person master
data, group-type defaults) are not emitted: only `/memberfields/group` rows can
be created or updated.

A group the OpenTofu id map owns (see `ct.dynamicGroup` in
[Dynamic groups](dynamic-groups.md)) is not adopted with its member fields: its
owner declares them, so adopt ignores the flag for that group and warns.

!!! note "Why opt-in, and not the default — and why that is temporary"
Whether an owned child resource is adopted automatically is a project-wide
contract, not a per-feature choice. That contract now exists
Expand Down
8 changes: 6 additions & 2 deletions 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: 066465b31f159b1a
sources_hash: 7974aecfe2a96172
reviewed: 2026-08-28
---

Expand Down Expand Up @@ -543,7 +543,7 @@ the rest of the DSL uses — so both spellings are interchangeable.

| `scopeField` | Reference form | Resolved against |
| -------------------- | ----------------------------------------- | ----------------------------------------------------------------- |
| `cdb_gruppe` | `{ group: "<key>" }` (or the bare string) | managed groups |
| `cdb_gruppe` | `{ group: "<key>" }` (or the bare string) | managed groups; `{ group }` also the id map |
| `cdb_station` | `{ campus: "<key>" }` | managed campuses, then `GET /campuses` |
| `cdb_gruppentyp` | `{ groupType: "<key>" }` | managed group types, then `GET /group/grouptypes` |
| `cdb_bereich` | `{ department: "<name-slug>" }` | managed Bereiche, then `GET /departments` (#108) |
Expand Down Expand Up @@ -596,6 +596,10 @@ Three things are hard errors at **plan** time, never a guessed `dataId`:
group", so on e.g. a `cdb_station` right it would either fail confusingly or —
worse — match an unrelated group that happens to carry that key.

A **bare string** group scope resolves from ct's state (or a group declared in the same config)
only. Once a group leaves ct's state because OpenTofu owns it, the bare string fails at plan time
with a hint to write `{ group: "<key>" }`, which resolves through the id map (#202).

### Comment viewers: from catalog-only to declarable (#151)

`cdb_comment_viewer` (Kommentare-Viewer) had a reference form since #102 but no
Expand Down
219 changes: 146 additions & 73 deletions src/application/operations/adopt-group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import { RESOURCES, configSnippet, fromInformation, slug } from "../../resources
import { ReverseResolver, type RoleCatalogEntry } from "../../resolve/reverse.js";
import type { RefKind } from "../../resolve/refs.js";
import { formatPortablizeWarnings, portablizeRuleset, scanUnportablized } from "../../config/query-refs.js";
import { CATALOG_DIR } from "../../permissions/catalog-store.js";
import { loadIdMap } from "../../resolve/idMap.js";
import { chooseAdoptKey, loadState, saveState, upsert, type State } from "../../state/state.js";
import type { CtWarning, OperationResult, ProjectRequest } from "../contracts.js";
import { InMemoryMutationLock } from "../prepared-operation-store.js";
Expand Down Expand Up @@ -357,6 +359,14 @@ export async function runAdoptGroups(
const project = await resolveProject(opts);
return mutationLock.runExclusive(project.statePath, async () => {
const state = await loadState(project.statePath, project.host);
// Groups OpenTofu owns (#202): present in the id map by id, absent from ct's state. Adopting one
// would write a state entry that duplicates the map, so only its ruleset is captured, as a
// `ct.dynamicGroup` snippet.
const idMap = await loadIdMap(project.host, join(project.cwd, CATALOG_DIR));
const mappedGroupKeyById = new Map<number, string>();
for (const entry of idMap?.entries ?? []) {
if (entry.type === "group") mappedGroupKeyById.set(entry.id, entry.key);
}
const { client } = await authedSession();

let resolvedIds: number[];
Expand Down Expand Up @@ -409,7 +419,131 @@ export async function runAdoptGroups(
roleCatalog = await reverse.roleGroupTypeCatalog();
}

/** Capture a group's dynamic ruleset to `rulesets/<key>.json`; the `dynamic` value for the snippet, or undefined. */
const captureRulesetFile = async (
id: number,
key: string,
): Promise<{ status: DynamicStatus; ruleset: { ref: string } } | undefined> => {
const captured = await captureDynamic(id, client);
if (captured) {
const relPath = `rulesets/${key}.json`;
let rulesetToWrite = captured.normalizedRuleset;
if (opts.portableRulesets !== false) {
// Managed group ids come from state (no catalog for `group`), including any group this
// same run already adopted; the master-data kinds come from the catalog maps above.
const groupMap = new Map<number, string>(mappedGroupKeyById);
// Managed role definitions, likewise from STATE rather than the live catalog (#125):
// state gives a per-host id under a shared logical key, which is what makes a
// duplicate role name fixable by adopting the role instead of renaming master data.
// (`ReverseResolver.idToKeyByKind("role-def")` keys by slug(name) off the catalog and
// is exactly the ambiguous mapping this replaces.)
const roleDefMap = new Map<number, string>();
for (const r of Object.values(state.resources)) {
if (r.type === "group") groupMap.set(r.id, r.key);
if (r.type === "group-role") roleDefMap.set(r.id, r.key);
}
const { ruleset, warnings: portableWarnings } = portablizeRuleset(captured.normalizedRuleset, {
idToKeyByKind: { ...portableCatalogMaps, group: groupMap, "role-def": roleDefMap },
roleCatalog,
groupTypeIdToKey,
});
rulesetToWrite = ruleset;
// Report every dimension left numeric, with its reason (#101). The old output said only
// "left N unmanaged id(s) numeric", which named neither the dimension nor the fix — so a
// capture that silently froze prod's ids into a cross-host file looked like a clean run.
if (portableWarnings.length > 0) {
const lines = formatPortablizeWarnings(portableWarnings);
if (opts.strictRulesets) {
throw new Error(
`--strict-rulesets: ${relPath} would contain ${portableWarnings.length} unportablized ` +
`(host-specific) id(s), so nothing was written:\n` +
lines.map((l) => ` ${l}`).join("\n"),
);
}
addWarning({
code: "RULESET_NOT_PORTABLE",
message:
`${relPath} keeps ${portableWarnings.length} host-specific id(s) — NOT portable to another host:\n` +
lines.map((line) => ` ${line}`).join("\n"),
});
}
} else {
// Verbatim capture (--no-portable-rulesets): every entity id in the file is this host's.
// Say so once per ruleset rather than let the opt-out quietly imply the ids are fine.
const left = scanUnportablized(captured.normalizedRuleset);
if (left.length > 0) {
if (opts.strictRulesets) {
throw new Error(
`--strict-rulesets with --no-portable-rulesets: ${relPath} would contain ` +
`${left.length} host-specific id(s) and nothing would rewrite them.`,
);
}
addWarning({
code: "RULESET_NOT_PORTABLE",
message:
`${relPath} captured verbatim (--no-portable-rulesets): ${left.length} host-specific ` +
`id(s) kept as-is — NOT portable to another host.`,
});
}
}
if (!opts.dryRun) {
await mkdir(join(project.cwd, "rulesets"), { recursive: true });
await writeFile(join(project.cwd, relPath), `${JSON.stringify(rulesetToWrite, null, 2)}\n`, "utf8");
}
return { status: captured.status, ruleset: { ref: `./${relPath}` } };
}
return undefined;
};
/** `ct adopt group` on a group the id map owns: capture only the ruleset, write no state entry (#202). */
const adoptMappedGroup = async (id: number, key: string): Promise<string | null> => {
// The map's key and OpenTofu's ownership win: say so rather than drop the options silently.
const ignored = [
...(opts.key && opts.key !== key ? [`--key (the id map names it "${key}")`] : []),
...(opts.withMemberFields ? ["--with-member-fields (the group's owner declares those)"] : []),
];
if (ignored.length > 0) {
addWarning({
code: "ADOPT_ID_MAP_OPTION_IGNORED",
message: `group #${id} ("${key}", id map): ignored ${ignored.join(" and ")}.`,
});
}
if (!opts.withDynamic) {
addWarning({
code: "ADOPT_SKIPPED_ID_MAP",
message:
`group #${id} is "${key}" in the OpenTofu id map, which owns it — nothing adopted. ` +
`Pass --with-dynamic to capture its ruleset as a ct.dynamicGroup declaration.`,
});
return null;
}
const dynamic = await captureRulesetFile(id, key);
if (!dynamic) {
addWarning({
code: "ADOPT_SKIPPED_ID_MAP",
message: `group #${id} ("${key}", id map) is not a dynamic group — nothing to capture.`,
});
return null;
}
return [
`ct.dynamicGroup({`,
` group: ${JSON.stringify(key)},`,
` dynamic: { status: ${JSON.stringify(dynamic.status)}, ruleset: { ref: ${JSON.stringify(dynamic.ruleset.ref)} } },`,
`});`,
].join("\n");
};

const stateGroupIds = new Set(
Object.values(state.resources)
.filter((r) => r.type === "group")
.map((r) => r.id),
);
for (const id of resolvedIds) {
const mappedKey = stateGroupIds.has(id) ? undefined : mappedGroupKeyById.get(id);
if (mappedKey !== undefined) {
const snippet = await adoptMappedGroup(id, mappedKey);
if (snippet !== null) results.push({ id, key: mappedKey, fields: {}, snippet });
continue;
}
const resource = await client.get<Record<string, unknown>>(GROUP_SPEC.itemPath(id));
// An already-managed group keeps its adopted key unless --rekey says otherwise (#123). This
// is the mode that made the bug bite: the documented ruleset-refresh workflow passes a LIST
Expand Down Expand Up @@ -447,78 +581,8 @@ export async function runAdoptGroups(
snippetFields.memberFields = memberFields.declarations;
}
if (opts.withDynamic) {
const captured = await captureDynamic(id, client);
if (captured) {
const relPath = `rulesets/${key}.json`;
let rulesetToWrite = captured.normalizedRuleset;
if (opts.portableRulesets !== false) {
// Managed group ids come from state (no catalog for `group`), including any group this
// same run already adopted; the master-data kinds come from the catalog maps above.
const groupMap = new Map<number, string>();
// Managed role definitions, likewise from STATE rather than the live catalog (#125):
// state gives a per-host id under a shared logical key, which is what makes a
// duplicate role name fixable by adopting the role instead of renaming master data.
// (`ReverseResolver.idToKeyByKind("role-def")` keys by slug(name) off the catalog and
// is exactly the ambiguous mapping this replaces.)
const roleDefMap = new Map<number, string>();
for (const r of Object.values(state.resources)) {
if (r.type === "group") groupMap.set(r.id, r.key);
if (r.type === "group-role") roleDefMap.set(r.id, r.key);
}
const { ruleset, warnings: portableWarnings } = portablizeRuleset(captured.normalizedRuleset, {
idToKeyByKind: { ...portableCatalogMaps, group: groupMap, "role-def": roleDefMap },
roleCatalog,
groupTypeIdToKey,
});
rulesetToWrite = ruleset;
// Report every dimension left numeric, with its reason (#101). The old output said only
// "left N unmanaged id(s) numeric", which named neither the dimension nor the fix — so a
// capture that silently froze prod's ids into a cross-host file looked like a clean run.
if (portableWarnings.length > 0) {
const lines = formatPortablizeWarnings(portableWarnings);
if (opts.strictRulesets) {
throw new Error(
`--strict-rulesets: ${relPath} would contain ${portableWarnings.length} unportablized ` +
`(host-specific) id(s), so nothing was written:\n` +
lines.map((l) => ` ${l}`).join("\n"),
);
}
addWarning({
code: "RULESET_NOT_PORTABLE",
message:
`${relPath} keeps ${portableWarnings.length} host-specific id(s) — NOT portable to another host:\n` +
lines.map((line) => ` ${line}`).join("\n"),
});
}
} else {
// Verbatim capture (--no-portable-rulesets): every entity id in the file is this host's.
// Say so once per ruleset rather than let the opt-out quietly imply the ids are fine.
const left = scanUnportablized(captured.normalizedRuleset);
if (left.length > 0) {
if (opts.strictRulesets) {
throw new Error(
`--strict-rulesets with --no-portable-rulesets: ${relPath} would contain ` +
`${left.length} host-specific id(s) and nothing would rewrite them.`,
);
}
addWarning({
code: "RULESET_NOT_PORTABLE",
message:
`${relPath} captured verbatim (--no-portable-rulesets): ${left.length} host-specific ` +
`id(s) kept as-is — NOT portable to another host.`,
});
}
}
if (!opts.dryRun) {
await mkdir(join(project.cwd, "rulesets"), { recursive: true });
await writeFile(
join(project.cwd, relPath),
`${JSON.stringify(rulesetToWrite, null, 2)}\n`,
"utf8",
);
}
snippetFields.dynamic = { status: captured.status, ruleset: { ref: `./${relPath}` } };
}
const captured = await captureRulesetFile(id, key);
if (captured) snippetFields.dynamic = captured;
}
const snippet = configSnippet("group", key, snippetFields, { todos });

Expand Down Expand Up @@ -550,7 +614,16 @@ export async function runAdoptGroups(
};
}

await saveState(project.statePath, state);
// Only id-map groups were selected and every one was skipped: nothing to save or paste.
if (results.length === 0) {
return {
operation: "adopt",
project,
warnings,
value: { groups: [], configBlock: null, noMatches: false, dryRun: false },
};
}
if (reports.length > 0) await saveState(project.statePath, state);
if (reports.some((report) => report.action === "updated")) {
addWarning({
code: "ADOPT_ALREADY_MANAGED",
Expand Down
3 changes: 2 additions & 1 deletion src/application/operations/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,8 @@ export async function executePreparedApply(
}
for (const key of resources.updated) {
const item = plan.items.find((candidate) => candidate.key === key);
const id = context.state.resources[key]?.id;
// A ruleset-only item (#202) has no state entry by design; its id is on the plan item.
const id = item?.rulesetOnly ? (item.id ?? undefined) : context.state.resources[key]?.id;
if (item && id !== undefined) {
observer.emit({ type: "resource-updated", resourceType: item.type, key, id });
}
Expand Down
Loading
Loading