Repository navigation
feat(permissions): portable scope refs + declarable person statuses - #99
Merged
Merged
Conversation
Closes two "the domain looks portable but isn't" gaps that both made a config stop being self-sufficient on a second host. #98 — typed logical scope refs. A scoped grant's `scope` now accepts a typed reference alongside the group key and the numeric dataId: { right: "churchdb:view station", scope: [{ campus: "koblenz" }] } { right: "churchdb:view alldata", scope: [{ department: "outreach" }] } Campus/department/group-type ids are host-specific, so the numeric literal that was previously the only option was a cross-environment misgrant — and since declaring a domain makes ct OWN it, a wrong-scope grant also revokes the real grant on the other host. Ref resolution is hoisted into one async pass (`resolveScopeRefs`) so per-grant `resolveScope` stays synchronous; tuples carry `scopeType` so apply-time re-resolution checks the right resource type. Three new plan-time hard errors, never a guessed dataId: a ref whose dimension does not match the right's `scopeField` (names both), a ref on a dimension with no logical form, and — a latent bug this surfaced — a bare string on a non-group dimension, which was previously looked up among groups and could silently match an unrelated same-keyed group. `ct adopt grants` emits typed refs for managed campus/group-type dataIds and a NOTE naming the fixing command otherwise. #96 — person statuses are declarable. Adds a `person-status` resource kind (`ct.personStatus`, `ct adopt person-status`, `ct get statuses`) and resolves a `personStatus:` domain from managed state before the `/statuses` catalog, so a status declared in the same config converges in one apply. Previously the resolver advised "Declare/adopt it" for something that could not be declared. Endpoint facts live-probed against the instance OpenAPI spec (CT 3.135.2): - `/departments` is GET-only — no write verb, no item path. Departments are a read-only ref catalog: referenceable by name on every host, never declarable, and an unknown name hard-errors saying so. - `/statuses` PUT requires all of name, shorty, isMember, isSearchable, sortKey, securityLevelId and is a full replace — uniquely strict among managed types. The registry therefore manages all six; an earlier narrow set would have 400'd on every update, or blanked the omitted fields. Also retires the #25 `group_role` pairing-id ASSUMPTION: verified live against two anchors on different group types. Each role row's `id` is a live `group_role` domainId carrying that role's grants, while its type-level `groupTypeRoleId` appears nowhere in the domainId set. The two resolver constants were already correct and are unchanged. Refs #25, #49. Closes #96, #98. Claude-Session: https://claude.ai/code/session_01NiQXu3vCDVgHLbWxmgXoyJ
… tighten adopt notes Review findings on #99: - Resolver read master-data catalogs (and per-group role lists) with a plain `get`, so only ChurchTools' default first page (10 rows) was visible: a campus/group type/department past it was unresolvable by name, which is exactly the portability #98 exists to provide. Read them with `getAll`. - `person-status` is the one managed type whose DELETE reaches person records (CT re-stamps everyone carrying the status) and `assertNotPeople` cannot see it — `/statuses/{id}` is not a people path. Types can now declare a `destroyWarning`; `ct destroy` prints it and `--force` no longer skips the typed confirmation for such a target. - Adopt emitter: a catalog-only dimension (`cdb_bereich`) emitted BOTH the portable-form NOTE and the contradictory "not a group, use numbers" line; the unmanaged-scope hint named only the first id while pluralising the rest. - Example/docs keyed a person status `"core"` for name "5 - Core", which can never match the live catalog (it matches `slug(name)`) — on a host that already has that status this would create a duplicate and grant on it. - Round-trip adoption test now runs the plan's real scope pre-resolution, so the `{ campus: … }` emission form is actually covered by the invariant. Claude-Session: https://claude.ai/code/session_01NiQXu3vCDVgHLbWxmgXoyJ
This was referenced Aug 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #96 and #98 — two instances of the same shape: a permission domain that
looks portable but silently isn't, so a config that plans clean on one host
breaks (or misgrants) on the next.
#98 — typed logical scope refs
A scoped grant's
scopenow takes a typed reference alongside the group key andthe numeric dataId:
{ campus: "x" }is sugar forref.campus("x"), so both spellings are identical.scopeFieldcdb_gruppe{ group: "…" }/ bare stringcdb_station{ campus: "…" }/campusescdb_gruppentyp{ groupType: "…" }/group/grouptypescdb_bereich{ department: "…" }/departments(read-only)Why it matters. These ids are host-specific, so the numeric literal that was
previously the only option is a cross-environment misgrant — and because
declaring a domain makes
ctown it, a wrong-scope grant doesn't merely addthe wrong thing, it revokes the right thing on the other host.
Ref resolution is hoisted into one async pass (
resolveScopeRefs) so per-grantresolveScopestays synchronous; tuples carryscopeTypeso apply-timere-resolution checks the right resource type.
Three hard errors at plan time, never a guessed dataId:
scopeField(names both);string always meant "managed group", so on e.g. a
cdb_stationright it eitherfailed confusingly or matched an unrelated group carrying that key.
ct adopt grantsemits typed refs for managed campus/group-type dataIds, and aNOTEnaming the one command that makes the rest portable.#96 — person statuses are declarable
Adds a
person-statusresource kind:ct.personStatus,ct adopt person-status <id>,ct get statuses. ApersonStatus:domain now resolves from managedstate before the
/statusescatalog, so a status declared in the same configresolves to a pending domain and converges in one
ct apply.Previously the resolver's own advice was impossible to follow:
Statuses are master data (the enumeration), never person records — the people
boundary is untouched and asserted in tests.
Verified against a live instance, not assumed
Endpoint facts read from the instance's own OpenAPI spec (CT 3.135.2):
/departmentsis GET-only — noPOST/PUT/DELETE, no item path. Thissettles the fork feat(permissions): portable scope refs for non-group dimensions (cdb_station, cdb_bereich, cdb_gruppentyp) #98 left open: departments are a read-only ref catalog,
referenceable by name everywhere but never declarable, and an unknown name
hard-errors saying exactly that.
/statusesPUT requires all six ofname, shorty, isMember, isSearchable, sortKey, securityLevelId, and is a full replace — uniquely strict amongmanaged types (every other managed PUT declares no required fields). This
caught a real bug in the first cut of feat(resources): person statuses are not declarable — the
statuspermission domain is only half-portable #96: the narrow{name, shorty}setwould have 400'd on every update, or blanked the omitted fields. The registry
now manages all six and declares no
createDefaults.Also: the #25
group_roleassumption is retiredThe pairing-id model has carried an
ASSUMPTION — verify once on a live instanceblock since #25. Verified against two anchors on different grouptypes, each chosen so the per-group
idand type-levelgroupTypeRoleIdnecessarily differ:
ids are livegroup_roledomainIds carrying that role'sauthored grants;
groupTypeRoleIdappears anywhere in the domainId set —decisive, since a type-scoped key would have to;
per-(group, role) pairing predicts.
GROUP_ROLE_ENDPOINTandGROUP_ROLE_PAIRING_FIELDwere already correct and areunchanged; only the comment, handbuch callout and runbook row move to "verified".
Notes for review
id:escape hatch behave exactly as before. The only new rejection is a barestring on a non-group dimension, which could not previously resolve correctly.
docs/handbuch/pages re-read and re-signed;docs/api-coverage.mdgains rowsfor
ct_person_statusandct_department; the runbook'sgroup_rolerow flipsto "verified live" and gains a Bereich row.
ct-structure, thedocs and examples use neutral placeholders rather than real resource names.
https://claude.ai/code/session_01NiQXu3vCDVgHLbWxmgXoyJ