Skip to content

feat(resources): promote comment-viewer to a declarable resource (#151) - #152

Merged
2000game merged 3 commits into
mainfrom
feat/comment-viewer-resource
Aug 26, 2026
Merged

feat(resources): promote comment-viewer to a declarable resource (#151)#152
2000game merged 3 commits into
mainfrom
feat/comment-viewer-resource

Conversation

@2000game

Copy link
Copy Markdown
Contributor

Closes #151.

cdb_comment_viewer was the last scope dimension a config could not express portably. It had a reference form since #102 but nothing to declare the viewers, so a config granting churchdb:view comments had to write a raw, host-specific dataId — which silently means something different, or nothing, on another host, and no plan or row count can see it. The obvious workaround (a name ref) is worse, not better: the second host lacks the names too, so the ref hard-errors instead of resolving wrongly.

Declaring the viewer is what makes the name exist on both hosts, which is what makes the reference portable.

ct.commentViewer({ key: "dienstbereich", name: "Dienstbereich", sortKey: 40 });
...
{ right: "churchdb:view comments", scope: [{ commentViewer: "dienstbereich" }] }

What changed

  • Registrycomment-viewer at /person/commentviewers, tier 0, PUT updates, managing name + sortKey (the whole editable surface of this 3-column master-data table, per epic(resources): generic master-data driver — 22 self-describing editable tables behind one endpoint #109's list). No new machinery was needed: CT mints the id (unlike security-level) and the writes are REST (unlike department). ct adopt comment-viewer <id> falls out of the registry.
  • DSLct.commentViewer({ key, name, sortKey }).
  • Resolution{ commentViewer: "…" } now resolves managed state first, live catalog second, so a viewer declared in the same run resolves pending and gets its real id at apply time, while a ref to a viewer the config does not own keeps working exactly as in feat(permissions): opt-in partial grant ownership — one module-scoped grant currently blocks 403 of 590 grants #102.
  • Grant adoption — an unmanaged viewer dataId now earns the ct adopt comment-viewer <id> hint instead of the weaker "portable form" note; a managed one is emitted as { commentViewer: "<key>" }.
  • Docs — permissions.md's "catalog dimensions: referenceable, not declarable" section is replaced by the declarable one (no scope dimension with a logical form is catalog-only any more), plus new rows in api-coverage.md and the README.

Acceptance, per the issue

Asked Where
ct adopt comment-viewer <id> emits a config entry and a state row registry entry; tests/comment-viewer-resource.test.ts "adopt → config → plan round-trips to a no-op"
ct.commentViewer({ key, name, sortKey }) declares one; plan/apply create and update, never delete implicitly ct.commentViewer + "declaring, creating and updating a comment viewer" (apply never deletes — unchanged engine rule; ct destroy carries a warning naming what a delete reaches)
Declared viewers resolve first, live catalog second REF_KIND_TYPE entry; "the same config means the same thing on two hosts"
Same config on two hosts ⇒ same names, and a grant meaning the same thing the two-host test: same declaration, dataId [4] on one host and [2] on the other

One judgement call worth a look

Reads of a single viewer filter the collection rather than hitting GET /person/commentviewers/{id}. The probe behind this dimension recorded the collection read plus POST/PUT/DELETE on the item path; a GET on the item path was never probed, and guessing it has the #108 failure mode — a 404 reads as "vanished in ChurchTools", so every plan proposes creating the viewer again and every apply duplicates it. If someone probes that path live, the fetchOne hook can just be dropped.

Not live-probed in this PR by design (the stored token points at prod).

Verification

npm run typecheck, npm test (1001 passed, 5 skipped — 11 new), npm run format. npm run lint reports 7 errors, all pre-existing and all inside an untracked stray .claude/worktrees/.../dist/index.js — identical on a clean checkout of main.

https://claude.ai/code/session_011DU9pubAh7dGWyojwd1smn

`cdb_comment_viewer` was the last scope dimension a config could not express
portably. It had a reference form since #102 but nothing to declare the viewers
themselves, so a config granting `churchdb:view comments` had to write a raw,
host-specific `dataId` — and that id silently means something different, or
nothing, on another host. Nothing detects it: a plan compares the declared id
against the live id and they match; the id is just meaningless on the target.
Switching to a name ref made it worse rather than better, because the second host
lacks the NAMES too, so the ref hard-errors instead of resolving wrongly.

Declaring the viewer is what makes the name exist on both hosts, which is what
makes the reference portable.

- registry: `comment-viewer` at `/person/commentviewers`, tier 0, PUT updates,
  managing `name` + `sortKey` (the whole editable surface of this 3-column
  master-data table). No new machinery: CT mints the id (unlike `security-level`)
  and the writes are REST (unlike `department`). Reads of a single viewer filter
  the collection — a GET on the item path was never probed, and guessing it has
  the #108 failure mode (a 404 reads as "vanished", so every plan re-creates it).
- DSL: `ct.commentViewer({ key, name, sortKey })`; `ct adopt comment-viewer <id>`
  falls out of the registry.
- resolution: `{ commentViewer: "…" }` now resolves managed-state-first, live
  catalog second, so a viewer declared in the same run resolves pending and is
  re-resolved at apply time — and a ref to a viewer the config does not own keeps
  working exactly as before.
- grant adoption: an unmanaged viewer dataId now earns the `ct adopt
  comment-viewer <id>` hint instead of the weaker "portable form" note, and a
  managed one is emitted as `{ commentViewer: "<key>" }`.

Docs: permissions.md's catalog-only section is replaced by the declarable one
(no scope dimension with a logical form is catalog-only any more), plus rows in
api-coverage.md and README.

Claude-Session: https://claude.ai/code/session_011DU9pubAh7dGWyojwd1smn
…t-in "Alle"

Review of #152 raised five findings. A live probe on eqrm-dev (CT 3.135.2,
2026-08-26 — one throwaway row created, read, updated, deleted, instance left as
found) settled the two that needed evidence.

- The create path was described three different ways: "conventional REST", "the
  item path takes POST/PUT/DELETE", and a plain collection POST in the code. The
  probe confirms the code: POST on the COLLECTION, CT mints the id. Replaced the
  vague prose in registry.ts, api-coverage.md and permissions.md with the actual
  verb table, and recorded why a viewer can never be callerAssignedId — the
  minted id skips deleted rows and moved 5 -> 8 -> 11 on a three-row instance.

- Dropped `fetchOne`. It filtered the whole collection because `GET {itemPath}`
  was unprobed and guessing wrong has #108's failure mode. The probe retired the
  guess: a present id returns the row, an absent one a clean 404 error.notfound.
  That is what the default read needs, and it removes one full collection read
  per managed viewer per plan/apply/destroy — `fetchActual` fans out
  concurrently, so the hook cost N round-trips against a rate-limited API.

- `dataId: 0` (the built-in "Alle" viewer) is no longer advertised as adoptable.
  Only `-1` was filtered, so adoption printed `ct adopt comment-viewer 0`;
  following that hint duplicates "Alle" on any second host, misgrants to the
  duplicate, and makes `{ commentViewer: "alle" }` permanently ambiguous. Added
  BUILTIN_SCOPE_IDS, keyed by dimension so `0` elsewhere stays an ordinary
  host-specific id, and emitted as a number (an admin can rename the row).

- Corrected the stale ScopeEntry doc (departments and security levels have been
  declarable since #108/#110; comment viewers since #151) and a section heading
  that stated the opposite of its own section.

The registry's "3-column table, so the managed set is complete" reasoning was
wrong — rows carry a fourth column, `nameTranslated`. The conclusion holds for a
different reason: the probe's PUT sent only {name, sortKey} and nameTranslated
followed name, so it is derived, not a sibling a PUT could blank.

Claude-Session: https://claude.ai/code/session_011DU9pubAh7dGWyojwd1smn
Follow-up review of e9b03ac found three minor issues, no correctness defects.

- `builtinScopeIdName` hardcoded the comment-viewer case instead of deriving from
  BUILTIN_SCOPE_IDS, so the two could drift: adding a dimension to the table but
  not the function would suppress the adoption hint while emitting no comment
  explaining the suppression — a bare `scope: [0]` with nothing saying why it was
  not flagged. The name now lives in the table and `isBuiltinScopeId` derives
  from it, which makes the two impossible to desync.

- The "verified live on two hosts, 2026-08-26" date conflated two probes: the
  two-host read was 2026-08-24, and 2026-08-26 was eqrm-dev only.

- The duplicate-"Alle" story overstated the ambiguity error. Resolution is
  managed-state-first, so on the host that created the duplicate the ref quietly
  resolves to it and never reaches resolveFromCatalog's ambiguity check. The
  hard error only reaches a config reading that catalog without a state entry, so
  the failure surfaces somewhere other than where it was caused — which is worse
  than the original wording, not better.

Claude-Session: https://claude.ai/code/session_011DU9pubAh7dGWyojwd1smn
@2000game
2000game merged commit 7cfa819 into main Aug 26, 2026
3 checks passed
@2000game
2000game deleted the feat/comment-viewer-resource branch August 26, 2026 09:20
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): promote comment-viewer to a declarable resource — raw cdb_comment_viewer ids are the last host-specific ids left in ct-structure

1 participant