Repository navigation
bug(permissions): group + its own groupRole in one config can never plan on a fresh host (the domain half of #29) #106
Description
Problem
#29 fixed the scope-resolution deadlock: a group and a grant scoped to that group can now be
declared in one config, because a pending scope ref is re-resolved at apply time after the resource
tier runs.
The domain side of the same problem is still a hard error. A ct.groupRole whose group is
declared in the same config but does not yet exist on the target host cannot plan at all:
✗ Cannot resolve group-role(group=skidscheckinopskoblenz, role=Mitglied) referenced at
group_role "skidscheckinopskoblenz_mitglied".domainId on https://eqrm-dev.church.tools:
group "skidscheckinopskoblenz" is declared in this config but not yet created — its
(group, role) pairing id only exists once the group does. Apply the group first, then
re-run, or pass a numeric id.
This is deliberate — resolveGroupRole documents it:
Returns a number — never a PendingRef: the pairing id only exists once the group does, so a
same-run-declared (not-yet-created) group is a hard error here
Why it bites in practice
It makes a config non-portable across hosts by construction. The exact same config:
- plans clean on prod, where the group already exists, and
- hard-fails on dev, where it does not.
So the promotion model the tool is built around — one config, dev rehearses what prod will get —
breaks for any new group that carries per-group grants. In eqrm/ct-structure this surfaced the
moment we adopted sKidsCheckinOpsKoblenz: prod plan a clean no-op, dev plan exit 1, PR check red.
The workaround is to split every such change into two merges — land the group, wait for apply-dev
to create it, then add the grants in a follow-up PR. That doubles the review cycle for what is
conceptually one change, and it is easy to forget the second half, leaving a Struktur group in
place with none of the rights it exists to carry.
The suggested "pass a numeric id" escape hatch does not help here: the pairing id is host-specific
(prod 44675), which is precisely what a shared config must not contain.
Why it should be solvable
The information needed at apply time is available. Tier ordering already guarantees the group is
created before permissions are applied — executePlan runs the resource tiers first, and
applyPermissionPlan is explicitly called after it, with a comment saying it re-resolves scope
dataIds against the post-execute state for exactly this reason. The domain id could be resolved in
the same window: after the group exists, GET /groups/{id}/roles yields the pairing id, and the
role name → pairing mapping is what resolveGroupRole already does.
The plan-time rendering can stay honest by showing the domain as pending, the way pending scope
refs already render.
Acceptance
- A config declaring a group and a
ct.groupRoleon that group plans on a host where the group
does not yet exist, rendering the domain as pending. -
ct applycreates the group, then resolves the pairing id and applies the grants in one run. - A
rolename that does not exist on the created group is still a hard error, with the
available roles listed as today. - The same config plans identically on a host where the group already exists (no regression).
Related: #29 (the scope half of this deadlock), #25 (group_role domains by (group, role) pair).
Activity
fix: address the #112 review — detached client reads, write-then-chec…
Merge pull request #112 from eqrm/feat/issues-106-110-111
2000game commentedon Aug 14, 2026
Shipped in #112 (merged 2026-08-14). A
ct.groupRolewhose group is declared in the same config but absent from the target host now resolves to a pending domain ref, completed at apply time from the freshly created group's own/groups/{id}/roles. Seesrc/permissions/plan.ts(pendingDomain) andtests/permission-pending-domain.test.ts.(Not auto-closed: the PR body's
Closes **#106**bolding defeats GitHub's keyword parser.)