Skip to content

feat(permissions): portable scope refs + declarable person statuses - #99

Merged
2000game merged 2 commits into
mainfrom
feat/portable-scope-refs-and-person-statuses
Aug 13, 2026
Merged

2000game merged 2 commits into
mainfrom
feat/portable-scope-refs-and-person-statuses

Conversation

@2000game

Copy link
Copy Markdown
Member

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 scope now takes a typed reference alongside the group key and
the numeric dataId:

{ right: "churchdb:view station", scope: [{ campus: "koblenz" }] }
{ right: "churchgroup:view groups of grouptype", scope: [{ groupType: "struktur" }] }
{ right: "churchdb:view alldata", scope: [{ department: "outreach" }] }

{ campus: "x" } is sugar for ref.campus("x"), so both spellings are identical.

scopeField Reference Resolved against
cdb_gruppe { group: "…" } / bare string managed groups
cdb_station { campus: "…" } managed campuses → /campuses
cdb_gruppentyp { groupType: "…" } managed group types → /group/grouptypes
cdb_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 ct own it, a wrong-scope grant doesn't merely add
the wrong thing, it revokes the right thing 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 hard errors at plan time, never a guessed dataId:

  • a ref whose dimension doesn't match the right's scopeField (names both);
  • a ref on a dimension with no logical form (points at the numeric hatch);
  • a bare string on a non-group dimension — a latent bug this surfaced. A
    string always meant "managed group", so on e.g. a cdb_station right it either
    failed confusingly or matched an unrelated group carrying that key.

ct adopt grants emits typed refs for managed campus/group-type dataIds, and a
NOTE naming the one command that makes the rest portable.

#96 — person statuses are declarable

Adds a person-status resource kind: ct.personStatus, ct adopt person-status <id>, ct get statuses. A personStatus: domain now resolves from managed
state before the /statuses catalog, so a status declared in the same config
resolves to a pending domain and converges in one ct apply.

Previously the resolver's own advice was impossible to follow:

✗ Cannot resolve person-status:3 - Group Active … no managed resource and no live
  person-status at /statuses matches key "3 - Group Active".
  Declare/adopt it, fix the key/name, or use a numeric id.

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):

Also: the #25 group_role assumption is retired

The pairing-id model has carried an ASSUMPTION — verify once on a live instance block since #25. Verified against two anchors on different group
types, each chosen so the per-group id and type-level groupTypeRoleId
necessarily differ:

  • both role rows' ids are live group_role domainIds carrying that role's
    authored grants;
  • neither row's groupTypeRoleId appears anywhere in the domainId set —
    decisive, since a type-scoped key would have to;
  • roles with no authored rights have no domain at all, exactly as a
    per-(group, role) pairing predicts.

GROUP_ROLE_ENDPOINT and GROUP_ROLE_PAIRING_FIELD were already correct and are
unchanged; only the comment, handbuch callout and runbook row move to "verified".

Notes for review

  • Backward compatible. Bare-string group scopes, numeric dataIds and every
    id: escape hatch behave exactly as before. The only new rejection is a bare
    string on a non-group dimension, which could not previously resolve correctly.
  • docs/handbuch/ pages re-read and re-signed; docs/api-coverage.md gains rows
    for ct_person_status and ct_department; the runbook's group_role row flips
    to "verified live" and gains a Bereich row.
  • 612 tests pass (35 new across two files), typecheck and lint clean.
  • Per the repo's own rule that per-instance values belong in ct-structure, the
    docs and examples use neutral placeholders rather than real resource names.

https://claude.ai/code/session_01NiQXu3vCDVgHLbWxmgXoyJ

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
@2000game
2000game merged commit 35c1fbf into main Aug 13, 2026
3 checks passed
@2000game
2000game deleted the feat/portable-scope-refs-and-person-statuses branch August 13, 2026 14:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(resources): person statuses are not declarable — the status permission domain is only half-portable

1 participant