Skip to content

feat: ct adopt grants — emit paste-ready permission config from live rows (#25) - #45

Merged
2000game merged 4 commits into
mainfrom
feat/grant-adoption-25
Jul 9, 2026
Merged

2000game merged 4 commits into
mainfrom
feat/grant-adoption-25

Conversation

@2000game

@2000game 2000game commented Jul 9, 2026

Copy link
Copy Markdown
Member

Implements the grant-adoption bullet of #25 (domainId-by-reference waits on #20, in flight; catalog lifecycle separate). This removes the hand-transcription blocker for #23: an existing instance's rights structure can now be read off into config.

Surface

ct adopt grants <domainType> <domainId> — accepts group_role/group_type_role (and hyphenated spellings), prints a paste-ready ct.groupRole({...})/ct.groupTypeRole({...}) block to stdout. Grants aren't state-tracked, so this is config-only — stated explicitly in the output.

Semantics — exactly what the planner would manage

  • Rows run through the same normalizeActual as reconciliation: baseline (modifiedPid === -1) and inherited rows excluded; revoke/deny rows preserved-and-noted, never emitted.
  • authId → module:right reverse-catalog mapping; unknown authId emits the numeric form with a WARNING comment instead of failing.
  • Scoped rows collapse per authId; dataIds matching managed groups emit logical state keys, unmanaged ones emit a placeholder comment pointing at ct adopt group <id> first.
  • Host-guarded state load before any network call; assertNotPeople enforced.

Pure emitAdoptedGrants core (no network) + thin command wrapper; no changes to permissions/plan.ts/types.ts (parallel #20 branch owns those).

Verification: 296 tests passing (13 new), typecheck + lint clean.

Addresses #25 (grant-adoption bullet).

2000game added 3 commits July 9, 2026 08:50
Read GET /permissions/<domainType>/<domainId>, run the rows through the
planner's normalizeActual, and print a paste-ready ct.groupRole /
ct.groupTypeRole block. Baseline (modifiedPid===-1) and inherited rows are
excluded; revoke/deny rows are preserved-and-noted, not emitted; authIds map
back to module:right via the catalog (unknown -> warning comment); scoped
dataIds map to managed-group state keys (unmanaged -> placeholder comment).
Grants are not state-tracked, so this prints config only and never writes
state. Wired as a subcommand of ct adopt.
@2000game

2000game commented Jul 9, 2026

Copy link
Copy Markdown
Member Author

Review — REQUEST_CHANGES (verified empirically by running the emitter's output through the real desiredTuples)

The core structure is right (planner-identical normalization, pure emitter core, correct host-guard ordering, no shadowing), but the headline invariant — paste → re-plan → no-op — is violated in two reachable cases, and one case is destructive:

  1. MAJOR — group_type_role rows with authId >= 10000 (52 catalog rights, all churchdb:+…) are emitted as grants, then the planner rejects them as unwritable → pasted config throws at ct plan. Emitter must mirror the planner's guard (NOTE comment, not grant).
  2. MAJOR — a scoped right granted globally in CT (dataId: null) is emitted as a bare string, which the scoped-right guard rejects at plan time. The emitter's scoped/hasUnscoped fields are computed but never read — branch on them (WARNING comment, not bare string).
  3. MEDIUM — any grant left as a WARNING/placeholder comment means the pasted block omits a live row, and reconciliation will DELETE it on the next apply. Output guidance must state this consequence explicitly.
  4. MINOR — "numeric form" claim in PR body/test name: code emits comments only (correct — numeric rights are undeclarable); rename.
  5. LOW — group-id scope resolution comment for non-cdb_gruppe scopeField collision risk.

Verified clean: catalog has zero duplicate authIds; multi-scope collapse round-trips order-independently; baseline/inherited/revoke handling matches the reconciler; suite green.

Fixes in progress on this branch; will re-verify the round-trip property before merge.

…t-only revocations (#25)

Addresses PR #45 review (REQUEST_CHANGES):

- group_type_role rows with authId >= 10000 (the churchdb:+… family, 52 catalog
  rights) are no longer emitted as grants — desiredTuples rejects them at plan
  time. They become NOTE comments explaining they reach roles via inheritance
  and are not writable on this domain type.
- A SCOPED right returned with dataId null (granted globally in CT) no longer
  collapses to a bare string — which the plan-time silent-global-grant guard
  rejects. It is emitted as a WARNING comment; ReverseEntry.scoped and
  CollapsedGrant.hasUnscoped are now actually consulted. An unscoped-per-catalog
  right carrying dataIds (stale catalog) is likewise a WARNING, not a scope.
- Comment-only grants are pending REVOCATIONS: reconciliation deletes any live
  grant missing from the declaration. The block now prints a header counting
  them and saying apply will revoke them; the command's stderr guidance and
  docs/permissions.md's no-op claim say the same.
- Renamed the unknown-authId test: it emits a comment only, never a numeric
  grant (numeric rights are undeclarable in the DSL).
- Comment on findByTypeId(state, "group", …): only cdb_gruppe rights truly
  scope by group; other scopeFields' ids could collide (pre-existing tool-wide
  constraint, no behavior change).
- New round-trip property test: for any mix of rows, every ACTIVE emitted grant
  passes the real desiredTuples without throwing, on both domain types.
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.

1 participant