From fc5c225cabaa567f3bbdec5852c45b11711cf90c Mon Sep 17 00:00:00 2001 From: Bernhard Weichel Date: Thu, 27 Aug 2026 14:01:54 +0200 Subject: [PATCH 1/4] feat: add external resource bindings --- README.md | 21 +- docs/README.md | 1 + docs/adoption-contract.md | 25 +- docs/external-resources.md | 175 +++++++++ docs/handbuch/blueprints.md | 34 +- docs/handbuch/dynamic-groups.md | 16 +- docs/handbuch/permissions.md | 132 +++---- src/application/contracts.ts | 2 +- src/application/errors.ts | 2 + src/application/operations/index.ts | 2 + src/application/operations/ownership.ts | 351 ++++++++++++++++++ src/application/operations/plan.ts | 44 ++- src/application/operations/state.ts | 127 ++++++- src/application/operations/use.ts | 289 ++++++++++++++ src/commands/ownership.ts | 27 ++ src/commands/state.ts | 68 +++- src/commands/use.ts | 146 ++++++++ src/completion/candidates.ts | 4 + src/completion/sources.ts | 8 +- src/config/context.ts | 12 +- src/engine/build.ts | 11 +- src/engine/hierarchy.ts | 5 +- src/engine/synthetic.ts | 8 +- src/index.ts | 4 + src/permissions/scope.ts | 13 +- src/resolve/external.ts | 128 +++++++ src/resolve/refs.ts | 21 ++ src/resolve/resolver.ts | 301 ++++++++++----- src/resources/registry.ts | 87 +++++ src/state/state.ts | 146 +++++++- tests/adopt-group-command.test.ts | 16 + tests/application/apply-operation.test.ts | 29 ++ tests/application/destroy-operation.test.ts | 17 + tests/application/ownership-operation.test.ts | 123 ++++++ tests/application/state-operation.test.ts | 61 ++- tests/application/use-operation.test.ts | 200 ++++++++++ tests/blueprint.test.ts | 6 +- tests/comment-viewer-resource.test.ts | 22 +- tests/completion.test.ts | 21 +- tests/context.test.ts | 6 +- tests/external-registry.test.ts | 64 ++++ tests/init.test.ts | 3 +- tests/permission-pending-domain.test.ts | 7 +- tests/permission-plan.test.ts | 32 +- tests/permission-scope-refs.test.ts | 100 ++--- tests/person-status-resource.test.ts | 18 +- tests/plan-partial-honesty.test.ts | 8 + tests/portable-refs.test.ts | 66 +++- tests/resolver.test.ts | 187 +++++++--- tests/state.test.ts | 46 ++- tests/use-command.test.ts | 65 ++++ 51 files changed, 2910 insertions(+), 397 deletions(-) create mode 100644 docs/external-resources.md create mode 100644 src/application/operations/ownership.ts create mode 100644 src/application/operations/use.ts create mode 100644 src/commands/ownership.ts create mode 100644 src/commands/use.ts create mode 100644 src/resolve/external.ts create mode 100644 tests/application/ownership-operation.test.ts create mode 100644 tests/application/use-operation.test.ts create mode 100644 tests/external-registry.test.ts create mode 100644 tests/use-command.test.ts diff --git a/README.md b/README.md index 718462b..336e667 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,9 @@ code, and reconcile it against the ChurchTools API with Terraform-style **`plan` / `apply`**. > **People are never managed.** This tool touches only the scaffold, and only -> resources that are _explicitly_ declared or adopted. Everything else is -> invisible: never shown, never changed, never proposed for deletion. +> ct-cli resources that are _explicitly_ declared or adopted. External +> bindings are read-only prerequisites: they may be resolved and validated, +> but are never changed or proposed for deletion. ## Why @@ -213,9 +214,12 @@ ct auth status # who am I? (`--env ` asks on another instan ct get groups # JSON to stdout — pipe into jq (every page, not just the first) ct adopt campus 0 # bring ONE existing resource under management +ct use group 4711 --key shared_group # bind an existing object read-only ct coverage # what the instance has that the config does not manage -ct state list # what is managed -ct state rm campus mainz # un-adopt: drop it from state. Never touches ChurchTools. +ct state list # managed and external entries, explicitly labelled +ct state rm campus mainz # remove either state kind. Never touches ChurchTools. +ct state rekey group old new # rename a logical key; update every ref.* use too +ct ownership check .. --env prod # validate visible owner/consumer projects ct plan # diff the config against ChurchTools (read-only) ct plan --format markdown # plain-language review report (German by default) ct apply # create + update in dependency order (confirm + backup first) @@ -231,9 +235,9 @@ printed — only the login token and its host reach the Keychain. There is delib Windows there is no Keychain to store anything in, so the prompt is not offered at all: export `CT_HOST` and `CT_LOGINTOKEN` there. -`state rm` is the inverse of `adopt`, and only of `adopt`: it removes the entry -from the state file, makes no HTTP call, and leaves the resource in place in -ChurchTools, now unmanaged. It refuses a key the config still declares — that +`state rm` removes a managed or external entry from the state file, makes no +HTTP call, and leaves the ChurchTools object in place. For managed entries it +refuses a key the config still declares — that would make the next plan propose creating a resource that already exists — so delete the declaration first, or pass `--force` to do both in one change. "Declares" covers permission declarations too, not only resources: a key named @@ -290,6 +294,9 @@ ct apply --env prod # protected env: type the env name to confirm - [**CI usage**](docs/ci.md) — the auth model and token-from-secret setup, `--detailed-exitcode`, Markdown/JSON plan projections, deterministic sidecar names, drift-vs-config attribution, and copy-pasteable PR artifacts. +- [**External ct-cli resources**](docs/external-resources.md) — the terminology + boundary, read-only `ct use` workflow, identity checks, state operations, and + cross-project ownership analysis. ## Guardrails (by design) diff --git a/docs/README.md b/docs/README.md index d6a750c..facd6d5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -51,6 +51,7 @@ A page with no code behaviour to track declares `sources: []` plus a | Page | About | | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | [`adoption-contract.md`](adoption-contract.md) | What else comes along when `ct adopt` adopts one resource — the five categories and their defaults | +| [`external-resources.md`](external-resources.md) | Read-only cross-project bindings, `ct use`, identity validation, state, and ownership checks | | [`api-coverage.md`](api-coverage.md) | Which ChurchTools endpoints support which CRUD verbs | | [`group-field-decisions.md`](group-field-decisions.md) | Which group fields are managed vs. left to the CT UI, and why | | [`runbook-manual-surface.md`](runbook-manual-surface.md) | What `ct` cannot automate today — where the write path is missing, and the manual steps around it | diff --git a/docs/adoption-contract.md b/docs/adoption-contract.md index e28bee4..cc5363d 100644 --- a/docs/adoption-contract.md +++ b/docs/adoption-contract.md @@ -127,8 +127,7 @@ ruleset, a calendar or a bookable room on an event. the thing that references it, at any depth, under any flag.** Adopting a resource booking does not adopt the room. -What is emitted instead, in order of preference — all of it already -implemented: +What is emitted instead, in order of preference: - **already managed** → a portable logical reference. `ReverseResolver.sugarFields` rewrites `campusId` → `campus: "…"`; `portablizeRuleset` rewrites entity ids @@ -138,6 +137,10 @@ implemented: `unmanaged`. `--strict-rulesets` already turns that from a warning into a refusal for rulesets; the same escalation applies to any category-4 reference. + The consumer can now replace that interim numeric form with an explicit + external binding (`ct use --key `) and the normal logical + `ref.*` form. See [External ct-cli resources](external-resources.md). + #### Why category 4 has no opt-in A `--with-referenced` flag would look harmless and would be the single most @@ -153,12 +156,12 @@ There are two supported remedies, and both make the ownership claim explicit: 1. `ct adopt ` on the referenced object — a deliberate, separate, visible act of taking ownership; -2. once #143 lands, declaring it as an **external / read-only prerequisite** — +2. declare it as an **external / read-only prerequisite** with `ct use` — resolvable in `ref` positions, never created, updated, deleted, or written. -Remedy 2 is the one this contract expects to become normal. Until it exists, -the numeric-id + `TODO` output is the honest interim: it says "this reference -is not portable yet" rather than pretending it is. +Remedy 2 is the normal consumer workflow. Numeric-id + `TODO` output remains an +honest signal from adoption that the reference is not portable until explicitly +bound; adoption never claims or writes the external object as a side effect. ### 5. Person-related data — permanently excluded @@ -390,11 +393,11 @@ This issue decides only. The work the contract implies, roughly in order: one adopted without its member fields. 5. **The five-verb summary** replacing today's per-command ad-hoc output, and a test pinning the rendered summary (the same discipline `plan` output has). -6. **External/read-only references** — [#143](https://github.com/eqrm/ct-cli/issues/143). - Until it lands, category 4 falls back to numeric id + `TODO`; after it lands, - the emitted form for an unmanaged reference should become an external - declaration, which is what makes category 4's "no opt-in" rule comfortable - rather than merely correct. +6. **External/read-only references** — implemented by + [#143](https://github.com/eqrm/ct-cli/issues/143). Category 4 still emits a + numeric id + `TODO` until the consumer explicitly runs `ct use`; the persisted + external binding then makes its logical `ref.*` form portable without + transferring lifecycle ownership. ### How #135 applies this contract diff --git a/docs/external-resources.md b/docs/external-resources.md new file mode 100644 index 0000000..ed1d011 --- /dev/null +++ b/docs/external-resources.md @@ -0,0 +1,175 @@ +# External ct-cli resources + +Independent ct projects can share one ChurchTools object without sharing its +lifecycle. The owner project declares or adopts it. Each consumer records a +host-specific, read-only binding with `ct use` and keeps using the normal +portable `ref.*` form in config. + +```bash +# owner project: lifecycle ownership +ct adopt group 4711 --key ojahr_fuzzies --env prod + +# consumer project: read-only consumption +ct use group 4711 --key ojahr_fuzzies --owner shared-masterdata --env prod +``` + +```ts +ref.group("ojahr_fuzzies"); +``` + +There is deliberately no `ct.external.*` config DSL. The config stays portable; +state binds its logical keys to ids separately on every ChurchTools host. + +## Terminology boundary + +ChurchTools does not use one official umbrella term for the objects in the +ct-cli registry, and it uses “Resource” for other product concepts. In this +documentation: + +- **ct project** means one config plus its environment-specific state files and + therefore one lifecycle boundary. +- **ct-cli resource** means an independently addressable top-level object in + the resource registry. It does not mean a ChurchTools “Resource” feature. +- **managed** means this ct project owns lifecycle responsibility. Its plan, + apply, and an explicit destroy may act on the object. +- **external** means this ct project can resolve the object read-only but does + not own it. This is ct-cli state only; no ChurchTools flag is written. +- **owner project** is the project with the managed state entry; a **consumer + project** has an external binding. +- A **logical key** is the portable, user-controlled name used by `ref.*`. It is + never stored in ChurchTools. +- A **host binding** maps `(ct-cli resource type, logical key)` to a ChurchTools + id for one host. +- An **identity snapshot** is the minimal live identity used to detect a changed + binding meaning. It is validated, never reconciled by the consumer. +- A **coordination scope** is only the explicit directory tree inspected by + `ct ownership check`; it is not a ChurchTools boundary. + +## Creating a binding + +The deterministic form is suitable for scripts and CI. Both id and key are +mandatory outside a terminal: + +```bash +ct use group 4711 --key ojahr_fuzzies --env prod +``` + +In a terminal, a string selector performs fuzzy discovery within the explicit +type and shows every match with its id, exact name, and disambiguating fields: + +```bash +ct use group "OJAHR Fuzzies" --env prod +``` + +The command never guesses among multiple matches. It proposes an existing +consumer key when the id is already bound; otherwise it proposes a one-time +slug that can be edited. A logical key is never recalculated later. + +`ct use` reads ChurchTools to validate the selected object but never writes it. +It is byte-idempotent: the same key, id, owner metadata, and hard identity is a +successful no-op and does not update `boundAt`. A hard identity change or a +replacement id requires interactive confirmation or `--yes`; the command shows +the changed fields or old/new targets first. Display-only changes need no +confirmation. Managed keys and ids cannot be rebound as external, and neither a +key nor `(type, id)` may have a second external alias. + +Supported top-level types come directly from the registry: `campus`, `group`, +`group-type`, `age-group`, `target-group`, `relationship-type`, +`person-status`, `department`, `security-level`, `comment-viewer`, and +`group-role`. Permissions, relationship edges, owned child structures, and all +person-related data are not independent external types. + +## State and identity + +State version 2 keeps lifecycle ownership and consumption separate: + +```json +{ + "version": 2, + "host": "https://example.church.tools", + "resources": {}, + "externals": { + "ojahr_fuzzies": { + "type": "group", + "key": "ojahr_fuzzies", + "id": 4711, + "owner": "shared-masterdata", + "identity": { "name": "OJAHR Fuzzies", "groupTypeId": 17 }, + "boundAt": "2026-08-27T12:00:00.000Z" + } + } +} +``` + +Version-1 files load in memory as version 2 with an empty `externals` map and +are written as version 2 on the next state mutation. Managed entries remain in +`resources`; external entries never carry managed fields or lifecycle flags. + +The registry defines hard identity: name for every type, plus group type id for +groups and group roles. Short names, translated names, campus/status, sort +order, relationship labels, member-status meaning, numeric security level, and +leader/participant role type are selection display only. Thus moving a group to +another campus does not block a consumer plan; renaming it or changing its group +type does. + +Inspect or maintain either state partition with the shared commands: + +```bash +ct state list --env prod # managed and external, with explicit kind +ct state list --managed --env prod +ct state list --external --env prod +ct state rm group ojahr_fuzzies --env prod +ct state rekey group old_key new_key --env prod +``` + +Removal and rekeying never contact ChurchTools. Rekeying requires every config +and `ref.*` use to be changed consistently. + +## Planning and safety boundary + +Resolution checks managed state first, same-run managed declarations second, +and persisted external bindings third. A bound external is read live by id and +its hard identity is verified before use in supported positions such as +permissions, hierarchy parents, and dynamic rulesets. + +An external is never a desired or pending resource. It therefore cannot emit a +create, update, or delete action; consumer `apply` and `destroy` enumerate only +managed state. If an external prerequisite is missing, stale, ambiguous, or has +changed identity, plan fails before writes. Discovery may provide complete +`ct use` commands, but plan never persists or temporarily consumes a candidate. +The consumer never applies or repairs the owner project. + +Blocking diagnostics include a stable reason code and structured context, +evidence, consequence, numbered remedies, and an exact verification command. +Typical recovery is one of: + +```bash +# create/repair the object from its owner project first +cd ../shared-masterdata && ct plan --env prod + +# bind the verified live object in the consumer +ct use group 4711 --key ojahr_fuzzies --env prod +ct plan --env prod +``` + +Do not bind an id that returns 404. Repair the owner's stale state or restore the +object first. + +## Checking ownership across projects + +Run the check with an explicit complete directory scope: + +```bash +ct ownership check .. --env prod +``` + +It recursively finds ct projects below that root, ignores `.git`, +`node_modules`, and build output, groups results by ChurchTools host, and makes +no network calls. It reports duplicate managed owners, missing or mismatching +owner hints, different keys for the same `(type, id)`, conflicting bindings, +and incompatible identity snapshots. Conflicts return a non-zero exit code for +CI and include `ct state rekey`, `ct state rm`, or broader-scope remediation. + +The guarantee is intentionally scope-limited. Projects outside the supplied +root remain unknowable; global atomic ownership would require a separate shared +registry. diff --git a/docs/handbuch/blueprints.md b/docs/handbuch/blueprints.md index b0aef90..4345173 100644 --- a/docs/handbuch/blueprints.md +++ b/docs/handbuch/blueprints.md @@ -54,7 +54,10 @@ freshly-created id at apply time (tier ordering creates the campus first). `ct plan` renders it as `campusId = `. The same portability applies to the group type: `groupType: "ministry_team"` -resolves against the live catalog per host, no hardcoded `groupTypeId`. +resolves through that host's managed or explicitly external state, with no +hardcoded `groupTypeId`. If another ct project owns it, bind it once per host +with `ct use group-type --key ministry_team`; plan never guesses from the +live catalog. ```ts function kidsArea(ct: ConfigContext, campus: string): void { @@ -225,20 +228,22 @@ per-group declaration order) → permission grants, for as many campuses as the loop instantiates, with no manual `parent:`/`dependsOn` bookkeeping beyond the `parents: [lead]` you'd write anyway. -## The managed-parent typo guard +## Parent-reference validation -`parents` references are validated **at config-evaluation time**, before -any plan or diff is computed (`validateReferences` in -[`src/config/context.ts`](https://github.com/eqrm/ct-cli/blob/main/src/config/context.ts), run by -`evaluateConfig`). Every key listed in a `parents` array must resolve to a -`group` declared _somewhere in the same config_ — including inside a -blueprint function called from the top-level export. A typo, a forgotten -`kidsArea(ct, campus)` call, or a `parents` key pointing at a non-group -resource throws immediately: +`parents` references are checked in two stages. Config evaluation +(`validateReferences` in +[`src/config/context.ts`](https://github.com/eqrm/ct-cli/blob/main/src/config/context.ts)) immediately rejects a key +that is declared as a non-group resource. A key not declared in this config is +allowed to continue because it may name an external parent recorded in this +host's state. Plan then resolves it as a group and validates a bound external's +live hard identity before any write. A key in neither managed nor external state +blocks plan with a copyable `ct use group --key ` remedy when discovery +finds a candidate. ``` -Group "berlin_kids_0_3" declares hierarchy parent "berlin_kids_laed", which is not declared in this config. -Managed parents must reference a group by its key (omit unmanaged parents entirely). +External prerequisite is not available +resource: group "berlin_kids_laed" +Consequence: Consumer plan/apply is blocked before writes. ``` The same pass validates **group member field references** (#135): a @@ -255,12 +260,13 @@ example `key: "stand_bewerbung"` plus `referenceName: "stand-bewerbung"`. Ruleset resolution follows that mapping; it never treats `-` and `_` as the same API identity. -This matters more in a blueprint than in a hand-written flat config, +This staged check matters more in a blueprint than in a hand-written flat config, because the `${campus}_`-prefixed key is itself computed (`` `${campus}_kids_lead` ``, not a literal string) — a copy-paste slip in one branch of a blueprint (e.g. reusing `mainz`'s lead key inside the `berlin` iteration) is exactly the kind of mistake this guard exists to -catch before it ever reaches `ct plan` against a live instance. +catch before any apply write. Locally declared wrong types fail offline; unknown +keys are checked against host-bound state and ChurchTools during plan. ## Full example diff --git a/docs/handbuch/dynamic-groups.md b/docs/handbuch/dynamic-groups.md index edff036..b2c142f 100644 --- a/docs/handbuch/dynamic-groups.md +++ b/docs/handbuch/dynamic-groups.md @@ -157,19 +157,21 @@ no-op — it does not re-`PUT` on every apply). Two equivalent ways to author it { "==": [{ "var": "ctgroup.campusId" }, { "__ctRef": true, "kind": "campus", "key": "mainz" }] } ``` - Simple marker `kind`s carry a single `key` (the logical key / slug): - `campus`, `group`, `group-type`. A **role** (`role.id`) uses the compound + Simple marker `kind`s carry a single logical `key`, including `campus`, + `group`, and `group-type`. The key resolves from managed state or a persisted + external binding; an unbound live catalog match is diagnostic only and blocks + plan until `ct use` records the binding. A **role** (`role.id`) uses the compound `group-type-role` marker instead — `{ "__ctRef": true, "kind": "group-type-role", "groupType": "", "role": "" }` — because a ruleset's `role.id` is a **groupTypeRoleId** (a role scoped to a group type), and role names are not globally unique (see the table note below). See `ref` in `src/resolve/refs.ts`. -**Escape hatch — raw numeric ids pass through untouched.** A query that -references an _operational_ group outside the managed scaffold (no logical key to -resolve against) can keep the plain number; you then own its per-environment -correctness. This mirrors the permission scope escape hatch (#49): prefer a -reference, fall back to a number where no managed key exists. +**Escape hatch — raw numeric ids pass through untouched.** A query can keep a +plain number, but you then own its per-environment correctness. For a shared +top-level object, prefer `ct use --key ` plus a logical +reference: the consumer can resolve it without gaining create/update/delete +authority. This mirrors the permission scope escape hatch (#49). #### Auto-rewrite on capture (default since #101; was `--portable-rulesets`, #76) diff --git a/docs/handbuch/permissions.md b/docs/handbuch/permissions.md index 4b2c46a..6c2f956 100644 --- a/docs/handbuch/permissions.md +++ b/docs/handbuch/permissions.md @@ -80,7 +80,7 @@ person-status rights — as code, and reconcile them idempotently with the same export default (ct) => { ct.groupTypeRole({ key: "leiter_tpl", // logical key (unique across the whole config) - groupType: "ministry_team", // domain BY NAME — resolved to the domainId per host (#20) + groupType: "ministry_team", // logical key — resolved from managed/external state per host grants: [ "churchgroup:view group", // unscoped { right: "churchgroup:view group", scope: ["kids_area"] }, // scoped @@ -97,7 +97,7 @@ export default (ct) => { ct.status({ key: "core_external_login", - personStatus: "5 - Core", // domain BY PERSON-STATUS NAME — resolved against /statuses (#90) + personStatus: "core", // logical person-status key, managed or explicitly external // -1 is ChurchTools' "all values of this dimension" sentinel (here: every external system). grants: [{ right: "churchcore:login to external system", scope: [-1] }], }); @@ -111,24 +111,24 @@ reference or a numeric `id`: namespace with every other resource type). - **domain** — the permission domain object. Declare it **by reference** (the portable form, #20) or **by numeric `id`** (the escape hatch): - - `ct.groupTypeRole` — `groupType: ""` resolves against the live - group-type catalog per host, or `id: ` targets one directly. + - `ct.groupTypeRole` — `groupType: ""` resolves from managed or external + host state, or `id: ` targets one directly. - `ct.groupRole` — `group: "", role: ""` resolves the (group, role) pair to its pairing domainId per host (#25), or `id: ` - targets one directly. The group must be **managed** (declared via `ct.group` - or adopted into state); it need not exist on the host yet — a group declared + targets one directly. The group may be managed (declared via `ct.group` or + adopted into state) or explicitly external via `ct use group`; a group declared in the same config plans as a pending domain and is granted later in that same `ct apply`, once it exists (#106). Declaring both a logical form and a numeric `id` is a conflict and throws. See "domainId semantics" for how the pairing id is resolved. - - `ct.status` — `personStatus: ""` resolves against the live - `/statuses` catalog per host, or `id: ` targets one directly. + - `ct.status` — `personStatus: ""` resolves from managed or external + host state, or `id: ` targets one directly. **Person** statuses ("0 - First", "3 - Group Active", …), not group statuses — see "domainId semantics". - **`grants`** — an array of `Grant`s, each either: - a bare string, `"module:right"` — an **unscoped** grant, or - an object `{ right: "module:right", scope: [...] }` — a **scoped** grant, - where each `scope` entry is a logical key of a managed group, a typed + where each `scope` entry is a logical key of a managed or external group, a typed logical reference such as `{ campus: "koblenz" }` (#98), or a raw numeric `dataId` (the escape hatch, #49). See "Scope resolution" below. @@ -220,7 +220,7 @@ The two DSL functions manage two different ChurchTools "domain types," and - **`group_type_role`** (`ct.groupTypeRole`) — the domain is the **group type's own id** (the same id you'd pass as `groupTypeId` on `ct.group`). It scopes the grant to "every role holder of this group type." Declare it portably as - `groupType: ""` (resolved per host, #20) or directly as `id: `. + `groupType: ""` (resolved per host from state, #20/#143) or directly as `id: `. - **`group_role`** (`ct.groupRole`) — the domain is the **internal (group, role) pairing's own id** — a ChurchTools-internal id for one specific group's specific role, _not_ the group's id and _not_ the role's @@ -239,7 +239,7 @@ The two DSL functions manage two different ChurchTools "domain types," and > different dimension with **no** REST catalog at all (#67) and must always be > written as a number. Person statuses do have one (`GET /statuses`, flat > array of `{id, name}` — live-verified 2026-08-10 on eqrm prod), so they - > resolve by name like campuses and group types. + > use the same logical-key binding model as campuses and group types. Since #96 the status itself is also **declarable**, via `ct.personStatus`: @@ -248,14 +248,11 @@ The two DSL functions manage two different ChurchTools "domain types," and ct.status({ key: "3_group_active_login", personStatus: "3_group_active", grants: [...] }); ``` - **Key it as `slug(name)`.** A `personStatus:` reference resolves against - managed state first and the live `/statuses` catalog second, and the catalog - matches by `slug(name)` — so a key that does not slug from the name (`"core"` - for `"5 - Core"`) can only ever match the declaration. On a host that already - has that status but has not adopted it, the plan then **creates a second, - identically-named status** and grants on the new one, leaving the real one - untouched. `ct adopt person-status ` emits `slug(name)` for this reason; - match it. + The logical key is user-controlled and need not equal `slug(name)`. `ct adopt` + derives a readable initial key; `ct use person-status --key ` records + a read-only host binding when another project owns the status. A declaration + still means lifecycle ownership and may create the status if it is absent from + this project's managed state. > **Teardown caveat.** A person status is the one managed type whose deletion > reaches person _records_: dropping the declaration and running `ct destroy` @@ -266,12 +263,10 @@ The two DSL functions manage two different ChurchTools "domain types," and > status is load-bearing. That is what makes a config using the `status` domain self-sufficient across - hosts. Before it, `personStatus: "…"` could only resolve against statuses that - already existed on the target instance, so a config that planned to a clean - no-op on prod died on dev with _"no managed resource and no live person-status - at /statuses matches key …"_ — whose own advice ("Declare/adopt it") was not - actually possible. A status declared in the same config resolves to a pending - domain and converges in one `ct apply`, exactly like a same-run group type. + hosts. A status declared in the same config resolves to a pending domain and + converges in one `ct apply`, exactly like a same-run group type. A shared status + owned elsewhere is never pending: the consumer must bind the already-existing + object with `ct use`, and missing/stale bindings block before writes. > **VERIFIED LIVE (2026-08-13, CT 3.135.2).** The reference form resolves by > reading the group's own role list (`GET /groups/{groupId}/roles`) and taking @@ -291,8 +286,8 @@ The two DSL functions manage two different ChurchTools "domain types," and > hardcode it like any other domainId. Resolution runs in `buildPermissionPlan` (`src/permissions/plan.ts`): a numeric -`id` passes straight through; a `groupType` reference resolves against the live -catalog, and a `group` + `role` pair against the group's role list. After +`id` passes straight through; a `groupType` reference resolves from managed or +external state, and a `group` + `role` pair against the group's role list. After resolution, two declarations that resolve to the **same** `(domainType, domainId)` are rejected (they would otherwise diff against each other's grants forever) — even if one used a name and the other a raw id. @@ -313,7 +308,8 @@ handled as a **pending domain** rather than aborting the plan: re-resolution machinery as resource pending refs. - The hard error (`references a resource created in the same run` → now only a genuine unresolvable) is reserved for references that resolve to **nothing**: - a key absent from the config, state, and the live catalog (a typo). + a key absent from the config and both managed/external state partitions. Live + discovery can explain a candidate but never supplies an ephemeral id. **`group_role` behaves the same way since #106.** A `group_role` domain id is the (group, role) **pairing** id, which only exists on @@ -417,7 +413,8 @@ forms (`src/permissions/scope.ts`): | **Typed logical reference** (#98) | `scope: [{ campus: "koblenz" }]` | campuses, group types — see below | | **Raw numeric `dataId`** (escape hatch, #49) | `scope: [1, 2, 3]` | any dimension | -String entries are resolved against **desired ∪ state**: +String entries are resolved against **desired managed resources ∪ managed state +∪ external state**: - A key already in state resolves to that group's `dataId`. - A key **declared in this config but not yet created** resolves to a _pending_ @@ -427,9 +424,10 @@ String entries are resolved against **desired ∪ state**: - A key that is neither in state nor declared throws: ``` - Scope key "kids_area" does not resolve to a managed group. Declare/adopt it, - use a group already under management, or pass a raw numeric dataId if this - right's scope is not a group (see the catalog's scopeField). + External prerequisite is not available: group "kids_area". + Bind the verified live group with `ct use group --key kids_area`, + declare/adopt it in its owner project, or pass a raw numeric dataId if this + right's scope is not a group. ``` The requirement that scope targets be tool-visible is deliberate: so `ct plan` @@ -463,21 +461,21 @@ ct.groupRole({ `{ campus: "koblenz" }` is sugar for `ref.campus("koblenz")` — the same `Ref` the rest of the DSL uses — so both spellings are interchangeable. -| `scopeField` | Reference form | Resolved against | -| -------------------- | ----------------------------------------- | ----------------------------------------------------------------- | -| `cdb_gruppe` | `{ group: "" }` (or the bare string) | managed groups | -| `cdb_station` | `{ campus: "" }` | managed campuses, then `GET /campuses` | -| `cdb_gruppentyp` | `{ groupType: "" }` | managed group types, then `GET /group/grouptypes` | -| `cdb_bereich` | `{ department: "" }` | managed Bereiche, then `GET /departments` (#108) | -| `cc_securitylevel` | `{ securityLevel: "" }` | managed security levels, then `GET /securitylevels` (#110) | -| `cdb_comment_viewer` | `{ commentViewer: "" }` | managed comment viewers, then `GET /person/commentviewers` (#151) | +| `scopeField` | Reference form | Resolved against | +| -------------------- | ----------------------------------------- | ----------------------------------- | +| `cdb_gruppe` | `{ group: "" }` (or the bare string) | managed or external groups | +| `cdb_station` | `{ campus: "" }` | managed or external campuses | +| `cdb_gruppentyp` | `{ groupType: "" }` | managed or external group types | +| `cdb_bereich` | `{ department: "" }` | managed or external Bereiche | +| `cc_securitylevel` | `{ securityLevel: "" }` | managed or external security levels | +| `cdb_comment_viewer` | `{ commentViewer: "" }` | managed or external comment viewers | Two `Ref` kinds are deliberately **not** in that table because no permission dimension scopes by them: `group-type-role` (a `groupTypeRoleId`, addressed by its `(group type, role name)` pair — #76) and `group-member-field` (a group-scoped member-field definition, addressed by its portable `(group key, local field key)` pair — #135). They share this file's resolver and -its "managed state first, then live lookup, else a hard error at plan time" +its "managed state first, then external binding, else a hard error at plan time" rules, but they are referenced from **dynamic-group rulesets**, not from grant scopes. For `group-member-field`, the logical pair selects a declaration; that declaration's exact ChurchTools `referenceName` selects the live row (#158). @@ -489,14 +487,12 @@ therefore a cross-environment misgrant, and because declaring a domain makes `ct` _own_ it, the wrong-scope grant also revokes whatever is really there on the other host. The typed reference makes one config plan clean on both. -Resolution mirrors the domain-reference rules: managed state first, the live -master-data catalog second, and a target **declared in this same config** -resolves to a _pending_ scope re-resolved at apply time. A reference resolved -through the catalog (not under management) carries an already-final id and is -not re-resolved. Catalogs are read **paginated** — ChurchTools returns only a -first page (10 rows) for a plain list read, so an instance with more campuses, -group types or departments than that would otherwise report a perfectly real -name as unresolvable. +Resolution mirrors the domain-reference rules: managed state first, a target +**declared in this same config** as pending, then a persisted external binding. +Every external is read live by id and its registry-defined hard identity is +validated. An unbound object always blocks plan; paginated live discovery may +list candidates and complete `ct use` commands, but never lends the plan an id +that was not persisted. Three things are hard errors at **plan** time, never a guessed `dataId`: @@ -563,15 +559,23 @@ something guarantees the row is there. `nameTranslated` is derived from `name`, not independently writable, so the managed set (`name`, `sortKey`) is complete and a `PUT` cannot blank a sibling. -- **Resolution is managed-state first, live catalog second.** An existing - reference to a viewer your config does not own keeps resolving exactly as it - did in #102, and a name that matches nothing anywhere is still a hard error - rather than a create: +- **Resolution is managed-state first, explicit external binding second.** A + viewer owned by another project is consumed without lifecycle authority by + binding it once per host: + +```bash +ct use comment-viewer 12 --key dienstbereich --env prod +``` + +Plan reads `GET /person/commentviewers/12`, validates the stored hard identity +(`name`), and uses the id only after it matches. An unbound name is still a +hard error; catalog discovery can suggest this command but cannot silently +resolve the reference: ``` -Cannot resolve comment-viewer:nope referenced at … : no managed resource and -no live comment-viewer at /person/commentviewers matches key "nope". -Declare/adopt it, fix the key/name, or use a numeric id. +External prerequisite is not available +resource: comment-viewer "nope" +Consequence: Consumer plan/apply is blocked before writes. ``` - **`ct apply` never deletes**, as everywhere else. `ct destroy` can, and warns: @@ -585,11 +589,9 @@ Declare/adopt it, fix the key/name, or use a numeric id. `ct.commentViewer({ key: "alle", name: "Alle" })` finds no state entry on a second host, so `ct apply` creates a **second** "Alle" with a fresh id and scopes the grant to the duplicate. Worse, that host never complains: - resolution is managed-state-first, so `{ commentViewer: "alle" }` quietly - resolves to the config's own duplicate. The two identically named rows only - hard-error for a config reading the catalog _without_ a state entry — a third - host, or the same one after a state reset — so the damage surfaces somewhere - other than where it was caused. The adopter therefore + the declaration authorises creating the managed object and resolves as pending, + so live discovery is not an ownership guard. An external binding would avoid + creating it, but is unnecessary for this documented cross-host constant. The adopter therefore treats it like the `-1` sentinel — emitted as the bare number with a comment saying what it is, never with an adoption hint. It stays a **number** rather than a name reference on purpose: an admin can rename the row, and the id is @@ -646,10 +648,10 @@ adding or reordering a level on one host silently changes what a hard-coded Two things address that, and you can use either: -**1. Reference a level by name.** `{ securityLevel: "stufe_3_hoch" }` resolves -against managed levels first, then `GET /securitylevels`. The trade-off: names -are localised German strings (`"Stufe 3 (Hoch)"` → `stufe_3_hoch`), so a -**rename** breaks a reference where a number would have survived. +**1. Reference a level by logical key.** `{ securityLevel: "stufe_3_hoch" }` +resolves against managed state or an explicit external binding. The external +binding validates the live name, so a rename intentionally blocks until +`ct use security-level --key stufe_3_hoch` accepts the changed identity. **2. Declare the levels themselves**, which makes the numeric form portable too, because the config now owns the ids: diff --git a/src/application/contracts.ts b/src/application/contracts.ts index 38904ca..d2fbc8b 100644 --- a/src/application/contracts.ts +++ b/src/application/contracts.ts @@ -3,7 +3,7 @@ export type JsonPrimitive = string | number | boolean | null; export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; export type OperationName = - "plan" | "apply" | "coverage" | "adopt" | "state" | "refresh" | "destroy" | "auth"; + "plan" | "apply" | "coverage" | "adopt" | "use" | "ownership" | "state" | "refresh" | "destroy" | "auth"; /** Common project selection accepted by CLI and, later, HTTP adapters. */ export interface ProjectRequest { diff --git a/src/application/errors.ts b/src/application/errors.ts index 880b68c..986b3fc 100644 --- a/src/application/errors.ts +++ b/src/application/errors.ts @@ -11,6 +11,8 @@ export const APPLICATION_ERROR_CODES = [ "OPERATION_EXPIRED", "OPERATION_ALREADY_USED", "MUTATION_BUSY", + "EXTERNAL_REFERENCE_BLOCKED", + "EXTERNAL_CONFIRMATION_REQUIRED", ] as const; export type ApplicationErrorCode = (typeof APPLICATION_ERROR_CODES)[number]; diff --git a/src/application/operations/index.ts b/src/application/operations/index.ts index 3a235dc..16a7080 100644 --- a/src/application/operations/index.ts +++ b/src/application/operations/index.ts @@ -8,3 +8,5 @@ export * from "./auth.js"; export * from "./adopt-group.js"; export * from "./adopt-grants.js"; export * from "./destroy.js"; +export * from "./use.js"; +export * from "./ownership.js"; diff --git a/src/application/operations/ownership.ts b/src/application/operations/ownership.ts new file mode 100644 index 0000000..2fc30fd --- /dev/null +++ b/src/application/operations/ownership.ts @@ -0,0 +1,351 @@ +import { readdir } from "node:fs/promises"; +import { basename, dirname, join, relative, resolve } from "node:path"; +import { loadEnvProfile } from "../../env/envs.js"; +import { identityDifferences } from "../../resolve/external.js"; +import { resourceType } from "../../resources/registry.js"; +import { + externalResources, + loadState, + type ExternalResource, + type ManagedResource, +} from "../../state/state.js"; +import type { OperationResult } from "../contracts.js"; + +const IGNORED_DIRECTORIES = new Set([ + ".git", + "node_modules", + "dist", + "build", + "coverage", + ".next", + ".turbo", + "out", +]); + +export interface OwnershipCheckRequest { + root: string; + environment: string; + cwd?: string; +} + +export interface OwnershipProject { + name: string; + path: string; + relativePath: string; + host: string; + statePath: string; + managed: ManagedResource[]; + externals: ExternalResource[]; +} + +export type OwnershipReason = + | "DUPLICATE_OWNER" + | "OWNER_HINT_MISMATCH" + | "OWNER_OUTSIDE_SCOPE" + | "OWNER_NOT_VISIBLE" + | "KEY_MISMATCH" + | "CONFLICTING_BINDING" + | "INCOMPATIBLE_IDENTITY" + | "PROJECT_STATE_INVALID"; + +export interface OwnershipFinding { + severity: "ok" | "error"; + reason: OwnershipReason | "OWNERSHIP_OK"; + host: string; + type?: string; + id?: number; + key?: string; + projects: string[]; + message: string; + remediation?: string[]; +} + +export type OwnershipCheckResult = OperationResult<{ + root: string; + environment: string; + projects: OwnershipProject[]; + hosts: string[]; + findings: OwnershipFinding[]; + conflicts: number; + completeScope: true; +}>; + +export interface OwnershipDependencies { + discover?: (root: string) => Promise; + loadEnvProfile?: typeof loadEnvProfile; + loadState?: typeof loadState; +} + +async function discoverEnvironmentFiles(root: string): Promise { + const found: string[] = []; + const walk = async (directory: string): Promise => { + const entries = await readdir(directory, { withFileTypes: true }); + await Promise.all( + entries.map(async (entry) => { + if (entry.isSymbolicLink()) return; + const path = join(directory, entry.name); + if (entry.isDirectory()) { + if (!IGNORED_DIRECTORIES.has(entry.name)) await walk(path); + } else if (entry.isFile() && entry.name === "ct.envs.json") { + found.push(path); + } + }), + ); + }; + await walk(root); + return found.sort(); +} + +function pair(type: string, id: number): string { + return `${type}\0${id}`; +} + +function claim( + project: OwnershipProject, + entry: ManagedResource | ExternalResource, + kind: "managed" | "external", +) { + return { project, entry, kind } as const; +} + +/** Analyse only the explicitly supplied directory tree; no network and no search outside it. */ +export async function checkOwnership( + request: OwnershipCheckRequest, + dependencies: OwnershipDependencies = {}, +): Promise { + if (!request.environment.trim()) throw new Error("ownership check requires --env ."); + const cwd = resolve(request.cwd ?? process.cwd()); + const root = resolve(cwd, request.root); + const envFiles = await (dependencies.discover ?? discoverEnvironmentFiles)(root); + const projects: OwnershipProject[] = []; + const findings: OwnershipFinding[] = []; + + for (const envPath of envFiles) { + const path = dirname(envPath); + let profile; + try { + profile = await (dependencies.loadEnvProfile ?? loadEnvProfile)(request.environment, envPath); + } catch (error) { + if (error instanceof Error && error.message.startsWith("Unknown environment")) continue; + findings.push({ + severity: "error", + reason: "PROJECT_STATE_INVALID", + host: "unknown", + projects: [relative(root, path) || "."], + message: error instanceof Error ? error.message : String(error), + }); + continue; + } + const statePath = resolve(path, profile.statePath); + try { + const state = await (dependencies.loadState ?? loadState)(statePath, profile.host); + projects.push({ + name: basename(path), + path, + relativePath: relative(root, path) || ".", + host: profile.host, + statePath, + managed: Object.values(state.resources), + externals: Object.values(externalResources(state)), + }); + } catch (error) { + findings.push({ + severity: "error", + reason: "PROJECT_STATE_INVALID", + host: profile.host, + projects: [relative(root, path) || "."], + message: `Cannot inspect ${statePath}: ${error instanceof Error ? error.message : String(error)}`, + }); + } + } + + for (const host of [...new Set(projects.map((project) => project.host))].sort()) { + const scoped = projects.filter((project) => project.host === host); + const claims = scoped.flatMap((project) => [ + ...project.managed.map((entry) => claim(project, entry, "managed")), + ...project.externals.map((entry) => claim(project, entry, "external")), + ]); + const byPair = new Map(); + const byKey = new Map(); + for (const item of claims) { + const p = pair(item.entry.type, item.entry.id); + byPair.set(p, [...(byPair.get(p) ?? []), item]); + byKey.set(item.entry.key, [...(byKey.get(item.entry.key) ?? []), item]); + } + + for (const items of byPair.values()) { + const first = items[0]!; + const errorsBefore = findings.filter((finding) => finding.severity === "error").length; + const owners = items.filter((item) => item.kind === "managed"); + const consumers = items.filter((item) => item.kind === "external"); + const keys = [...new Set(items.map((item) => item.entry.key))]; + if (owners.length > 1) { + findings.push({ + severity: "error", + reason: "DUPLICATE_OWNER", + host, + type: first.entry.type, + id: first.entry.id, + projects: owners.map((item) => item.project.relativePath), + message: `${first.entry.type} #${first.entry.id} is managed by ${owners.length} visible ct projects.`, + remediation: owners + .slice(1) + .map( + (item) => + `cd ${item.project.path} && ct state rm ${item.entry.type} ${item.entry.key} --env ${request.environment}`, + ), + }); + } + if (keys.length > 1) { + const canonical = owners[0]?.entry.key ?? keys[0]!; + findings.push({ + severity: "error", + reason: "KEY_MISMATCH", + host, + type: first.entry.type, + id: first.entry.id, + projects: items.map((item) => item.project.relativePath), + message: `${first.entry.type} #${first.entry.id} uses different logical keys: ${keys.join(", ")}.`, + remediation: items + .filter((item) => item.entry.key !== canonical) + .map( + (item) => + `cd ${item.project.path} && ct state rekey ${item.entry.type} ${item.entry.key} ${canonical} --env ${request.environment}`, + ), + }); + } + for (const consumer of consumers) { + const consumerEntry = consumer.entry as ExternalResource; + const hinted = consumerEntry.owner; + if (hinted) { + const visible = scoped.find( + (project) => + project.name === hinted || project.relativePath === hinted || project.path === hinted, + ); + if (!visible) { + findings.push({ + severity: "error", + reason: "OWNER_OUTSIDE_SCOPE", + host, + type: consumer.entry.type, + id: consumer.entry.id, + key: consumer.entry.key, + projects: [consumer.project.relativePath], + message: `Owner hint ${JSON.stringify(hinted)} is not visible below ${root}.`, + remediation: [`ct ownership check --env ${request.environment}`], + }); + } else if ( + !visible.managed.some( + (entry) => entry.type === consumer.entry.type && entry.id === consumer.entry.id, + ) + ) { + findings.push({ + severity: "error", + reason: "OWNER_HINT_MISMATCH", + host, + type: consumer.entry.type, + id: consumer.entry.id, + key: consumer.entry.key, + projects: [consumer.project.relativePath, visible.relativePath], + message: `Owner hint ${JSON.stringify(hinted)} is visible but does not manage this binding.`, + remediation: [`Correct --owner metadata with ct use, or repair the hinted owner's state.`], + }); + } + } + if ( + owners.length === 0 && + !findings.some( + (finding) => finding.reason === "OWNER_OUTSIDE_SCOPE" && finding.key === consumer.entry.key, + ) + ) { + findings.push({ + severity: "error", + reason: "OWNER_NOT_VISIBLE", + host, + type: consumer.entry.type, + id: consumer.entry.id, + key: consumer.entry.key, + projects: [consumer.project.relativePath], + message: `No visible ct project manages ${consumer.entry.type} #${consumer.entry.id}.`, + remediation: [`Broaden the explicit root or establish exactly one managed owner.`], + }); + } + const owner = owners[0]; + if (owner) { + const ownerEntry = owner.entry as ManagedResource; + const ownerIdentity = resourceType(ownerEntry.type).external.identity(ownerEntry.fields); + const diff = identityDifferences(consumerEntry.identity, ownerIdentity); + if (diff.length > 0) { + findings.push({ + severity: "error", + reason: "INCOMPATIBLE_IDENTITY", + host, + type: consumer.entry.type, + id: consumer.entry.id, + key: consumer.entry.key, + projects: [owner.project.relativePath, consumer.project.relativePath], + message: `Owner managed snapshot and consumer hard identity disagree (${diff.map((item) => item.field).join(", ")}).`, + remediation: [ + `cd ${consumer.project.path} && ct use ${consumer.entry.type} ${consumer.entry.id} --key ${consumer.entry.key} --env ${request.environment}`, + ], + }); + } + } + } + const errorsAfter = findings.filter((finding) => finding.severity === "error").length; + if (owners.length === 1 && consumers.length > 0 && keys.length === 1 && errorsAfter === errorsBefore) { + findings.push({ + severity: "ok", + reason: "OWNERSHIP_OK", + host, + type: first.entry.type, + id: first.entry.id, + key: first.entry.key, + projects: items.map((item) => item.project.relativePath), + message: `${owners[0]!.project.relativePath} owns; ${consumers.length} consumer(s) bind read-only.`, + }); + } + } + + for (const [key, items] of byKey) { + const bindings = [...new Set(items.map((item) => pair(item.entry.type, item.entry.id)))]; + if (bindings.length > 1) { + findings.push({ + severity: "error", + reason: "CONFLICTING_BINDING", + host, + key, + projects: items.map((item) => item.project.relativePath), + message: `Logical key ${JSON.stringify(key)} maps to incompatible type/id bindings on this host.`, + remediation: ["Rekey the conflicting project state so one portable key has one meaning."], + }); + } + } + } + + const conflicts = findings.filter((finding) => finding.severity === "error").length; + return { + operation: "ownership", + project: { + cwd, + configPath: "", + statePath: "", + environmentsPath: "", + configDisplayPath: "", + stateDisplayPath: "", + environment: request.environment, + protected: false, + host: "multiple", + }, + warnings: [], + value: { + root, + environment: request.environment, + projects, + hosts: [...new Set(projects.map((project) => project.host))].sort(), + findings, + conflicts, + completeScope: true, + }, + }; +} diff --git a/src/application/operations/plan.ts b/src/application/operations/plan.ts index 86b6fb7..3281b60 100644 --- a/src/application/operations/plan.ts +++ b/src/application/operations/plan.ts @@ -6,11 +6,14 @@ import { summarize, type Plan, type PlanAction } from "../../engine/types.js"; import { CATALOG_DIR, loadHostCatalog } from "../../permissions/catalog-store.js"; import { buildPermissionPlan, type PermissionPlanItem } from "../../permissions/plan.js"; import { Resolver } from "../../resolve/resolver.js"; +import { ExternalReferenceError } from "../../resolve/external.js"; import { loadState, type State } from "../../state/state.js"; import type { CtClient } from "../../api/ctClient.js"; import type { CtWarning, OperationResult, ProjectRequest } from "../contracts.js"; import { noopObserver, type OperationObserver } from "../ports.js"; import { resolveProject, type ProjectResolutionDependencies } from "../project.js"; +import { CtApplicationError } from "../errors.js"; +import type { JsonValue } from "../contracts.js"; export type PlanRequest = ProjectRequest; @@ -120,20 +123,39 @@ export async function buildPlanContext( state, desired, host: project.host, + context: { + consumer: project.cwd.split(/[\\/]/).filter(Boolean).at(-1), + cwd: project.cwd, + configPath: project.configPath, + statePath: project.statePath, + environment: project.environment, + }, }); observer.emit({ type: "phase-started", phase: "build-plan" }); - const [resourceResult, permissionResult] = await Promise.all([ - (dependencies.buildPlan ?? buildPlan)(client, state, desired, { configDir, resolver }), - (dependencies.buildPermissionPlan ?? buildPermissionPlan)( - client, - state, - permissions, - desired, - resolver, - client.version ?? undefined, - ), - ]); + let resourceResult: Awaited>; + let permissionResult: Awaited>; + try { + [resourceResult, permissionResult] = await Promise.all([ + (dependencies.buildPlan ?? buildPlan)(client, state, desired, { configDir, resolver }), + (dependencies.buildPermissionPlan ?? buildPermissionPlan)( + client, + state, + permissions, + desired, + resolver, + client.version ?? undefined, + ), + ]); + } catch (error) { + if (error instanceof ExternalReferenceError) { + throw new CtApplicationError("EXTERNAL_REFERENCE_BLOCKED", error.message, { + details: error.details as unknown as Record, + cause: error, + }); + } + throw error; + } const fetchErrors = [...resourceResult.fetchErrors, ...permissionResult.fetchErrors]; const warnings: CtWarning[] = permissionResult.warnings.map((message) => ({ code: "PERMISSION_CATALOG", diff --git a/src/application/operations/state.ts b/src/application/operations/state.ts index 4ec60a5..06d8aad 100644 --- a/src/application/operations/state.ts +++ b/src/application/operations/state.ts @@ -1,7 +1,13 @@ import { loadConfig } from "../../config/load.js"; import { resourceType } from "../../resources/registry.js"; import { collectRefs, isRef, type Ref } from "../../resolve/refs.js"; -import { loadState, saveState, type ManagedResource } from "../../state/state.js"; +import { + externalResources, + loadState, + saveState, + type ExternalResource, + type ManagedResource, +} from "../../state/state.js"; import type { CtWarning, OperationResult, ProjectRequest } from "../contracts.js"; import { InMemoryMutationLock } from "../prepared-operation-store.js"; import type { MutationLock } from "../ports.js"; @@ -16,7 +22,16 @@ export interface StateOperationDependencies { lock?: MutationLock; } -export type StateListResult = OperationResult<{ resources: ManagedResource[] }>; +export type StateEntry = + | { kind: "managed"; ownership: "owned"; entry: ManagedResource } + | { kind: "external"; ownership: "read-only"; entry: ExternalResource }; + +export interface StateListRequest extends ProjectRequest { + managed?: boolean; + external?: boolean; +} + +export type StateListResult = OperationResult<{ entries: StateEntry[]; resources: ManagedResource[] }>; export interface StateRemoveRequest extends ProjectRequest { type: string; @@ -26,7 +41,8 @@ export interface StateRemoveRequest extends ProjectRequest { } export type StateRemoveResult = OperationResult<{ - entry: ManagedResource; + kind: "managed" | "external"; + entry: ManagedResource | ExternalResource; removed: boolean; churchToolsContacted: false; }>; @@ -34,16 +50,29 @@ export type StateRemoveResult = OperationResult<{ const defaultLock = new InMemoryMutationLock(); export async function listState( - request: ProjectRequest = {}, + request: StateListRequest = {}, dependencies: StateOperationDependencies = {}, ): Promise { const project = await (dependencies.resolveProject ?? resolveProject)(request, dependencies.project); const state = await (dependencies.loadState ?? loadState)(project.statePath, project.host); + const includeManaged = request.managed || !request.external; + const includeExternal = request.external || !request.managed; + const resources = includeManaged ? Object.values(state.resources) : []; + const entries: StateEntry[] = [ + ...resources.map((entry): StateEntry => ({ kind: "managed", ownership: "owned", entry })), + ...(includeExternal + ? Object.values(externalResources(state)).map((entry): StateEntry => ({ + kind: "external", + ownership: "read-only", + entry, + })) + : []), + ]; return { operation: "state", project, warnings: [], - value: { resources: Object.values(state.resources) }, + value: { entries, resources }, }; } @@ -86,7 +115,9 @@ export async function removeStateEntry( const lock = dependencies.lock ?? defaultLock; return lock.runExclusive(project.statePath, async () => { const state = await (dependencies.loadState ?? loadState)(project.statePath, project.host); - const entry = state.resources[request.key]; + const managed = state.resources[request.key]; + const external = externalResources(state)[request.key]; + const entry = managed ?? external; if (!entry) { throw new Error( `No entry "${request.key}" in ${project.stateDisplayPath}. List them with \`ct state list\`.`, @@ -99,8 +130,9 @@ export async function removeStateEntry( ); } + const kind = managed ? "managed" : "external"; const warnings: CtWarning[] = []; - if (!request.force) { + if (kind === "managed" && !request.force) { try { const declared = await declaredKeys(project.configPath, dependencies); if (declared.has(request.key)) { @@ -123,14 +155,91 @@ export async function removeStateEntry( } if (!request.dryRun) { - delete state.resources[request.key]; + if (kind === "managed") delete state.resources[request.key]; + else delete externalResources(state)[request.key]; await (dependencies.saveState ?? saveState)(project.statePath, state); } return { operation: "state", project, warnings, - value: { entry, removed: !request.dryRun, churchToolsContacted: false }, + value: { kind, entry, removed: !request.dryRun, churchToolsContacted: false }, + }; + }); +} + +export interface StateRekeyRequest extends ProjectRequest { + type: string; + oldKey: string; + newKey: string; + dryRun?: boolean; +} + +export type StateRekeyResult = OperationResult<{ + kind: "managed" | "external"; + entry: ManagedResource | ExternalResource; + oldKey: string; + newKey: string; + changed: boolean; + churchToolsContacted: false; +}>; + +export async function rekeyStateEntry( + request: StateRekeyRequest, + dependencies: StateOperationDependencies = {}, +): Promise { + resourceType(request.type); + const oldKey = request.oldKey.trim(); + const newKey = request.newKey.trim(); + if (!oldKey || !newKey) throw new Error("Old and new logical keys must be non-empty."); + const project = await (dependencies.resolveProject ?? resolveProject)(request, dependencies.project); + const lock = dependencies.lock ?? defaultLock; + return lock.runExclusive(project.statePath, async () => { + const state = await (dependencies.loadState ?? loadState)(project.statePath, project.host); + const externals = externalResources(state); + const managed = state.resources[oldKey]; + const external = externals[oldKey]; + const entry = managed ?? external; + if (!entry) throw new Error(`No entry "${oldKey}" in ${project.stateDisplayPath}.`); + if (entry.type !== request.type) { + throw new Error(`"${oldKey}" is a ${entry.type}, not a ${request.type}.`); + } + const collision = state.resources[newKey] ?? externals[newKey]; + if (collision && newKey !== oldKey) { + throw new Error( + `Logical key "${newKey}" is already used by ${collision.type} #${collision.id}; keys are unique across managed and external entries.`, + ); + } + const kind = managed ? "managed" : "external"; + const changed = oldKey !== newKey; + const updated = changed ? { ...entry, key: newKey } : entry; + if (changed && !request.dryRun) { + if (kind === "managed") { + delete state.resources[oldKey]; + state.resources[newKey] = updated as ManagedResource; + } else { + delete externals[oldKey]; + externals[newKey] = updated as ExternalResource; + } + await (dependencies.saveState ?? saveState)(project.statePath, state); + } + return { + operation: "state", + project, + warnings: [ + { + code: "STATE_REKEY_REFS", + message: `Update every ref.* use from "${oldKey}" to "${newKey}" consistently.`, + }, + ], + value: { + kind, + entry: updated, + oldKey, + newKey, + changed: changed && !request.dryRun, + churchToolsContacted: false, + }, }; }); } diff --git a/src/application/operations/use.ts b/src/application/operations/use.ts new file mode 100644 index 0000000..c229469 --- /dev/null +++ b/src/application/operations/use.ts @@ -0,0 +1,289 @@ +import { authedSession, type AuthedSession } from "../../api/session.js"; +import { resourceType, slug, type CtWriteClient } from "../../resources/registry.js"; +import { + externalResources, + findByTypeId, + findExternalByTypeId, + loadState, + saveState, + type ExternalResource, +} from "../../state/state.js"; +import { identityDifferences, type ExternalCandidate } from "../../resolve/external.js"; +import type { OperationResult, ProjectRequest } from "../contracts.js"; +import { CtApplicationError } from "../errors.js"; +import { InMemoryMutationLock } from "../prepared-operation-store.js"; +import { systemClock, type Clock, type MutationLock } from "../ports.js"; +import { resolveProject, type ProjectResolutionDependencies } from "../project.js"; + +export interface UseOperationDependencies { + project?: ProjectResolutionDependencies; + resolveProject?: typeof resolveProject; + loadState?: typeof loadState; + saveState?: typeof saveState; + authedSession?: () => Promise; + clock?: Clock; + lock?: MutationLock; +} + +export interface DiscoverExternalRequest extends ProjectRequest { + type: string; + search: string; +} + +export type DiscoverExternalResult = OperationResult<{ + type: string; + search: string; + candidates: ExternalCandidate[]; +}>; + +export type InspectExternalResult = OperationResult<{ + type: string; + candidate: ExternalCandidate; + /** Existing consumer key wins over a newly derived slug. */ + suggestedKey: string; +}>; + +export interface UseResourceRequest extends ProjectRequest { + type: string; + id: string | number; + key: string; + owner?: string; + acceptChanges?: boolean; + dryRun?: boolean; +} + +export type UseAction = "created" | "no-op" | "identity-updated" | "rebound" | "metadata-updated"; + +export type UseResourceResult = OperationResult<{ + action: UseAction; + binding: ExternalResource; + live: ExternalCandidate; + previous?: ExternalResource; + previousLive?: ExternalCandidate; + identityDiff: ReturnType; + written: boolean; + churchToolsWritten: false; +}>; + +const defaultLock = new InMemoryMutationLock(); + +function parseId(raw: string | number): number { + const value = String(raw).trim(); + if (!/^\d+$/.test(value)) throw new Error(`Invalid id "${raw}" — expected a non-negative integer.`); + const id = Number.parseInt(value, 10); + if (!Number.isSafeInteger(id)) throw new Error(`Invalid id "${raw}" — expected a safe integer.`); + return id; +} + +function requireKey(value: string): string { + const key = value.trim(); + if (!key) throw new Error("External logical key must be non-empty. Pass --key ."); + if (/\s/.test(key)) throw new Error(`Logical key ${JSON.stringify(key)} must not contain whitespace.`); + return key; +} + +async function readOne( + client: AuthedSession["client"], + type: string, + id: number, +): Promise> { + const spec = resourceType(type); + const row = spec.fetchOne + ? await spec.fetchOne(client as CtWriteClient, id) + : await client.get>(spec.itemPath(id)); + if (!row) throw new Error(`No ${type} with id ${id} exists in ChurchTools.`); + return row; +} + +function candidate(type: string, row: Record): ExternalCandidate { + const spec = resourceType(type); + const id = row.id; + if (typeof id !== "number") throw new Error(`Live ${type} candidate carries no numeric id.`); + return { + id, + name: typeof row.name === "string" ? row.name : `#${id}`, + identity: spec.external.identity(row), + display: spec.external.display(row), + }; +} + +/** Read-only fuzzy discovery for the terminal adapter. Never persists or resolves a reference. */ +export async function discoverExternalCandidates( + request: DiscoverExternalRequest, + dependencies: UseOperationDependencies = {}, +): Promise { + const spec = resourceType(request.type); + const search = request.search.trim(); + if (!search) throw new Error("Interactive search must be non-empty."); + const project = await (dependencies.resolveProject ?? resolveProject)(request, dependencies.project); + const { client } = await (dependencies.authedSession ?? authedSession)(); + const page = await client.getAll>(spec.collectionPath); + const needle = search.toLocaleLowerCase(); + const needleSlug = slug(search); + const matches = page.data.filter((row) => { + const name = typeof row.name === "string" ? row.name : ""; + return name.toLocaleLowerCase().includes(needle) || slug(name).includes(needleSlug); + }); + return { + operation: "use", + project, + warnings: [], + value: { type: request.type, search, candidates: matches.map((row) => candidate(request.type, row)) }, + }; +} + +/** Inspect one exact live id for interactive key proposal and replacement presentation. */ +export async function inspectExternalCandidate( + request: ProjectRequest & { type: string; id: string | number }, + dependencies: UseOperationDependencies = {}, +): Promise { + resourceType(request.type); + const id = parseId(request.id); + const project = await (dependencies.resolveProject ?? resolveProject)(request, dependencies.project); + const state = await (dependencies.loadState ?? loadState)(project.statePath, project.host); + const { client } = await (dependencies.authedSession ?? authedSession)(); + const row = await readOne(client, request.type, id); + const live = candidate(request.type, { ...row, id }); + const existing = findExternalByTypeId(state, request.type, id); + return { + operation: "use", + project, + warnings: [], + value: { + type: request.type, + candidate: live, + suggestedKey: existing?.key ?? resourceType(request.type).deriveKey({ ...row, id }), + }, + }; +} + +/** Validate and persist one explicit read-only binding. Never writes to ChurchTools. */ +export async function runUseResource( + request: UseResourceRequest, + dependencies: UseOperationDependencies = {}, +): Promise { + resourceType(request.type); + const id = parseId(request.id); + const key = requireKey(request.key); + const project = await (dependencies.resolveProject ?? resolveProject)(request, dependencies.project); + const lock = dependencies.lock ?? defaultLock; + return lock.runExclusive(project.statePath, async () => { + const state = await (dependencies.loadState ?? loadState)(project.statePath, project.host); + const managedKey = state.resources[key]; + if (managedKey) { + throw new Error( + `Logical key "${key}" is already managed as ${managedKey.type} #${managedKey.id}; it cannot also be external.`, + ); + } + const managedId = findByTypeId(state, request.type, id); + if (managedId) { + throw new Error( + `${request.type} #${id} is already managed by this ct project as "${managedId.key}"; use is read-only and cannot duplicate ownership.`, + ); + } + const alias = findExternalByTypeId(state, request.type, id); + if (alias && alias.key !== key) { + throw new Error( + `${request.type} #${id} is already external as "${alias.key}". Rekey that binding instead of creating a second alias.`, + ); + } + const { client } = await (dependencies.authedSession ?? authedSession)(); + const row = await readOne(client, request.type, id); + const live = candidate(request.type, { ...row, id }); + const entries = externalResources(state); + const existing = entries[key]; + if (existing && existing.type !== request.type) { + throw new Error( + `Logical key "${key}" is already external as ${existing.type} #${existing.id}, not ${request.type}.`, + ); + } + + const now = (dependencies.clock ?? systemClock).now().toISOString(); + let action: UseAction; + let binding: ExternalResource; + let diff: ReturnType = []; + let previousLive: ExternalCandidate | undefined; + if (!existing) { + action = "created"; + binding = { + type: request.type, + key, + id, + ...(request.owner?.trim() ? { owner: request.owner.trim() } : {}), + identity: live.identity, + boundAt: now, + }; + } else if (existing.id !== id) { + action = "rebound"; + diff = identityDifferences(existing.identity, live.identity); + try { + const oldRow = await readOne(client, request.type, existing.id); + previousLive = candidate(request.type, { ...oldRow, id: existing.id }); + } catch { + // A stale old target is still useful evidence: the persisted snapshot remains in `previous`. + } + binding = { + ...existing, + id, + identity: live.identity, + boundAt: now, + ...(request.owner?.trim() ? { owner: request.owner.trim() } : {}), + }; + } else { + diff = identityDifferences(existing.identity, live.identity); + const owner = request.owner?.trim() || existing.owner; + if (diff.length > 0) action = "identity-updated"; + else if (owner !== existing.owner) action = "metadata-updated"; + else action = "no-op"; + binding = { + ...existing, + ...(owner ? { owner } : {}), + identity: diff.length > 0 ? live.identity : existing.identity, + }; + } + + if ((action === "identity-updated" || action === "rebound") && !request.acceptChanges) { + throw new CtApplicationError( + "EXTERNAL_CONFIRMATION_REQUIRED", + action === "rebound" + ? `${request.type}.${key} is bound to #${existing!.id} (${JSON.stringify(previousLive ?? existing!.identity)}), not #${id} (${JSON.stringify(live)}). Explicit confirmation is required to replace it.` + : `${request.type}.${key} #${id} changed hard identity: ${diff.map((item) => `${item.field}: ${JSON.stringify(item.expected)} -> ${JSON.stringify(item.actual)}`).join(", ")}. Explicit confirmation is required to accept the field diff.`, + { + details: { + action, + type: request.type, + key, + oldId: existing!.id, + newId: id, + identityDiff: diff as never, + previous: existing as never, + live: live as never, + proposed: binding as never, + previousLive: (previousLive ?? null) as never, + }, + }, + ); + } + + const written = action !== "no-op" && !request.dryRun; + if (written) { + entries[key] = binding; + await (dependencies.saveState ?? saveState)(project.statePath, state); + } + return { + operation: "use", + project, + warnings: [], + value: { + action, + binding, + live, + ...(existing ? { previous: existing } : {}), + ...(previousLive ? { previousLive } : {}), + identityDiff: diff, + written, + churchToolsWritten: false, + }, + }; + }); +} diff --git a/src/commands/ownership.ts b/src/commands/ownership.ts new file mode 100644 index 0000000..f688425 --- /dev/null +++ b/src/commands/ownership.ts @@ -0,0 +1,27 @@ +import { Command } from "commander"; +import { checkOwnership } from "../application/operations/ownership.js"; +import { info, success, warn } from "../ui.js"; + +export function ownershipCommand(): Command { + const command = new Command("ownership").description("Analyse ownership within an explicit directory tree"); + command + .command("check") + .description("Check managed/external ownership claims without contacting ChurchTools") + .argument("", "complete coordination-scope directory for this invocation") + .requiredOption("-e, --env ", "environment name to inspect in every discovered ct project") + .action(async (root: string, opts: { env: string }) => { + const result = await checkOwnership({ root, environment: opts.env }); + info( + `Inspected ${result.value.projects.length} ct project(s) across ${result.value.hosts.length} host(s) below ${result.value.root}.`, + ); + for (const finding of result.value.findings) { + const line = `[${finding.reason}] ${finding.message}`; + if (finding.severity === "ok") success(line); + else warn(line); + finding.remediation?.forEach((step, index) => info(` ${index + 1}. ${step}`)); + } + info("Scope guarantee applies only below the explicit root; projects outside it remain unknowable."); + if (result.value.conflicts > 0) process.exitCode = 1; + }); + return command; +} diff --git a/src/commands/state.ts b/src/commands/state.ts index 09634a1..5360a98 100644 --- a/src/commands/state.ts +++ b/src/commands/state.ts @@ -1,10 +1,12 @@ import { Command } from "commander"; -import { listState, removeStateEntry } from "../application/operations/state.js"; +import { listState, rekeyStateEntry, removeStateEntry } from "../application/operations/state.js"; import { info, out, success, warn } from "../ui.js"; interface StateOptions { state?: string; env?: string; + managed?: boolean; + external?: boolean; } interface StateRmOptions extends StateOptions { @@ -14,24 +16,31 @@ interface StateRmOptions extends StateOptions { } export function stateCommand(): Command { - const cmd = new Command("state").description("Inspect the managed-resource state file"); + const cmd = new Command("state").description("Inspect managed and external ct-cli resource state"); cmd .command("list") - .description("List every resource under management (JSON to stdout)") + .description("List managed and external entries together (JSON to stdout)") .option("-s, --state ", "state file path (or set CT_STATE)") .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") + .option("--managed", "show managed entries only") + .option("--external", "show external entries only") .action(async (opts: StateOptions) => { - const result = await listState({ statePath: opts.state, environment: opts.env }); + const result = await listState({ + statePath: opts.state, + environment: opts.env, + managed: opts.managed, + external: opts.external, + }); info( - `${result.value.resources.length} managed resource(s) in ${result.project.stateDisplayPath} (host ${result.project.host}).`, + `${result.value.entries.length} state entr${result.value.entries.length === 1 ? "y" : "ies"} in ${result.project.stateDisplayPath} (host ${result.project.host}).`, ); - out(result.value.resources); + out(result.value.entries.map(({ kind, ownership, entry }) => ({ kind, ownership, ...entry }))); }); cmd .command("rm") - .description("Un-adopt: remove a resource from the state file. Never touches ChurchTools.") + .description("Remove a managed or external entry from state. Never touches ChurchTools.") .argument("", "resource type, e.g. campus | group | group-role") .argument("", "logical key of the entry to remove") .option("-s, --state ", "state file path (or set CT_STATE)") @@ -52,15 +61,52 @@ export function stateCommand(): Command { for (const warning of result.warnings) warn(warning.message); const entry = result.value.entry; if (!result.value.removed) { - info(`Would remove ${entry.type}.${key} (#${entry.id}) from ${result.project.stateDisplayPath}.`); + info( + `Would remove ${result.value.kind} ${entry.type}.${key} (#${entry.id}) from ${result.project.stateDisplayPath}.`, + ); return; } - success(`Removed ${entry.type}.${key} (#${entry.id}) from ${result.project.stateDisplayPath}.`); + success( + `Removed ${result.value.kind} ${entry.type}.${key} (#${entry.id}) from ${result.project.stateDisplayPath}.`, + ); info( - `ChurchTools was not contacted — #${entry.id} still exists there, now unmanaged. ` + - `Re-adopt it with \`ct adopt ${type} ${entry.id}\`.`, + `ChurchTools was not contacted — #${entry.id} still exists there. ` + + (result.value.kind === "managed" + ? `Re-adopt it with \`ct adopt ${type} ${entry.id}\`.` + : `Re-bind it with \`ct use ${type} ${entry.id} --key ${key}\`.`), ); }); + cmd + .command("rekey") + .description("Change the logical key of a managed or external state entry") + .argument("", "resource type") + .argument("", "current logical key") + .argument("", "new globally unique logical key") + .option("-s, --state ", "state file path (or set CT_STATE)") + .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") + .option("--dry-run", "report the rekey without writing") + .action( + async (type: string, oldKey: string, newKey: string, opts: StateOptions & { dryRun?: boolean }) => { + const result = await rekeyStateEntry({ + type, + oldKey, + newKey, + statePath: opts.state, + environment: opts.env, + dryRun: opts.dryRun, + }); + for (const warning of result.warnings) warn(warning.message); + if (opts.dryRun) { + info(`Would rekey ${result.value.kind} ${type}.${oldKey} to ${type}.${newKey}.`); + } else if (!result.value.changed) { + info(`${result.value.kind} ${type}.${oldKey} already has that key; state is unchanged.`); + } else { + success(`Rekeyed ${result.value.kind} ${type}.${oldKey} to ${type}.${newKey}.`); + } + info("ChurchTools was not contacted."); + }, + ); + return cmd; } diff --git a/src/commands/use.ts b/src/commands/use.ts new file mode 100644 index 0000000..3ba68db --- /dev/null +++ b/src/commands/use.ts @@ -0,0 +1,146 @@ +import { Command } from "commander"; +import { + discoverExternalCandidates, + inspectExternalCandidate, + runUseResource, + type UseResourceResult, +} from "../application/operations/use.js"; +import { CtApplicationError } from "../application/errors.js"; +import { askVisible, confirm } from "../ui/prompt.js"; +import { info, success, warn } from "../ui.js"; + +interface UseOptions { + key?: string; + owner?: string; + state?: string; + env?: string; + yes?: boolean; + dryRun?: boolean; +} + +function candidateLine(candidate: { + id: number; + name: string; + identity: Record; + display: Record; +}): string { + const details = Object.entries({ ...candidate.identity, ...candidate.display }) + .map(([key, value]) => `${key}=${JSON.stringify(value)}`) + .join(", "); + return `#${candidate.id} ${JSON.stringify(candidate.name)}${details ? ` · ${details}` : ""}`; +} + +async function bind( + type: string, + id: number, + key: string, + opts: UseOptions, +): Promise { + const request = { + type, + id, + key, + owner: opts.owner, + statePath: opts.state, + environment: opts.env, + dryRun: opts.dryRun, + }; + try { + return await runUseResource(request); + } catch (error) { + if (!(error instanceof CtApplicationError) || error.code !== "EXTERNAL_CONFIRMATION_REQUIRED") + throw error; + warn(error.message); + const accepted = await confirm("Accept this external binding change?", { assumeYes: opts.yes }); + if (!accepted) { + info("Aborted — external state was not changed."); + return null; + } + return runUseResource({ ...request, acceptChanges: true }); + } +} + +export function useCommand(): Command { + return new Command("use") + .description("Bind an existing ChurchTools object as an external read-only prerequisite") + .argument("", "ct-cli resource type, e.g. campus | group | group-type") + .argument("", "exact ChurchTools id, or an interactive fuzzy name search") + .option("-k, --key ", "portable logical key (required for non-interactive use)") + .option("--owner ", "optional owner-project coordination hint") + .option("-s, --state ", "state file path (or set CT_STATE)") + .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") + .option("-y, --yes", "accept an identity change or replacement without prompting") + .option("--dry-run", "validate and report the binding without writing state") + .action(async (type: string, selector: string, opts: UseOptions) => { + const numeric = /^\d+$/.test(selector.trim()); + const interactive = Boolean(process.stdin.isTTY); + if ((!numeric || !opts.key) && !interactive) { + throw new Error( + "Non-interactive use requires an exact numeric id and --key, e.g. `ct use group 4711 --key ojahr_fuzzies`.", + ); + } + + let chosen; + let suggestedKey: string | undefined; + if (numeric) { + const inspected = await inspectExternalCandidate({ + type, + id: selector, + statePath: opts.state, + environment: opts.env, + }); + chosen = inspected.value.candidate; + suggestedKey = inspected.value.suggestedKey; + } else { + const discovered = await discoverExternalCandidates({ + type, + search: selector, + statePath: opts.state, + environment: opts.env, + }); + if (discovered.value.candidates.length === 0) { + throw new Error(`No live ${type} matches ${JSON.stringify(selector)}.`); + } + info(`Matching ${type} candidates:`); + discovered.value.candidates.forEach((candidate, index) => + info(` ${index + 1}. ${candidateLine(candidate)}`), + ); + const answer = ( + await askVisible(`Select candidate [1-${discovered.value.candidates.length}]: `) + ).trim(); + const selected = answer === "" && discovered.value.candidates.length === 1 ? 1 : Number(answer); + if (!Number.isInteger(selected) || selected < 1 || selected > discovered.value.candidates.length) { + throw new Error("No valid candidate selected. External state was not changed."); + } + chosen = discovered.value.candidates[selected - 1]!; + const inspected = await inspectExternalCandidate({ + type, + id: chosen.id, + statePath: opts.state, + environment: opts.env, + }); + chosen = inspected.value.candidate; + suggestedKey = inspected.value.suggestedKey; + } + + let key = opts.key?.trim(); + if (!key) { + const proposal = suggestedKey!; + const answer = (await askVisible(`Logical key [${proposal}]: `)).trim(); + key = answer || proposal; + } + const result = await bind(type, chosen.id, key, opts); + if (!result) return; + const { action, binding, written } = result.value; + if (opts.dryRun) { + info(`Would ${action} external ${type}.${binding.key} -> #${binding.id}; ChurchTools is read-only.`); + } else if (action === "no-op") { + success(`External ${type}.${binding.key} already binds #${binding.id}; state is byte-unchanged.`); + } else { + success( + `${action}: external ${type}.${binding.key} -> #${binding.id} in ${result.project.stateDisplayPath}.`, + ); + } + if (written) info("ChurchTools was read for validation and was not written."); + }); +} diff --git a/src/completion/candidates.ts b/src/completion/candidates.ts index 801750a..ce56595 100644 --- a/src/completion/candidates.ts +++ b/src/completion/candidates.ts @@ -39,10 +39,14 @@ type DynamicSource = (position: Position, partial: string) => Promise */ const DYNAMIC_ARGUMENTS: Record = { "ct adopt type": () => resourceTypes(), + "ct use type": () => resourceTypes(), "ct state rm type": () => resourceTypes(), + "ct state rekey type": () => resourceTypes(), // `ct state rm ` refuses a key belonging to another type, so the type // already typed narrows the keys — completing into a guaranteed error helps nobody. "ct state rm key": async (position) => stateKeys(await statePathFor(position), position.positionals[0]), + "ct state rekey old-key": async (position) => + stateKeys(await statePathFor(position), position.positionals[0]), }; /** diff --git a/src/completion/sources.ts b/src/completion/sources.ts index 75803b0..7e0caae 100644 --- a/src/completion/sources.ts +++ b/src/completion/sources.ts @@ -74,15 +74,17 @@ export function envStatePath(path: string, name: string): Promise * ` rejects a key of any other type: offering it would only complete into an error. */ export function stateKeys(path: string, type?: string): Promise { return offline(async () => { - const resources = objectField(JSON.parse(await readFile(path, "utf8")), "resources"); - return Object.entries(resources) + const state = JSON.parse(await readFile(path, "utf8")); + const resources = objectField(state, "resources"); + const externals = objectField(state, "externals"); + return Object.entries({ ...resources, ...externals }) .filter(([, entry]) => type === undefined || (isObject(entry) && entry.type === type)) .map(([key]) => key); }, []); diff --git a/src/config/context.ts b/src/config/context.ts index 3b1c3e9..b0de569 100644 --- a/src/config/context.ts +++ b/src/config/context.ts @@ -647,10 +647,9 @@ function toDesired(type: string, input: ResourceInput, location?: string): Desir } /** - * Every managed hierarchy parent must reference a group declared in the same config. - * A parent that resolves to nothing (typo, unmanaged group) or to a non-group would - * diff forever against the managed-only actual side, so reject it up front rather than - * emit a plan that can never converge. + * A hierarchy parent may be declared in this config or supplied by an external group binding. + * Config evaluation can reject a known non-group immediately; unknown keys are deliberately left + * for the plan-time resolver/state validation, because external declarations live in per-host state. */ function validateReferences(resources: DesiredResource[]): void { const byKey = new Map(resources.map((r) => [r.key, r])); @@ -658,10 +657,7 @@ function validateReferences(resources: DesiredResource[]): void { for (const parentKey of r.parents ?? []) { const target = byKey.get(parentKey); if (!target) { - throw new Error( - `Group "${r.key}" declares hierarchy parent "${parentKey}", which is not declared in this config. ` + - `Managed parents must reference a group by its key (omit unmanaged parents entirely).`, - ); + continue; } if (target.type !== "group") { throw new Error( diff --git a/src/engine/build.ts b/src/engine/build.ts index 91fd191..a6b41c3 100644 --- a/src/engine/build.ts +++ b/src/engine/build.ts @@ -119,6 +119,16 @@ export async function buildPlan( desired: DesiredResource[], opts: BuildOptions = {}, ): Promise { + const resolver = opts.resolver ?? new Resolver({ client, state, desired }); + // Hierarchy predates the generic Ref sentinel and stores parent keys as strings. Validate those + // keys through the same resolver so external parents receive the identical live identity gate. + await Promise.all( + desired.flatMap((resource) => + (resource.parents ?? []).map((parent) => + resolver.resolveKey("group", parent, `group "${resource.key}" hierarchy parent`), + ), + ), + ); // Keyed by logical key (globally unique), not CT id (unique only within a type — the Mainz campus is id 0). const { actual, @@ -141,7 +151,6 @@ export async function buildPlan( // Resolution pass (#20): rewrite Ref-valued fields (and the dynamic ruleset, walked deeply) to // numbers / pending markers AFTER folding, BEFORE computePlan — so the diff stays number↔number. // Unknown/ambiguous refs THROW here (a config error, not a degrade-and-continue fetch error). - const resolver = opts.resolver ?? new Resolver({ client, state, desired }); const resolved = await Promise.all( folded.desired.map(async (d) => { const fields = await resolver.resolveValue(d.fields, `${d.type} "${d.key}"`); diff --git a/src/engine/hierarchy.ts b/src/engine/hierarchy.ts index 08cf182..e28d8ca 100644 --- a/src/engine/hierarchy.ts +++ b/src/engine/hierarchy.ts @@ -9,7 +9,7 @@ * removal. */ -import type { State } from "../state/state.js"; +import { externalResources, type State } from "../state/state.js"; import type { DesiredResource } from "./types.js"; export interface HierarchyEntry { @@ -72,6 +72,9 @@ export function applyHierarchy( groupIdToKey.set(managed.id, managed.key); } } + for (const external of Object.values(externalResources(state))) { + if (external.type === "group") groupIdToKey.set(external.id, external.key); + } // Single pass over the desired opt-ins (one copy of the predicate, mirroring the desired-side // guard below). A group's actual gets a `parents` set only when it opted in AND is a managed diff --git a/src/engine/synthetic.ts b/src/engine/synthetic.ts index 9051201..cddf8b1 100644 --- a/src/engine/synthetic.ts +++ b/src/engine/synthetic.ts @@ -7,7 +7,7 @@ */ import type { CtClient } from "../api/ctClient.js"; import { CtApiError } from "../api/ctClient.js"; -import type { State } from "../state/state.js"; +import { findByKey, type State } from "../state/state.js"; import type { DesiredResource, FieldChange, Plan, PlanItem } from "./types.js"; import { applyHierarchy, parentIdsByGroupId, type HierarchyEntry } from "./hierarchy.js"; import { assertNotPeople } from "./guard.js"; @@ -124,9 +124,9 @@ export interface SyntheticField { } function resolveId(state: State, key: string): number { - const managed = state.resources[key]; - if (!managed) throw new Error(`Cannot resolve parent "${key}" — not under management yet.`); - return managed.id; + const binding = findByKey(state, key); + if (!binding) throw new Error(`Cannot resolve parent "${key}" — no managed or external binding exists.`); + return binding.id; } /** `parents`: many-to-many group hierarchy, reconciled per-edge. Wraps the existing hierarchy helpers. */ diff --git a/src/index.ts b/src/index.ts index 3fa5b90..393957e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,8 @@ import { Command } from "commander"; import { authCommand } from "./commands/auth.js"; import { getCommand } from "./commands/get.js"; import { adoptCommand } from "./commands/adopt.js"; +import { useCommand } from "./commands/use.js"; +import { ownershipCommand } from "./commands/ownership.js"; import { stateCommand } from "./commands/state.js"; import { coverageCommand } from "./commands/coverage.js"; import { permissionsCommand } from "./commands/permissions.js"; @@ -33,6 +35,8 @@ export function buildProgram(): Command { program.addCommand(authCommand()); program.addCommand(getCommand()); program.addCommand(adoptCommand()); + program.addCommand(useCommand()); + program.addCommand(ownershipCommand()); program.addCommand(stateCommand()); program.addCommand(coverageCommand()); program.addCommand(permissionsCommand()); diff --git a/src/permissions/scope.ts b/src/permissions/scope.ts index d81d9ba..23c3d64 100644 --- a/src/permissions/scope.ts +++ b/src/permissions/scope.ts @@ -230,7 +230,10 @@ export async function resolveScopeRefs( continue; // unknown right — desiredTuples reports it with the catalog's own hint } for (const raw of g.scope) { - const entry = normalizeScopeEntry(raw, where); + let entry = normalizeScopeEntry(raw, where); + // Bare strings are the historical group-scope sugar. Pre-resolve them through the shared + // resolver too, so an external group binding works in exactly the same permission position. + if (typeof entry === "string" && scopeField === GROUP_SCOPE_FIELD) entry = ref.group(entry); if (!isRef(entry)) continue; const dimension = expectedDimension(entry, scopeField, where); const k = refKey(entry); @@ -385,8 +388,14 @@ export function resolveScope( } else if (declaredGroupKeys.has(key)) { pending.push({ key, id: null, type: "group" }); } else { + const external = refs?.get(refKey(ref.group(key))); + if (external?.id !== null && external?.id !== undefined) { + resolved.push({ key: String(external.id), id: external.id, numeric: true }); + continue; + } throw new Error( - `Scope key "${key}" does not resolve to a managed group. Declare/adopt it, use a group already under management, or pass a raw numeric dataId if this right's scope is not a group (see the catalog's scopeField).`, + `Scope key "${key}" does not resolve to a managed or external group. Declare/adopt it, bind it with ` + + `\`ct use group --key ${key}\`, or pass a raw numeric dataId if this right's scope is not a group.`, ); } } diff --git a/src/resolve/external.ts b/src/resolve/external.ts new file mode 100644 index 0000000..3ade1df --- /dev/null +++ b/src/resolve/external.ts @@ -0,0 +1,128 @@ +import type { ExternalResource } from "../state/state.js"; + +export const EXTERNAL_REASON_CODES = [ + "EXTERNAL_BINDING_MISSING", + "EXTERNAL_BINDING_AMBIGUOUS", + "EXTERNAL_IDENTITY_MISMATCH", + "EXTERNAL_BINDING_STALE", + "EXTERNAL_READ_FAILED", +] as const; + +export type ExternalReasonCode = (typeof EXTERNAL_REASON_CODES)[number]; + +export interface ExternalCandidate { + id: number; + name: string; + identity: Record; + display: Record; +} + +export interface IdentityDifference { + field: string; + expected: unknown; + actual: unknown; +} + +export interface ExternalDiagnosticContext { + consumer?: string; + cwd?: string; + configPath?: string; + statePath?: string; + environment?: string | null; + host: string; +} + +export interface ExternalRemediation { + description: string; + command?: string; +} + +export interface ExternalDiagnosticDetails { + reason: ExternalReasonCode; + type: string; + key: string; + site: string; + context: ExternalDiagnosticContext; + binding?: ExternalResource; + candidates?: ExternalCandidate[]; + identityDiff?: IdentityDifference[]; + evidence: string[]; + consequence: string; + remediation: ExternalRemediation[]; + verification: string; +} + +function fieldBag(value: Record): string { + const entries = Object.entries(value); + return entries.length === 0 + ? "" + : ` · ${entries.map(([key, item]) => `${key}=${JSON.stringify(item)}`).join(", ")}`; +} + +export function identityDifferences( + expected: Record, + actual: Record, +): IdentityDifference[] { + const keys = [...new Set([...Object.keys(expected), ...Object.keys(actual)])].sort(); + return keys + .filter((field) => JSON.stringify(expected[field]) !== JSON.stringify(actual[field])) + .map((field) => ({ field, expected: expected[field], actual: actual[field] })); +} + +export function planVerification(environment?: string | null): string { + return environment ? `ct plan --env ${environment}` : "ct plan"; +} + +export function useBindingCommand( + type: string, + id: number, + key: string, + environment?: string | null, +): string { + return `ct use ${type} ${id} --key ${key}${environment ? ` --env ${environment}` : ""}`; +} + +export function renderExternalDiagnostic(details: ExternalDiagnosticDetails): string { + const { context } = details; + const lines = [ + "External prerequisite is not available", + "", + ` resource: ${details.type} ${JSON.stringify(details.key)}`, + ` referenced: ${details.site}`, + ` consumer: ${context.consumer ?? context.cwd ?? "current ct project"}`, + ` owner: ${details.binding?.owner ?? "unknown"}`, + ` environment: ${context.environment ?? "default"}`, + ` host: ${context.host}`, + "", + "Evidence:", + ...details.evidence.map((item) => ` - ${item}`), + ]; + if (details.candidates?.length) { + lines.push("", "Candidates:"); + for (const candidate of details.candidates) { + lines.push( + ` - #${candidate.id} ${JSON.stringify(candidate.name)}${fieldBag(candidate.identity)}${fieldBag(candidate.display)}`, + ); + } + } + if (details.identityDiff?.length) { + lines.push("", "Identity changes:"); + for (const diff of details.identityDiff) { + lines.push(` - ${diff.field}: ${JSON.stringify(diff.expected)} -> ${JSON.stringify(diff.actual)}`); + } + } + lines.push("", `Consequence: ${details.consequence}`, "", "Next steps:"); + details.remediation.forEach((step, index) => { + lines.push(` ${index + 1}. ${step.command ?? step.description}`); + if (step.command && step.description) lines.push(` ${step.description}`); + }); + lines.push("", `Verification: ${details.verification}`); + return lines.join("\n"); +} + +export class ExternalReferenceError extends Error { + constructor(readonly details: ExternalDiagnosticDetails) { + super(renderExternalDiagnostic(details)); + this.name = "ExternalReferenceError"; + } +} diff --git a/src/resolve/refs.ts b/src/resolve/refs.ts index 98e062e..2c1c0f6 100644 --- a/src/resolve/refs.ts +++ b/src/resolve/refs.ts @@ -24,6 +24,9 @@ export type RefKind = | "security-level" | "comment-viewer" | "group-type" + | "age-group" + | "target-group" + | "relationship-type" | "group-status" | "person-status" | "role-def" @@ -56,6 +59,9 @@ export interface SimpleRef { | "security-level" | "comment-viewer" | "group-type" + | "age-group" + | "target-group" + | "relationship-type" | "group-status" | "person-status" | "role-def" @@ -204,6 +210,21 @@ export const ref = { kind: "group-type", key: requireKey("group-type", key), }), + ageGroup: (key: string): SimpleRef => ({ + __ctRef: true, + kind: "age-group", + key: requireKey("age-group", key), + }), + targetGroup: (key: string): SimpleRef => ({ + __ctRef: true, + kind: "target-group", + key: requireKey("target-group", key), + }), + relationshipType: (key: string): SimpleRef => ({ + __ctRef: true, + kind: "relationship-type", + key: requireKey("relationship-type", key), + }), status: (key: string): SimpleRef => ({ __ctRef: true, kind: "group-status", diff --git a/src/resolve/resolver.ts b/src/resolve/resolver.ts index 0c91814..4d0839a 100644 --- a/src/resolve/resolver.ts +++ b/src/resolve/resolver.ts @@ -7,28 +7,22 @@ * state resolves to a {@link PendingRef} (its id is only known after the * resource tier applies — re-resolved at apply time, mirroring the permission * scope pattern in src/permissions/scope.ts). - * 2. Live catalog master data, matched by `slug(name) === key` with an exact-name - * secondary: campus → /campuses, group-type → /group/grouptypes, role-def → /group/roles. - * Each catalog is fetched at most once per run and cached by a `Map`, - * so the resolver is safe to share across `buildPlan` and `buildPermissionPlan` running - * concurrently (both await the same in-flight promise). group-status ("group-status" / - * `ref.status`) has NO catalog here — ChurchTools exposes no REST list endpoint for group - * statuses at all (live-verified 2026-07-10 on eqrm prod; see the note by `CATALOG_PATH` - * below and #67). A declared `status:` field fails fast at eval time (src/config/context.ts) - * before it ever reaches this resolver — but a `groupStatusId: ref.status(...)` value skips - * that guard (the id-field escape hatch accepts any Ref) and lands on step 3 below, where - * `notFound` special-cases "group-status" to give the same actionable message instead of - * the generic "declare/adopt it" advice, which would be wrong (no such resource, no catalog). - * 3. Hard error naming the kind, key, referencing site, and host. + * 2. A persisted external binding, read live by id and checked against the registry-defined hard + * identity. It is never pending and never enters desired/write inputs. + * 3. Live catalog discovery for diagnostics only. A unique candidate still blocks until `ct use` + * persists the explicit host binding; ambiguous candidates each get a complete command. Catalog + * reads are cached so a resolver shared by resource and permission planning is concurrency-safe. + * Group status remains the specialised exception: ChurchTools exposes no corresponding REST + * catalog (#67), so its existing numeric-only error remains. * * Unknown / ambiguous references THROW (a config error — distinct from the * degrade-and-continue fetchErrors path). Resolved ids are never written back to * config; only state carries ids. */ -import type { CtClient } from "../api/ctClient.js"; -import type { State } from "../state/state.js"; +import { CtApiError, type CtClient } from "../api/ctClient.js"; +import { externalResources, type ExternalResource, type State } from "../state/state.js"; import type { DesiredResource } from "../engine/types.js"; -import { slug } from "../resources/registry.js"; +import { RESOURCES, resourceType, slug, type CtWriteClient } from "../resources/registry.js"; import { conflictingReferenceName, groupScopedRows, @@ -55,35 +49,19 @@ import { type RefKind, type SimpleRef, } from "./refs.js"; +import { + ExternalReferenceError, + identityDifferences, + planVerification, + useBindingCommand, + type ExternalCandidate, + type ExternalDiagnosticContext, +} from "./external.js"; /** ref kind → managed resource type (state/desired). group-status has neither: no catalog and never managed (#67). */ -const REF_KIND_TYPE: Partial> = { - campus: "campus", - "group-type": "group-type", - // Person statuses became an adoptable resource in #96, so a `personStatus: "…"` domain now - // resolves from managed state / this run's declarations FIRST and only falls through to the - // `/statuses` catalog for a status this config does not own. That ordering is what makes a - // status declared in the same config usable as a permission domain (it resolves to a PendingRef, - // which buildPermissionPlan carries as a pending domain, #69). - "person-status": "person-status", - // Bereiche became a managed resource in #108 (writes go through the legacy master-data endpoint, - // reads stay `GET /departments`), so a `{ department: "…" }` ref resolves from managed state first - // and only falls back to the catalog for a Bereich this config does not own. - department: "department", - // Security levels became a managed resource in #110, so — exactly like person statuses above — a - // `{ securityLevel: "…" }` ref resolves from managed state / this run's declarations FIRST and only - // falls through to the `/securitylevels` catalog for a level this config does not own. That - // ordering is what lets a config declare a level and scope a grant to it in the same run (the ref - // resolves to a PendingRef, carried as a pending scope). - "security-level": "security-level", - // Comment viewers became a managed resource in #151 — same ordering as security levels above, and - // for the sharper reason: their ids genuinely differ across hosts of the same deployment, so a - // config that declares its viewers must resolve `{ commentViewer: "…" }` against what IT owns - // before falling back to `/person/commentviewers` for a viewer it does not. - "comment-viewer": "comment-viewer", - "role-def": "group-role", - group: "group", -}; +const REF_KIND_TYPE: Partial> = Object.fromEntries( + Object.entries(RESOURCES).map(([type, spec]) => [spec.external.refKind, type]), +) as Partial>; /** * ref kind → live catalog path. `group` has no catalog (managed-only); `group-role` is gated. @@ -99,33 +77,9 @@ const REF_KIND_TYPE: Partial> = { * here as an unconditional hard error. If CT ever ships a real group-status endpoint, add it back * here and restore the `status` entry to `ID_SUGAR` in context.ts. */ -const CATALOG_PATH: Partial> = { - campus: "/campuses", - // Bereiche/departments — the `cdb_bereich` permission scope dimension (#98). Catalog-ONLY, with no - // REF_KIND_TYPE entry above: `GET /departments` exists but no POST/PUT/DELETE does (live-probed on - // eqrm prod, CT 3.135.2, 2026-08-13), so a department is resolvable by name on every host yet can - // never be declared, adopted or created. Rows carry {id, name, nameTranslated, sortKey, shorty}. - department: "/departments", - // Security levels — the `cc_securitylevel` scope dimension (#110). `GET /securitylevels` returns a - // flat `[{id, name, sortKey}]` array ("Stufe 1 (Niedrig)" … "Stufe 4 (Sehr hoch)"), live-verified on - // eqrm prod (CT 3.135.2, 2026-08-13) and eqrm-dev (2026-08-14). Unlike `department` this kind ALSO - // has a REF_KIND_TYPE entry: levels are a managed resource, so this catalog is the fallback for a - // level the config does not own, not the only source. Reading it by name matters because the ids - // are not a protocol constant — an editable table with an auto-increment id and a supported - // reorder, so a hard-coded `scope: [1, 2, 3]` is portable only by convention. - "security-level": "/securitylevels", - // Comment viewers — the `cdb_comment_viewer` scope dimension (#102). `[{id, name, sortKey}]`, - // live-verified on eqrm-dev CT 3.135.2, 2026-08-14. NB `id: 0` is a real row here ("Alle"), which - // is why nothing in the resolve path may treat a falsy id as "missing". - "comment-viewer": "/person/commentviewers", - "group-type": "/group/grouptypes", - // PERSON statuses — the domain of a `status` permission declaration (#90). Unlike GROUP statuses - // (see the note above), these DO have a flat REST catalog: `GET /statuses` returns - // `[{id, name, shorty, …}]` — live-verified 2026-08-10 on eqrm prod. (`/person/masterdata` carries - // the same rows under a `statuses` key, but nested; this catalog reader expects a top-level array.) - "person-status": "/statuses", - "role-def": "/group/roles", -}; +const CATALOG_PATH: Partial> = Object.fromEntries( + Object.values(RESOURCES).map((spec) => [spec.external.refKind, spec.collectionPath]), +) as Partial>; interface CatalogRecord { id: number; @@ -146,6 +100,8 @@ export interface ResolverDeps { desired: DesiredResource[]; /** Host label for error messages. Defaults to `state.host`. */ host?: string; + /** Public project metadata used to produce copyable, structured external diagnostics. */ + context?: Omit; } /** @@ -197,7 +153,9 @@ export class Resolver { private readonly client: Pick & Partial>; private readonly state: State; private readonly host: string; + private readonly context: ExternalDiagnosticContext; private readonly catalogs = new Map>(); + private readonly externalReads = new Map>(); /** Per-group role list cache (group_role domain resolution), keyed by group id, fetched at most once. */ private readonly groupRoleLists = new Map>(); /** Declared logical keys indexed by resource type — a same-run target that resolves to pending. */ @@ -229,6 +187,7 @@ export class Resolver { this.client = deps.client; this.state = deps.state; this.host = deps.host ?? deps.state.host; + this.context = { ...deps.context, host: this.host }; for (const d of deps.desired) { let set = this.declaredByType.get(d.type); if (!set) { @@ -266,10 +225,13 @@ export class Resolver { const managed = this.state.resources[r.key]; if (managed && managed.type === type) return managed.id; if (this.declaredByType.get(type)?.has(r.key)) return pendingRef(r); + // (2) a persisted external binding, always read live and hard-identity validated. + const external = externalResources(this.state)[r.key]; + if (external && external.type === type) return this.resolveBoundExternal(external, site); + // (3) discovery is diagnostic only. It never supplies an ephemeral id. + return this.requireExternalBinding(type, r, site); } - // (2) live catalog - if (CATALOG_PATH[r.kind] !== undefined) return this.resolveFromCatalog(r, site); - // (3) hard error + // Non-registry refs (group status and compound/owned structures) keep their specialised errors. throw this.notFound(r, site); } @@ -289,6 +251,12 @@ export class Resolver { return deepMapRefs(value, (r) => byKey.get(refKey(r))); } + /** Resolve a registry type/key pair used by string-only legacy positions such as hierarchy parents. */ + async resolveKey(type: string, key: string, site: string): Promise { + const kind = resourceType(type).external.refKind; + return this.resolve({ __ctRef: true, kind, key } as SimpleRef, site); + } + /** * Fetch one master-data catalog, ONCE per run, paginated. * @@ -311,19 +279,164 @@ export class Resolver { return p; } - private async resolveFromCatalog(r: SimpleRef, site: string): Promise { - const rows = await this.catalog(r.kind); - const pick = (candidates: CatalogRecord[]): number => { - if (candidates.length > 1) throw this.ambiguous(r, site, candidates); - return candidates[0]!.id; + private async resolveBoundExternal(external: ExternalResource, site: string): Promise { + let read = this.externalReads.get(external.key); + if (!read) { + read = this.validateBoundExternal(external, site); + this.externalReads.set(external.key, read); + } + return read; + } + + private async validateBoundExternal(external: ExternalResource, site: string): Promise { + const spec = resourceType(external.type); + let live: Record | null; + try { + live = spec.fetchOne + ? await spec.fetchOne(this.client as CtWriteClient, external.id) + : await this.client.get>(spec.itemPath(external.id)); + } catch (error) { + if (error instanceof CtApiError && error.status === 404) live = null; + else { + throw new ExternalReferenceError({ + reason: "EXTERNAL_READ_FAILED", + type: external.type, + key: external.key, + site, + context: this.context, + binding: external, + evidence: [ + `Inspected external state binding ${external.type}.${external.key} -> #${external.id}.`, + `The live item read failed: ${error instanceof Error ? error.message : String(error)}.`, + ], + consequence: + "Consumer plan/apply is blocked before writes; the consumer will not create or repair the owner's object.", + remediation: [{ description: "Restore live read access or retry after the transient failure." }], + verification: planVerification(this.context.environment), + }); + } + } + if (live === null) { + throw new ExternalReferenceError({ + reason: "EXTERNAL_BINDING_STALE", + type: external.type, + key: external.key, + site, + context: this.context, + binding: external, + evidence: [ + `Inspected external state binding ${external.type}.${external.key} -> #${external.id}.`, + `The registry item read ${spec.itemPath(external.id)} returned 404 / no row.`, + ], + consequence: + "Consumer plan/apply is blocked before writes; the consumer will not recreate the missing owner's object.", + remediation: external.owner + ? [ + { + description: `Run plan in owner project ${JSON.stringify(external.owner)} and repair its stale state/object first.`, + }, + ] + : [ + { + description: + "Locate the owner project and run its plan before changing this consumer binding.", + }, + ], + verification: planVerification(this.context.environment), + }); + } + const identity = spec.external.identity(live); + const diff = identityDifferences(external.identity, identity); + if (diff.length > 0) { + const command = useBindingCommand(external.type, external.id, external.key, this.context.environment); + throw new ExternalReferenceError({ + reason: "EXTERNAL_IDENTITY_MISMATCH", + type: external.type, + key: external.key, + site, + context: this.context, + binding: external, + identityDiff: diff, + evidence: [ + `Inspected external state binding ${external.type}.${external.key} -> #${external.id}.`, + "The live object exists, but its registry-defined hard identity differs from the stored snapshot.", + ], + consequence: + "Consumer plan/apply is blocked before writes; display-only changes would not block, but hard identity changes require explicit acceptance.", + remediation: [ + { command, description: "Review the field diff and confirm accepting the live identity." }, + ], + verification: planVerification(this.context.environment), + }); + } + return external.id; + } + + private candidate(type: string, row: CatalogRecord): ExternalCandidate { + const spec = resourceType(type); + return { + id: row.id, + name: typeof row.name === "string" ? row.name : `#${row.id}`, + identity: spec.external.identity(row), + display: spec.external.display(row), }; - // Primary: slugified name. Secondary: exact (case-sensitive) name — covers a name that does not - // survive slugging cleanly. Ambiguity in either bucket is a hard error listing the candidates. + } + + private async requireExternalBinding(type: string, r: SimpleRef, site: string): Promise { + let rows: CatalogRecord[]; + try { + rows = await this.catalog(r.kind); + } catch (error) { + throw new ExternalReferenceError({ + reason: "EXTERNAL_READ_FAILED", + type, + key: r.key, + site, + context: this.context, + evidence: [ + `Inspected managed state and external state; neither contains a ${type} binding for key ${JSON.stringify(r.key)}.`, + `Live discovery at ${resourceType(type).collectionPath} failed: ${error instanceof Error ? error.message : String(error)}.`, + ], + consequence: + "Consumer plan/apply is blocked before writes; the consumer will not create or repair the owner's object.", + remediation: [{ description: "Restore collection read access, then create the explicit binding." }], + verification: planVerification(this.context.environment), + }); + } const bySlug = rows.filter((row) => typeof row.name === "string" && slug(row.name) === r.key); - if (bySlug.length >= 1) return pick(bySlug); - const byExact = rows.filter((row) => row.name === r.key); - if (byExact.length >= 1) return pick(byExact); - throw this.notFound(r, site); + const matches = bySlug.length > 0 ? bySlug : rows.filter((row) => row.name === r.key); + const candidates = matches.map((row) => this.candidate(type, row)); + const reason = candidates.length > 1 ? "EXTERNAL_BINDING_AMBIGUOUS" : "EXTERNAL_BINDING_MISSING"; + const remediation = + candidates.length > 0 + ? candidates.map((candidate) => ({ + command: useBindingCommand(type, candidate.id, r.key, this.context.environment), + description: `Bind ${JSON.stringify(candidate.name)} read-only in this consumer.`, + })) + : [ + { + description: + "Apply the owner project first, correct the logical key, or use `ct use` with the intended live id.", + }, + ]; + throw new ExternalReferenceError({ + reason, + type, + key: r.key, + site, + context: this.context, + candidates, + evidence: [ + `Inspected managed state and external state; neither contains a ${type} binding for key ${JSON.stringify(r.key)}.`, + candidates.length === 0 + ? `Live discovery at ${resourceType(type).collectionPath} found no matching candidate.` + : `Live discovery found ${candidates.length} matching candidate(s), but plan is read-only and cannot persist or consume an ephemeral binding.`, + ], + consequence: + "Consumer plan/apply is blocked before writes; the consumer will not create, adopt, or repair the owner's object.", + remediation, + verification: planVerification(this.context.environment), + }); } /** @@ -343,7 +456,7 @@ export class Resolver { site: string, opts: ResolveOptions = {}, ): Promise { - const groupId = this.groupIdForRole(r, site, opts); + const groupId = await this.groupIdForRole(r, site, opts); if (typeof groupId !== "number") return groupId; const rows = await this.groupRoleList(groupId); // #106 made a same-run GROUP resolve as pending. #120: the ROLE half needs the same treatment. @@ -388,7 +501,9 @@ export class Resolver { if (!this.declaresRoleNamed(r.role)) return false; const declaredTypes = this.declaredRoleDefTypes.get(slug(r.role)); if (declaredTypes === undefined) return true; // declared, but on no stated group type - const groupTypeId = this.state.resources[r.group]?.fields.groupTypeId; + const groupTypeId = + this.state.resources[r.group]?.fields.groupTypeId ?? + externalResources(this.state)[r.group]?.identity.groupTypeId; if (typeof groupTypeId !== "number") return true; // this host's state cannot say — stay lenient for (const t of declaredTypes) { // A group-type Ref that is itself pending cannot be this existing group's type, so a non-number @@ -469,7 +584,11 @@ export class Resolver { * declared in this run resolves to a {@link PendingRef} when the call site can finish it later * (#106) and stays a hard error otherwise. */ - private groupIdForRole(r: GroupRoleRef, site: string, opts: ResolveOptions): number | PendingRef { + private async groupIdForRole( + r: GroupRoleRef, + site: string, + opts: ResolveOptions, + ): Promise { const managed = this.state.resources[r.group]; if (managed && managed.type === "group") return managed.id; if (this.declaredByType.get("group")?.has(r.group)) { @@ -480,10 +599,10 @@ export class Resolver { `the group does. Apply the group first, then re-run, or pass a numeric id.`, ); } - throw new Error( - `Cannot resolve ${refLabel(r)} referenced at ${site} on ${this.host}: no managed group named ` + - `"${r.group}" is declared or adopted. Declare/adopt it, fix the key, or pass a numeric id.`, - ); + const external = externalResources(this.state)[r.group]; + if (external?.type === "group") return this.resolveBoundExternal(external, site); + const simple: SimpleRef = { __ctRef: true, kind: "group", key: r.group }; + return this.requireExternalBinding("group", simple, site); } /** diff --git a/src/resources/registry.ts b/src/resources/registry.ts index 7cefb32..71b0c3b 100644 --- a/src/resources/registry.ts +++ b/src/resources/registry.ts @@ -27,6 +27,27 @@ export interface CtWriteClient { get?(path: string): Promise; } +/** Generic read-only binding contract shared by `ct use`, resolution and ownership analysis. */ +export interface ExternalResourceAdapter { + /** Simple logical ref kind that resolves this top-level registry type. */ + refKind: + | "campus" + | "department" + | "security-level" + | "comment-viewer" + | "group-type" + | "age-group" + | "target-group" + | "relationship-type" + | "person-status" + | "role-def" + | "group"; + /** Minimal hard identity persisted in external state and validated on every plan. */ + identity(resource: Record): Record; + /** Non-validating fields shown while selecting or explaining a binding. */ + display(resource: Record): Record; +} + export interface AdoptableResource { /** Collection path: `POST` here creates, unless {@link createPath} overrides the target. */ collectionPath: string; @@ -127,6 +148,8 @@ export interface AdoptableResource { * (e.g. `group-role` → `roleDefinition`, because `groupRole` is the permission function). */ dslName?: string; + /** Mandatory generic external/read-only behaviour for this top-level ct-cli resource. */ + external: ExternalResourceAdapter; } /** Build a full spec, deriving `itemPath` from the collection path so each entry names its path once. */ @@ -153,6 +176,11 @@ function str(resource: Record, key: string): string { return typeof value === "string" ? value : ""; } +/** Keep adapter output compact and deterministic; absent API fields are not identity assertions. */ +function present(fields: Record): Record { + return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined)); +} + /** * First `max` *code points* (not UTF-16 code units) of `value` — CT's create validators cap several * name/shorty fields. Plain `String#slice` operates on UTF-16 code units, which can split an astral @@ -320,6 +348,11 @@ export const RESOURCES: Record = { // exactly as before — this is only about the key. deriveKey: (r) => slug(str(r, "name") || str(r, "shorty")), managedFields: (r) => ({ name: r.name, shorty: r.shorty }), + external: { + refKind: "campus", + identity: (r) => present({ name: r.name }), + display: (r) => present({ shorty: r.shorty }), + }, }), group: define({ collectionPath: "/groups", @@ -338,6 +371,15 @@ export const RESOURCES: Record = { groupStatusId: fromInformation(r, "groupStatusId"), campusId: fromInformation(r, "campusId") ?? null, }), + external: { + refKind: "group", + identity: (r) => present({ name: r.name, groupTypeId: fromInformation(r, "groupTypeId") }), + display: (r) => + present({ + campusId: fromInformation(r, "campusId"), + groupStatusId: fromInformation(r, "groupStatusId"), + }), + }, }), "group-type": define({ collectionPath: "/group/grouptypes", @@ -345,6 +387,11 @@ export const RESOURCES: Record = { tier: 0, deriveKey: (r) => slug(str(r, "name")), managedFields: (r) => ({ name: r.name, nameTranslated: r.nameTranslated }), + external: { + refKind: "group-type", + identity: (r) => present({ name: r.name }), + display: (r) => present({ nameTranslated: r.nameTranslated }), + }, // POST /group/grouptypes rejects a body carrying only name/nameTranslated: CT requires the fields // below (validated live on CT 3.134.1, #73, and against the OpenAPI POST schema). They are unmanaged // (create-only) and derived deterministically from the declared `name`. If a user declares one of @@ -376,6 +423,11 @@ export const RESOURCES: Record = { tier: 0, deriveKey: (r) => slug(str(r, "name")), managedFields: (r) => ({ name: r.name, nameTranslated: r.nameTranslated, sortKey: r.sortKey }), + external: { + refKind: "age-group", + identity: (r) => present({ name: r.name }), + display: (r) => present({ nameTranslated: r.nameTranslated, sortKey: r.sortKey }), + }, }), "target-group": define({ collectionPath: "/group/targetgroups", @@ -383,6 +435,11 @@ export const RESOURCES: Record = { tier: 0, deriveKey: (r) => slug(str(r, "name")), managedFields: (r) => ({ name: r.name, nameTranslated: r.nameTranslated, sortKey: r.sortKey }), + external: { + refKind: "target-group", + identity: (r) => present({ name: r.name }), + display: (r) => present({ nameTranslated: r.nameTranslated, sortKey: r.sortKey }), + }, }), "relationship-type": define({ collectionPath: "/person/relationshiptypes", @@ -396,6 +453,11 @@ export const RESOURCES: Record = { degreeNameA: r.degreeNameA, degreeNameB: r.degreeNameB, }), + external: { + refKind: "relationship-type", + identity: (r) => present({ name: r.name }), + display: (r) => present({ degreeNameA: r.degreeNameA, degreeNameB: r.degreeNameB }), + }, }), /** * PERSON statuses (`/statuses` — "0 - First", "3 - Group Active", …), the domain of a `ct.status` @@ -443,6 +505,11 @@ export const RESOURCES: Record = { sortKey: r.sortKey, securityLevelId: r.securityLevelId, }), + external: { + refKind: "person-status", + identity: (r) => present({ name: r.name }), + display: (r) => present({ shorty: r.shorty, isMember: r.isMember }), + }, }), /** * BEREICHE / DEPARTMENTS (#108) — `cdb_bereich`, the scope dimension of `churchdb:view alldata` @@ -473,6 +540,11 @@ export const RESOURCES: Record = { "through the legacy master-data endpoint. Verify it is unused first (`ct get departments`).", deriveKey: (r) => slug(str(r, "name")), managedFields: (r) => ({ name: r.name, shorty: r.shorty, sortKey: r.sortKey ?? 0 }), + external: { + refKind: "department", + identity: (r) => present({ name: r.name }), + display: (r) => present({ shorty: r.shorty }), + }, // There is no `GET /departments/{id}` — filter the collection instead. Without this every plan // after a create would read a 404 and propose creating the same Bereich again. fetchOne: async (client, id) => @@ -513,6 +585,11 @@ export const RESOURCES: Record = { "scopes). Verify it is unused first (`ct get security-levels`, `ct get data-fields`).", deriveKey: (r) => slug(str(r, "name")), managedFields: (r) => ({ id: r.id, name: r.name }), + external: { + refKind: "security-level", + identity: (r) => present({ name: r.name }), + display: (r) => present({ level: r.sortKey ?? r.id }), + }, }), /** * COMMENT VIEWERS (#151) — `cdb_comment_viewer`, the scope dimension of `churchdb:view comments` @@ -559,6 +636,11 @@ export const RESOURCES: Record = { "it is unused first (`ct get comment-viewers`, `ct report permissions`).", deriveKey: (r) => slug(str(r, "name")), managedFields: (r) => ({ name: r.name, sortKey: r.sortKey }), + external: { + refKind: "comment-viewer", + identity: (r) => present({ name: r.name }), + display: (r) => present({ sortKey: r.sortKey }), + }, // `sortKey` is managed but not mandatory in a hand-authored declaration; CT's create validator // for the 3-column master-data tables rejects a missing integer column, so supply a neutral one // (a declared value still wins — createDefaults merges UNDER the body). @@ -586,6 +668,11 @@ export const RESOURCES: Record = { // only diffs when the config declares it: `diffFields` walks the DESIRED keys. type: r.type, }), + external: { + refKind: "role-def", + identity: (r) => present({ name: r.name, groupTypeId: r.groupTypeId }), + display: (r) => present({ type: r.type }), + }, // Fields CT REQUIRES at create but the tool does not otherwise manage (#73/#121). The old comment // here claimed `type`/`isLeader`/`sortKey` were "all optional/nullable — no default needed", which // is what made this look supported. VERIFIED LIVE on eqrm-dev, CT 3.135.2 (2026-08-17): POSTing diff --git a/src/state/state.ts b/src/state/state.ts index 5311b83..67bf69b 100644 --- a/src/state/state.ts +++ b/src/state/state.ts @@ -1,9 +1,9 @@ /** - * The state file: the set of **explicitly managed** resources. + * The state file: explicitly managed ct-cli resources plus read-only external bindings. * - * Everything not in here is invisible to the tool — never shown, never changed, - * never proposed for deletion. It maps a logical key → CT id + the last-known - * snapshot of the fields we manage (the desired-state baseline for diffing). + * `resources` maps owned logical keys to ids and managed-field snapshots; + * `externals` maps consumer keys to ids and minimal hard-identity snapshots. Only + * the first partition participates in apply/destroy. * * The file belongs to the config repo (eqrm/ct-structure) and is meant to be * committed. Default path is `ct-state.json` in the cwd; override with @@ -43,11 +43,27 @@ export interface ManagedResource { memberFields?: Record; } +/** A read-only host binding. It is never part of desired/apply/destroy inputs. */ +export interface ExternalResource { + type: string; + id: number; + key: string; + /** Optional coordination hint; ownership checks derive the real owner from visible managed states. */ + owner?: string; + /** Minimal registry-defined hard identity. Display-only fields are deliberately not persisted. */ + identity: Record; + /** Creation time of this binding. Verification and identity acceptance never change it. */ + boundAt: string; +} + export interface State { - version: 1; + /** Version 1 is accepted on in-memory test/adapter inputs; files always load/save as version 2. */ + version: 1 | 2; host: string; /** Keyed by logical key (e.g. "mainz", "mainz_kids_lead"). */ resources: Record; + /** Keyed by the same globally unique logical-key namespace as {@link resources}. */ + externals?: Record; } export const DEFAULT_STATE_PATH = "ct-state.json"; @@ -66,7 +82,7 @@ export function resolveStatePath( } export function emptyState(host: string): State { - return { version: 1, host, resources: {} }; + return { version: 2, host, resources: {}, externals: {} }; } /** @@ -109,7 +125,7 @@ function validateState(parsed: unknown, path: string): State { throw new Error(`Malformed state file ${path}: expected a JSON object at the top level.`); } const obj = parsed as Record; - if (obj.version !== 1) { + if (obj.version !== 1 && obj.version !== 2) { throw new Error(`Unsupported state file version ${String(obj.version)} in ${path}`); } if (typeof obj.host !== "string" || obj.host === "") { @@ -118,9 +134,63 @@ function validateState(parsed: unknown, path: string): State { if (typeof obj.resources !== "object" || obj.resources === null || Array.isArray(obj.resources)) { throw new Error(`Malformed state file ${path}: "resources" must be an object.`); } + if ( + obj.version === 2 && + (typeof obj.externals !== "object" || obj.externals === null || Array.isArray(obj.externals)) + ) { + throw new Error(`Malformed state file ${path}: "externals" must be an object in version 2.`); + } + validateManagedEntries(obj.resources as Record, path); + if (obj.version === 2) validateExternalEntries(obj.externals as Record, path); return obj as unknown as State; } +function entryObject(value: unknown, label: string, path: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`Malformed state file ${path}: ${label} must be an object.`); + } + return value as Record; +} + +function validateCommonEntry( + key: string, + value: unknown, + label: string, + path: string, +): Record { + const entry = entryObject(value, label, path); + if (entry.key !== key) { + throw new Error(`Malformed state file ${path}: ${label}.key must equal its map key "${key}".`); + } + if (typeof entry.type !== "string" || entry.type === "") { + throw new Error(`Malformed state file ${path}: ${label}.type must be a non-empty string.`); + } + if (typeof entry.id !== "number" || !Number.isSafeInteger(entry.id) || entry.id < 0) { + throw new Error(`Malformed state file ${path}: ${label}.id must be a non-negative safe integer.`); + } + return entry; +} + +function validateManagedEntries(resources: Record, path: string): void { + for (const [key, value] of Object.entries(resources)) { + const entry = validateCommonEntry(key, value, `resources.${key}`, path); + entryObject(entry.fields, `resources.${key}.fields`, path); + } +} + +function validateExternalEntries(externals: Record, path: string): void { + for (const [key, value] of Object.entries(externals)) { + const entry = validateCommonEntry(key, value, `externals.${key}`, path); + entryObject(entry.identity, `externals.${key}.identity`, path); + if (typeof entry.boundAt !== "string" || entry.boundAt === "") { + throw new Error(`Malformed state file ${path}: externals.${key}.boundAt must be a non-empty string.`); + } + if (entry.owner !== undefined && (typeof entry.owner !== "string" || entry.owner === "")) { + throw new Error(`Malformed state file ${path}: externals.${key}.owner must be a non-empty string.`); + } + } +} + /** * In-place, version-preserving migrations for state loaded from disk. * @@ -149,11 +219,58 @@ function migrateState(state: State): State { delete fields.shortName; } } + if (state.version === 1) { + state.version = 2; + state.externals = {}; + } else if (!state.externals) { + state.externals = {}; + } + assertStateKeyUniqueness(state); return state; } export async function saveState(path: string, state: State): Promise { - await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + assertStateKeyUniqueness(state); + const persisted: State = { ...state, version: 2, externals: state.externals ?? {} }; + await writeFile(path, `${JSON.stringify(persisted, null, 2)}\n`, "utf8"); +} + +/** External entries, normalized for legacy in-memory callers. */ +export function externalResources(state: State): Record { + return state.externals ?? (state.externals = {}); +} + +/** Managed then external, matching the resolver's precedence. */ +export function findByKey(state: State, key: string): ManagedResource | ExternalResource | undefined { + return state.resources[key] ?? externalResources(state)[key]; +} + +export function findExternalByTypeId(state: State, type: string, id: number): ExternalResource | undefined { + return Object.values(externalResources(state)).find((r) => r.type === type && r.id === id); +} + +export function assertStateKeyUniqueness(state: State): void { + for (const key of Object.keys(externalResources(state))) { + if (state.resources[key]) { + throw new Error(`Logical key "${key}" is used by both a managed and an external entry.`); + } + } + const seen = new Map(); + for (const [kind, entries] of [ + ["managed", Object.values(state.resources)], + ["external", Object.values(externalResources(state))], + ] as const) { + for (const entry of entries) { + const identity = `${entry.type}\0${entry.id}`; + const prior = seen.get(identity); + if (prior) { + throw new Error( + `${entry.type} #${entry.id} appears more than once in state (${prior} and ${kind} "${entry.key}").`, + ); + } + seen.set(identity, `${kind} "${entry.key}"`); + } + } } /** Find a managed entry by CT type + id (id may legitimately be 0). */ @@ -219,6 +336,19 @@ export type UpsertAction = "created" | "updated"; * A key already taken by a *different* resource is a conflict, not an overwrite. */ export function upsert(state: State, input: UpsertInput, now: string): UpsertAction { + const externalCollision = externalResources(state)[input.key]; + if (externalCollision) { + throw new Error( + `Logical key "${input.key}" is already used by external ${externalCollision.type} #${externalCollision.id}. ` + + `Remove or rekey that binding first.`, + ); + } + const externalAlias = findExternalByTypeId(state, input.type, input.id); + if (externalAlias) { + throw new Error( + `${input.type} #${input.id} is already external as "${externalAlias.key}". Remove that binding before adopting it.`, + ); + } const existing = findByTypeId(state, input.type, input.id); const collision = state.resources[input.key]; if (collision && !(collision.type === input.type && collision.id === input.id)) { diff --git a/tests/adopt-group-command.test.ts b/tests/adopt-group-command.test.ts index cc6735f..ab8ccb0 100644 --- a/tests/adopt-group-command.test.ts +++ b/tests/adopt-group-command.test.ts @@ -137,7 +137,9 @@ function makeClient(childrenResponse: ChildrenResponse = "array") { m = /^\/groups\/(\d+)\/memberfields$/.exec(path); if (m) return memberFields[Number(m[1])] ?? []; if (path === "/group/grouptypes") return groupTypes; + if (path === "/group/grouptypes/5") return groupTypes[0]; if (path === "/campuses") return campuses; + if (path === "/campuses/0") return campuses[0]; if (path === "/group/roles") return roles; if (path === "/group/memberstatus") return memberStatuses; m = /^\/dynamicgroups\/(\d+)\/ruleset$/.exec(path); @@ -566,6 +568,20 @@ describe("ct adopt group — idiomatic snippet round-trips to a no-op (#52 item // Load it through the real loader and plan against the state the adopt just wrote. const { resources } = await loadConfig(configPath); const state = await loadState(statePath, HOST); + state.externals!.mainz = { + type: "campus", + id: 0, + key: "mainz", + identity: { name: "Mainz" }, + boundAt: "t", + }; + state.externals!.team = { + type: "group-type", + id: 5, + key: "team", + identity: { name: "Team" }, + boundAt: "t", + }; const { plan } = await buildPlan(client as unknown as Pick, state, resources, { configDir: workDir, }); diff --git a/tests/application/apply-operation.test.ts b/tests/application/apply-operation.test.ts index a69a633..762e6f3 100644 --- a/tests/application/apply-operation.test.ts +++ b/tests/application/apply-operation.test.ts @@ -10,6 +10,7 @@ import { PreparedOperationStore } from "../../src/application/prepared-operation import type { Clock } from "../../src/application/ports.js"; import type { Plan } from "../../src/engine/types.js"; import { emptyState } from "../../src/state/state.js"; +import { ExternalReferenceError } from "../../src/resolve/external.js"; const host = "https://example.church.tools"; const statePath = "/project/ct-state.prod.json"; @@ -93,6 +94,34 @@ async function expectCode(promise: Promise, code: CtApplicationError["c } describe("prepared apply operation", () => { + it("blocks before backup and writes when an external prerequisite diagnostic is raised", async () => { + const test = harness(); + const diagnostic = new ExternalReferenceError({ + reason: "EXTERNAL_BINDING_MISSING", + type: "group", + key: "shared", + site: 'group "consumer".parents', + context: { host, consumer: "consumer", environment: "prod" }, + evidence: ["No persisted external binding exists."], + consequence: "Apply is blocked before writes.", + remediation: [{ command: "ct use group 77 --key shared", description: "Bind the live group." }], + verification: "ct plan --env prod", + }); + test.dependencies.buildPlan = vi.fn(async () => { + throw diagnostic; + }); + + await expect(prepareApply({}, test.dependencies)).rejects.toMatchObject({ + code: "EXTERNAL_REFERENCE_BLOCKED", + details: { + reason: "EXTERNAL_BINDING_MISSING", + remediation: [{ command: "ct use group 77 --key shared" }], + }, + }); + expect(test.backup).not.toHaveBeenCalled(); + expect(test.execute).not.toHaveBeenCalled(); + }); + it("requires the exact protected environment and writes the backup before resources", async () => { const test = harness(); const prepared = await prepareApply({}, test.dependencies); diff --git a/tests/application/destroy-operation.test.ts b/tests/application/destroy-operation.test.ts index a4e1d12..cb32836 100644 --- a/tests/application/destroy-operation.test.ts +++ b/tests/application/destroy-operation.test.ts @@ -62,6 +62,7 @@ function harness() { }; return { dependencies, + state, request, events, changeState(value: string) { @@ -75,6 +76,22 @@ async function expectCode(promise: Promise, code: CtApplicationError["c } describe("prepared destroy operation", () => { + it("cannot target an external binding and performs no ChurchTools read or write", async () => { + const test = harness(); + test.state.externals!.shared = { + type: "group", + id: 77, + key: "shared", + identity: { name: "Shared", groupTypeId: 2 }, + boundAt: "t", + }; + await expect(prepareDestroy({ targets: ["shared"] }, test.dependencies)).rejects.toThrow( + /not managed.*Nothing to destroy/, + ); + expect(test.request).not.toHaveBeenCalled(); + expect(test.dependencies.authedSession).not.toHaveBeenCalled(); + }); + it("exposes the exact proposal and requires the protected environment before deleting", async () => { const test = harness(); const prepared = await prepareDestroy({ targets: ["area"] }, test.dependencies); diff --git a/tests/application/ownership-operation.test.ts b/tests/application/ownership-operation.test.ts new file mode 100644 index 0000000..ff366b5 --- /dev/null +++ b/tests/application/ownership-operation.test.ts @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { checkOwnership } from "../../src/application/operations/ownership.js"; +import { RESOURCES } from "../../src/resources/registry.js"; + +const host = "https://example.church.tools"; +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +async function project( + root: string, + name: string, + state: { resources?: Record; externals?: Record }, +): Promise { + const directory = join(root, name); + await mkdir(directory, { recursive: true }); + await writeFile( + join(directory, "ct.envs.json"), + JSON.stringify({ environments: { prod: { host, state: "ct-state.prod.json" } } }), + ); + await writeFile( + join(directory, "ct-state.prod.json"), + JSON.stringify({ version: 2, host, resources: state.resources ?? {}, externals: state.externals ?? {} }), + ); +} + +function managed(key: string, id: number) { + return { + type: "group", + key, + id, + fields: { name: "Shared", groupTypeId: 2 }, + adoptedAt: "t", + updatedAt: "t", + }; +} + +function external(key: string, id: number, owner?: string) { + return { + type: "group", + key, + id, + ...(owner ? { owner } : {}), + identity: { name: "Shared", groupTypeId: 2 }, + boundAt: "t", + }; +} + +describe("checkOwnership", () => { + it("analyses every registry type through the same generic identity contract", async () => { + const root = await mkdtemp(join(tmpdir(), "ct-ownership-")); + roots.push(root); + const ownerResources: Record = {}; + const consumerExternals: Record = {}; + let id = 1; + for (const [type, spec] of Object.entries(RESOURCES)) { + const key = type.replaceAll("-", "_"); + const fields = { name: `Shared ${type}`, groupTypeId: 2 }; + ownerResources[key] = { + type, + key, + id, + fields, + adoptedAt: "t", + updatedAt: "t", + }; + consumerExternals[key] = { + type, + key, + id, + owner: "master", + identity: spec.external.identity(fields), + boundAt: "t", + }; + id += 1; + } + await project(root, "master", { resources: ownerResources }); + await project(root, "consumer", { externals: consumerExternals }); + const result = await checkOwnership({ root, environment: "prod" }); + expect(result.value.conflicts).toBe(0); + expect(result.value.findings.filter((finding) => finding.reason === "OWNERSHIP_OK")).toHaveLength( + Object.keys(RESOURCES).length, + ); + }); + + it("reports one visible owner plus read-only consumers as ok", async () => { + const root = await mkdtemp(join(tmpdir(), "ct-ownership-")); + roots.push(root); + await project(root, "master", { resources: { shared: managed("shared", 7) } }); + await project(root, "consumer", { externals: { shared: external("shared", 7, "master") } }); + const result = await checkOwnership({ root, environment: "prod" }); + expect(result.value.conflicts).toBe(0); + expect(result.value.findings).toContainEqual(expect.objectContaining({ reason: "OWNERSHIP_OK" })); + }); + + it("detects duplicate owners and key mismatches with rekey remediation", async () => { + const root = await mkdtemp(join(tmpdir(), "ct-ownership-")); + roots.push(root); + await project(root, "owner-a", { resources: { shared: managed("shared", 7) } }); + await project(root, "owner-b", { resources: { alias: managed("alias", 7) } }); + const result = await checkOwnership({ root, environment: "prod" }); + expect(result.value.findings.map((finding) => finding.reason)).toEqual( + expect.arrayContaining(["DUPLICATE_OWNER", "KEY_MISMATCH"]), + ); + expect( + result.value.findings.find((finding) => finding.reason === "KEY_MISMATCH")?.remediation?.[0], + ).toContain("ct state rekey group alias shared --env prod"); + }); + + it("does not search ignored build or node_modules directories", async () => { + const root = await mkdtemp(join(tmpdir(), "ct-ownership-")); + roots.push(root); + await project(root, "visible", { resources: { shared: managed("shared", 7) } }); + await project(join(root, "node_modules"), "hidden", { resources: { alias: managed("alias", 7) } }); + const result = await checkOwnership({ root, environment: "prod" }); + expect(result.value.projects.map((item) => item.name)).toEqual(["visible"]); + }); +}); diff --git a/tests/application/state-operation.test.ts b/tests/application/state-operation.test.ts index 1587e57..2f8df82 100644 --- a/tests/application/state-operation.test.ts +++ b/tests/application/state-operation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { listState, removeStateEntry } from "../../src/application/operations/state.js"; +import { listState, rekeyStateEntry, removeStateEntry } from "../../src/application/operations/state.js"; import { emptyState } from "../../src/state/state.js"; const host = "https://example.church.tools"; @@ -69,4 +69,63 @@ describe("state operations", () => { expect(saveState).not.toHaveBeenCalled(); expect(state.resources.mainz).toBeDefined(); }); + + it("lists, removes and rekeys external entries through the shared key namespace", async () => { + const state = emptyState(host); + state.externals!.shared = { + type: "group", + id: 7, + key: "shared", + identity: { name: "Shared", groupTypeId: 2 }, + boundAt: "t", + }; + const saveState = vi.fn(); + const dependencies = { + resolveProject: vi.fn(async () => project()), + loadState: vi.fn(async () => state), + loadConfig: vi.fn(async () => ({ resources: [], permissions: [], configDir: "/project" })), + saveState, + }; + const listed = await listState({}, dependencies); + expect(listed.value.entries).toContainEqual( + expect.objectContaining({ kind: "external", ownership: "read-only" }), + ); + const rekeyed = await rekeyStateEntry( + { type: "group", oldKey: "shared", newKey: "shared_group" }, + dependencies, + ); + expect(rekeyed.value.kind).toBe("external"); + expect(state.externals?.shared_group?.key).toBe("shared_group"); + const removed = await removeStateEntry({ type: "group", key: "shared_group" }, dependencies); + expect(removed.value.kind).toBe("external"); + expect(state.externals?.shared_group).toBeUndefined(); + }); + + it("rejects rekey collisions across managed and external entries", async () => { + const state = emptyState(host); + state.resources.owned = { + type: "campus", + id: 0, + key: "owned", + fields: {}, + adoptedAt: "t", + updatedAt: "t", + }; + state.externals!.shared = { + type: "group", + id: 7, + key: "shared", + identity: { name: "Shared", groupTypeId: 2 }, + boundAt: "t", + }; + await expect( + rekeyStateEntry( + { type: "group", oldKey: "shared", newKey: "owned" }, + { + resolveProject: vi.fn(async () => project()), + loadState: vi.fn(async () => state), + }, + ), + ).rejects.toThrow(/unique across managed and external/); + }); }); diff --git a/tests/application/use-operation.test.ts b/tests/application/use-operation.test.ts new file mode 100644 index 0000000..5b02eae --- /dev/null +++ b/tests/application/use-operation.test.ts @@ -0,0 +1,200 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + discoverExternalCandidates, + inspectExternalCandidate, + runUseResource, + type UseOperationDependencies, +} from "../../src/application/operations/use.js"; +import type { CtApplicationError } from "../../src/application/errors.js"; +import { emptyState } from "../../src/state/state.js"; + +const host = "https://example.church.tools"; +const dirs: string[] = []; + +afterEach(async () => { + await Promise.all(dirs.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +function project(directory = "/project") { + return { + cwd: directory, + configPath: join(directory, "ct.config.ts"), + statePath: join(directory, "state.json"), + environmentsPath: join(directory, "ct.envs.json"), + configDisplayPath: "ct.config.ts", + stateDisplayPath: "state.json", + environment: "prod", + protected: false, + host, + }; +} + +function dependencies(state = emptyState(host), row: Record = { id: 7, name: "Mainz" }) { + const saveState = vi.fn(); + const client = { + get: vi.fn<(path?: string) => Promise>>().mockResolvedValue(row), + getAll: vi.fn(async () => ({ data: [row] })), + }; + return { + state, + saveState, + client, + value: { + resolveProject: vi.fn(async () => project()), + loadState: vi.fn(async () => state), + saveState, + authedSession: vi.fn(async () => ({ client, me: { id: 1 } })) as never, + clock: { now: () => new Date("2026-08-27T12:00:00.000Z") }, + } satisfies UseOperationDependencies, + }; +} + +describe("runUseResource", () => { + it("creates a read-only binding and leaves managed resources untouched", async () => { + const deps = dependencies(); + const result = await runUseResource({ type: "campus", id: 7, key: "mainz", owner: "master" }, deps.value); + expect(result.value).toMatchObject({ action: "created", written: true, churchToolsWritten: false }); + expect(deps.state.resources).toEqual({}); + expect(deps.state.externals?.mainz).toEqual({ + type: "campus", + key: "mainz", + id: 7, + owner: "master", + identity: { name: "Mainz" }, + boundAt: "2026-08-27T12:00:00.000Z", + }); + }); + + it("is byte-stable for an unchanged binding and does not update boundAt", async () => { + const directory = await mkdtemp(join(tmpdir(), "ct-use-idempotent-")); + dirs.push(directory); + const client = { get: vi.fn(async () => ({ id: 7, name: "Mainz", shorty: "MZ" })) }; + const deps: UseOperationDependencies = { + resolveProject: vi.fn(async () => project(directory)), + authedSession: vi.fn(async () => ({ client, me: { id: 1 } })) as never, + clock: { now: () => new Date("2026-08-27T12:00:00.000Z") }, + }; + await runUseResource({ type: "campus", id: 7, key: "mainz" }, deps); + const before = await readFile(join(directory, "state.json"), "utf8"); + deps.clock = { now: () => new Date("2026-08-28T12:00:00.000Z") }; + const second = await runUseResource({ type: "campus", id: 7, key: "mainz" }, deps); + const after = await readFile(join(directory, "state.json"), "utf8"); + expect(second.value.action).toBe("no-op"); + expect(after).toBe(before); + }); + + it("requires explicit confirmation for hard identity changes, but not display-only changes", async () => { + const state = emptyState(host); + state.externals!.team = { + type: "group", + key: "team", + id: 9, + identity: { name: "Team", groupTypeId: 2 }, + boundAt: "t", + }; + const deps = dependencies(state, { + id: 9, + name: "Team renamed", + information: { groupTypeId: 2, campusId: 99, groupStatusId: 4 }, + }); + await expect(runUseResource({ type: "group", id: 9, key: "team" }, deps.value)).rejects.toMatchObject({ + code: "EXTERNAL_CONFIRMATION_REQUIRED", + } satisfies Partial); + expect(deps.saveState).not.toHaveBeenCalled(); + + const accepted = await runUseResource( + { type: "group", id: 9, key: "team", acceptChanges: true }, + deps.value, + ); + expect(accepted.value.action).toBe("identity-updated"); + expect(state.externals!.team!.identity).toEqual({ name: "Team renamed", groupTypeId: 2 }); + }); + + it("rejects managed/external key, id and alias collisions", async () => { + const state = emptyState(host); + state.resources.mainz = { + type: "campus", + key: "mainz", + id: 7, + fields: { name: "Mainz" }, + adoptedAt: "t", + updatedAt: "t", + }; + const deps = dependencies(state); + await expect(runUseResource({ type: "campus", id: 7, key: "mainz" }, deps.value)).rejects.toThrow( + /already managed/, + ); + state.externals!.berlin = { + type: "campus", + key: "berlin", + id: 8, + identity: { name: "Berlin" }, + boundAt: "t", + }; + await expect(runUseResource({ type: "campus", id: 8, key: "other" }, deps.value)).rejects.toThrow( + /already external as "berlin"/, + ); + }); + + it("retains the existing consumer key as the interactive proposal", async () => { + const state = emptyState(host); + state.externals!.carefully_named = { + type: "campus", + key: "carefully_named", + id: 7, + identity: { name: "Mainz" }, + boundAt: "t", + }; + const deps = dependencies(state); + const result = await inspectExternalCandidate({ type: "campus", id: 7 }, deps.value); + expect(result.value.suggestedKey).toBe("carefully_named"); + }); + + it("reports both old and new targets before an explicitly confirmed rebind", async () => { + const state = emptyState(host); + state.externals!.mainz = { + type: "campus", + key: "mainz", + id: 6, + identity: { name: "Old Mainz" }, + boundAt: "t", + }; + const deps = dependencies(state, { id: 7, name: "New Mainz" }); + deps.client.get.mockImplementation(async (path?: string) => + path?.endsWith("/6") ? { id: 6, name: "Old Mainz" } : { id: 7, name: "New Mainz" }, + ); + await expect(runUseResource({ type: "campus", id: 7, key: "mainz" }, deps.value)).rejects.toMatchObject({ + code: "EXTERNAL_CONFIRMATION_REQUIRED", + details: { + action: "rebound", + oldId: 6, + newId: 7, + previousLive: expect.objectContaining({ id: 6, name: "Old Mainz" }), + live: expect.objectContaining({ id: 7, name: "New Mainz" }), + }, + }); + expect(deps.saveState).not.toHaveBeenCalled(); + }); +}); + +describe("discoverExternalCandidates", () => { + it("returns every fuzzy match with registry-defined identity and display data", async () => { + const deps = dependencies(); + deps.client.getAll.mockResolvedValue({ + data: [ + { id: 1, name: "OJAHR Fuzzies", information: { groupTypeId: 4, campusId: 2 } }, + { id: 2, name: "OJAHR Fuzzies Alumni", information: { groupTypeId: 5, campusId: 3 } }, + ], + }); + const result = await discoverExternalCandidates({ type: "group", search: "fuzz" }, deps.value); + expect(result.value.candidates).toHaveLength(2); + expect(result.value.candidates[0]).toMatchObject({ + id: 1, + identity: { name: "OJAHR Fuzzies", groupTypeId: 4 }, + display: { campusId: 2 }, + }); + }); +}); diff --git a/tests/blueprint.test.ts b/tests/blueprint.test.ts index f2690de..407c325 100644 --- a/tests/blueprint.test.ts +++ b/tests/blueprint.test.ts @@ -28,10 +28,12 @@ describe("campus blueprint", () => { } }); - it("rejects a blueprint whose managed parent is undeclared (typo guard)", async () => { + it("retains an undeclared hierarchy key for plan-time external binding validation", async () => { const broken = (ct: ConfigContext) => { ct.group({ key: "g", name: "g", groupTypeId: 2, parents: ["missing"] }); }; - await expect(evaluateConfig(broken)).rejects.toThrow(/not declared/i); + await expect(evaluateConfig(broken)).resolves.toMatchObject({ + resources: [expect.objectContaining({ parents: ["missing"] })], + }); }); }); diff --git a/tests/comment-viewer-resource.test.ts b/tests/comment-viewer-resource.test.ts index 12f2fe5..561be58 100644 --- a/tests/comment-viewer-resource.test.ts +++ b/tests/comment-viewer-resource.test.ts @@ -190,7 +190,12 @@ describe("the same config means the same thing on two hosts (#151)", () => { /** A client whose permission reads are empty and whose viewer catalog is host-specific. */ function mockClient(viewers: { id: number; name: string }[] = [], newId = 555) { const calls: { method: string; path: string; body?: unknown }[] = []; - const get = vi.fn(async (path: string) => (path === PATH ? viewers : [])); + const get = vi.fn(async (path: string) => { + if (path === PATH) return viewers; + const match = /^\/person\/commentviewers\/(\d+)$/.exec(path); + if (match) return viewers.find((viewer) => viewer.id === Number(match[1])); + return []; + }); const request = vi.fn(async (method: string, path: string, body?: unknown) => { calls.push({ method, path, body }); if (method === "POST" && path === PATH) return { id: newId }; @@ -262,13 +267,18 @@ describe("the same config means the same thing on two hosts (#151)", () => { expect(put?.body).toEqual({ authId: 113, type: "grant", dataId: [555] }); }); - it("still falls back to the live catalog for a viewer this config does not own", async () => { - // The compatibility half of the promotion: a name ref against an unmanaged viewer keeps working - // exactly as it did in #102, so no existing config has to change. + it("resolves an explicitly bound external viewer without managing it", async () => { const { client } = mockClient([{ id: 2, name: "Dienstbereich" }]); - const { items, fetchErrors } = await buildPermissionPlan(client, emptyState(HOST), [viewerScoped]); + const state = emptyState(HOST); + state.externals!.dienstbereich = { + type: "comment-viewer", + id: 2, + key: "dienstbereich", + identity: { name: "Dienstbereich" }, + boundAt: "t", + }; + const { items, fetchErrors } = await buildPermissionPlan(client, state, [viewerScoped]); expect(fetchErrors).toEqual([]); - // Catalog-resolved: already host-correct, so no managed identity is carried for re-resolution. expect(items[0]?.diff.toPut).toEqual([{ authId: 113, dataId: [2], type: "grant" }]); }); }); diff --git a/tests/completion.test.ts b/tests/completion.test.ts index baf36c4..2bee897 100644 --- a/tests/completion.test.ts +++ b/tests/completion.test.ts @@ -82,10 +82,10 @@ describe("completion candidates", () => { it("reflects the real command tree, nested commands included", async () => { const program = buildProgram(); expect(await complete(program, "ct ")).toEqual( - expect.arrayContaining(["auth", "state", "plan", "apply", "destroy", "completion"]), + expect.arrayContaining(["auth", "use", "ownership", "state", "plan", "apply", "destroy", "completion"]), ); expect(await complete(program, "ct auth ")).toEqual(expect.arrayContaining(["login", "logout"])); - expect(await complete(program, "ct state ")).toEqual(expect.arrayContaining(["list", "rm"])); + expect(await complete(program, "ct state ")).toEqual(expect.arrayContaining(["list", "rm", "rekey"])); expect(await complete(program, "ct apply -")).toEqual(expect.arrayContaining(["--auto-approve"])); }); }); @@ -185,6 +185,23 @@ describe("dynamic completion", () => { ); }); + it("completes use/rekey types and external state keys from the generic registry/state", async () => { + process.env.CT_STATE = join(dir, "ct-state.json"); + await writeFile( + process.env.CT_STATE, + JSON.stringify({ + version: 2, + host: "https://x.church.tools", + resources: {}, + externals: { shared: { type: "group", id: 7, key: "shared", identity: {}, boundAt: "t" } }, + }), + ); + expect(await complete(buildProgram(), "ct use ")).toEqual( + expect.arrayContaining(["campus", "group", "group-role"]), + ); + expect(await complete(buildProgram(), "ct state rekey group ")).toEqual(["shared"]); + }); + it("completes a path option from the filesystem", async () => { await writeFile(join(dir, "ct.config.ts"), ""); const candidates = await complete(buildProgram(), `ct plan --config ${dir}/ct`); diff --git a/tests/context.test.ts b/tests/context.test.ts index 0bea769..d4c6351 100644 --- a/tests/context.test.ts +++ b/tests/context.test.ts @@ -131,7 +131,7 @@ describe("config context", () => { }); }); - it("rejects a hierarchy parent that is not a declared group", async () => { + it("rejects a declared non-group parent but permits a plan-time external group key", async () => { await expect( evaluateConfig((ct) => { ct.campus({ key: "mz", name: "Mainz" }); @@ -141,9 +141,9 @@ describe("config context", () => { await expect( evaluateConfig((ct) => { - ct.group({ key: "kids", name: "Kids", parents: ["ghost"] }); // never declared + ct.group({ key: "kids", name: "Kids", parents: ["shared_parent"] }); }), - ).rejects.toThrow(/not declared in this config/); + ).resolves.toMatchObject({ resources: [expect.objectContaining({ parents: ["shared_parent"] })] }); }); it("rejects a duplicate logical key", () => { diff --git a/tests/external-registry.test.ts b/tests/external-registry.test.ts new file mode 100644 index 0000000..1dc91e0 --- /dev/null +++ b/tests/external-registry.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { RESOURCES } from "../src/resources/registry.js"; + +describe("generic external registry contract", () => { + it.each(Object.entries(RESOURCES))( + "%s supplies identity, display and one unique ref kind", + (type, spec) => { + const sample = { + id: 7, + name: "Example", + shorty: "EX", + groupTypeId: 2, + information: { groupTypeId: 2, campusId: 3, groupStatusId: 1 }, + sortKey: 4, + degreeNameA: "A", + degreeNameB: "B", + type: "participant", + }; + expect(spec.external.refKind).toBeTruthy(); + expect(spec.external.identity(sample)).toHaveProperty("name", "Example"); + expect(spec.external.display(sample)).toEqual(expect.any(Object)); + expect(spec.collectionPath).toBeTruthy(); + expect(spec.itemPath(7)).toBeTruthy(); + expect(type).toBeTruthy(); + }, + ); + + it("does not maintain two type lists: every registry entry has a distinct logical ref kind", () => { + const kinds = Object.values(RESOURCES).map((spec) => spec.external.refKind); + expect(new Set(kinds).size).toBe(Object.keys(RESOURCES).length); + }); + + it.each([ + ["campus", ["name"], ["shorty"]], + ["group", ["groupTypeId", "name"], ["campusId", "groupStatusId"]], + ["group-type", ["name"], ["nameTranslated"]], + ["age-group", ["name"], ["nameTranslated", "sortKey"]], + ["target-group", ["name"], ["nameTranslated", "sortKey"]], + ["relationship-type", ["name"], ["degreeNameA", "degreeNameB"]], + ["person-status", ["name"], ["isMember", "shorty"]], + ["department", ["name"], ["shorty"]], + ["security-level", ["name"], ["level"]], + ["comment-viewer", ["name"], ["sortKey"]], + ["group-role", ["groupTypeId", "name"], ["type"]], + ] as const)("%s pins the issue-defined hard/display identity boundary", (type, hard, display) => { + const sample = { + id: 7, + name: "Example", + shorty: "EX", + nameTranslated: "Example translated", + groupTypeId: 2, + information: { groupTypeId: 2, campusId: 3, groupStatusId: 1 }, + campusId: 3, + groupStatusId: 1, + sortKey: 4, + degreeNameA: "Parent", + degreeNameB: "Child", + isMember: true, + type: "participant", + }; + expect(Object.keys(RESOURCES[type]!.external.identity(sample)).sort()).toEqual([...hard].sort()); + expect(Object.keys(RESOURCES[type]!.external.display(sample)).sort()).toEqual([...display].sort()); + }); +}); diff --git a/tests/init.test.ts b/tests/init.test.ts index de6871d..ff20a60 100644 --- a/tests/init.test.ts +++ b/tests/init.test.ts @@ -95,9 +95,10 @@ describe("initializeConfigRepository", () => { protected: true, }); await expect(loadState(join(directory, statePath), "https://example.church.tools")).resolves.toEqual({ - version: 1, + version: 2, host: "https://example.church.tools", resources: {}, + externals: {}, }); await expect(access(join(directory, "ct-state.json"))).rejects.toMatchObject({ code: "ENOENT" }); }); diff --git a/tests/permission-pending-domain.test.ts b/tests/permission-pending-domain.test.ts index 0f6996c..ec947d0 100644 --- a/tests/permission-pending-domain.test.ts +++ b/tests/permission-pending-domain.test.ts @@ -334,13 +334,12 @@ describe("group_role symmetry: a same-run group DOES go pending and completes in }); }); -describe("pending domain: a TRUE typo (key absent from config AND state AND catalog) still hard-errors (#69)", () => { - it("throws the resolver's unchanged notFound message — not a pending block", async () => { - // "strucktur" is neither declared, nor in state, nor a live catalog match → genuinely unresolvable. +describe("pending domain: a TRUE typo (key absent from config and both state partitions) still hard-errors (#69)", () => { + it("reports the missing explicit external binding — not a pending block", async () => { const typoPerm: DesiredPermission = { ...strukturPerm, domainId: ref.groupType("strucktur") }; const { client } = mockClient({ "/group/grouptypes": [{ id: STRUKTUR_TYPE_ID, name: "Struktur" }] }); await expect(buildPermissionPlan(client, emptyState(HOST), [typoPerm], strukturType)).rejects.toThrow( - /Cannot resolve group-type:strucktur referenced at group_type_role "struktur_roles".domainId/, + /resource:\s+group-type "strucktur"[\s\S]*neither contains[\s\S]*ct use/, ); }); }); diff --git a/tests/permission-plan.test.ts b/tests/permission-plan.test.ts index 884540b..1e94947 100644 --- a/tests/permission-plan.test.ts +++ b/tests/permission-plan.test.ts @@ -8,12 +8,28 @@ import type { State } from "../src/state/state.js"; const bundledForTest = { ...CATALOG }; const state: State = { - version: 1, + version: 2, host: "h", resources: { kids_area: { type: "group", id: 42, key: "kids_area", fields: {}, adoptedAt: "t", updatedAt: "t" }, other: { type: "group", id: 7, key: "other", fields: {}, adoptedAt: "t", updatedAt: "t" }, }, + externals: { + core: { + type: "person-status", + id: 6, + key: "core", + identity: { name: "5 - Core" }, + boundAt: "t", + }, + group_active: { + type: "person-status", + id: 4, + key: "group_active", + identity: { name: "3 - Group Active" }, + boundAt: "t", + }, + }, }; describe("desiredTuples", () => { @@ -471,11 +487,7 @@ describe("buildPermissionPlan", () => { it("resolves a status domain by person-status name and reconciles the -1 ALL sentinel idempotently", async () => { const client = { get: vi.fn(async (path: string) => { - if (path === "/statuses") - return [ - { id: 4, name: "3 - Group Active" }, - { id: 6, name: "5 - Core" }, - ]; + if (path === "/statuses/6") return { id: 6, name: "5 - Core" }; if (path === "/permissions/status") return [ { @@ -494,13 +506,13 @@ describe("buildPermissionPlan", () => { { key: "core_login", domainType: "status", - domainId: ref.personStatus("5 - Core"), + domainId: ref.personStatus("core"), grants: [{ right: "churchcore:login to external system", scope: [-1] }], }, ]); expect(fetchErrors).toEqual([]); expect(warnings).toEqual([]); - expect(items[0]?.domainId).toBe(6); // resolved from the /statuses catalog + expect(items[0]?.domainId).toBe(6); expect(items[0]?.diff.toPut).toEqual([]); // live -1 row matches the declaration expect(items[0]?.diff.toDelete).toEqual([]); }); @@ -508,7 +520,7 @@ describe("buildPermissionPlan", () => { it("proposes the status grant on a status that does not carry it yet", async () => { const client = { get: vi.fn(async (path: string) => { - if (path === "/statuses") return [{ id: 4, name: "3 - Group Active" }]; + if (path === "/statuses/4") return { id: 4, name: "3 - Group Active" }; if (path === "/permissions/status") return []; throw new Error(`unexpected path ${path}`); }), @@ -517,7 +529,7 @@ describe("buildPermissionPlan", () => { { key: "group_active_login", domainType: "status", - domainId: ref.personStatus("3 - Group Active"), + domainId: ref.personStatus("group_active"), grants: [{ right: "churchcore:login to external system", scope: [-1] }], }, ]); diff --git a/tests/permission-scope-refs.test.ts b/tests/permission-scope-refs.test.ts index d2b97c1..1a270f4 100644 --- a/tests/permission-scope-refs.test.ts +++ b/tests/permission-scope-refs.test.ts @@ -50,10 +50,20 @@ function stateWithKoblenz(id: number): State { }; } +function stateWithExternal(type: string, key: string, id: number, identity: Record): State { + const state = emptyState(HOST); + state.externals![key] = { type, key, id, identity, boundAt: "t" }; + return state; +} + /** A client whose `/permissions/*` reads are empty and whose `/campuses` catalog is host-specific. */ function mockClient(campuses: { id: number; name: string }[] = [], newId = 555) { const calls: { method: string; path: string; body?: unknown }[] = []; - const get = vi.fn(async (path: string) => (path === "/campuses" ? campuses : [])); + const get = vi.fn(async (path: string) => { + if (path === "/campuses") return campuses; + const id = /^\/campuses\/(\d+)$/.exec(path)?.[1]; + return id ? campuses.find((campus) => campus.id === Number(id)) : []; + }); const request = vi.fn(async (method: string, path: string, body?: unknown) => { calls.push({ method, path, body }); if (method === "POST" && path === "/campuses") return { id: newId }; @@ -114,11 +124,10 @@ describe("campus-scoped grants are portable across hosts (#98)", () => { } }); - it("falls back to the live /campuses catalog for a campus this config does not manage", async () => { - // Not in state → resolved by name against the host's catalog. The id is already host-correct, so - // there is no managed identity to re-resolve at apply time and the tuple keeps no scopeKey. + it("resolves a read-only campus only through its explicit external binding", async () => { const { client } = mockClient([{ id: 23, name: "Koblenz" }]); - const tuples = await tuplesFor(campusScoped, emptyState(HOST), [], client); + const state = stateWithExternal("campus", "koblenz", 23, { name: "Koblenz" }); + const tuples = await tuplesFor(campusScoped, state, [], client); expect(tuples).toEqual([{ authId: 124, dataId: [23], type: "grant" }]); }); @@ -243,7 +252,9 @@ describe("scope-dimension validation (#98)", () => { domainId: 1, grants: [{ right: VIEW_STATION, scope: [ref.campus("nowhere")] }], }; - await expect(tuplesFor(perm, emptyState(HOST))).rejects.toThrow(/Cannot resolve campus:nowhere/); + await expect(tuplesFor(perm, emptyState(HOST))).rejects.toMatchObject({ + details: { reason: "EXTERNAL_BINDING_MISSING", type: "campus", key: "nowhere" }, + }); }); it("still accepts a numeric dataId on any dimension (the #49 escape hatch is untouched)", async () => { @@ -296,10 +307,9 @@ describe("department scopes are a read-but-not-managed ref catalog (cdb_bereich, grants: [{ right: "churchdb:view alldata", scope: [{ department: key }] }], }); - it("resolves a department by name against the live catalog", async () => { - // No scopeKey: a catalog-resolved id is already host-correct and has no managed identity to - // re-resolve at apply time — it behaves exactly like the numeric escape hatch from there on. - expect(await tuplesFor(perm("equippers_koblenz"), emptyState(HOST), [], client)).toEqual([ + it("resolves a department through an explicit external binding", async () => { + const state = stateWithExternal("department", "equippers_koblenz", 7, { name: "Equippers Koblenz" }); + expect(await tuplesFor(perm("equippers_koblenz"), state, [], client)).toEqual([ { authId: 102, dataId: [7], type: "grant" }, ]); }); @@ -307,27 +317,21 @@ describe("department scopes are a read-but-not-managed ref catalog (cdb_bereich, it("hard-errors on an unknown department, and NOW advises declaring it (#108)", async () => { // Before #108 this said departments could not be declared or adopted. They can: `ct.department` // creates one through the legacy master-data endpoint, so the generic advice is correct again. - await expect(tuplesFor(perm("nope"), emptyState(HOST), [], client)).rejects.toThrow( - /no managed resource and no live department at \/departments matches key "nope".*Declare\/adopt it/s, - ); - }); - - it("is never treated as a managed resource, even if a same-keyed resource is in state", async () => { - const state: State = { - version: 1, - host: HOST, - resources: { - equippers_koblenz: { - type: "group", - id: 999, - key: "equippers_koblenz", - fields: {}, - adoptedAt: "t", - updatedAt: "t", - }, - }, + await expect(tuplesFor(perm("nope"), emptyState(HOST), [], client)).rejects.toMatchObject({ + details: { reason: "EXTERNAL_BINDING_MISSING", type: "department", key: "nope" }, + }); + }); + + it("is not shadowed by an unrelated managed resource under another key", async () => { + const state = stateWithExternal("department", "equippers_koblenz", 7, { name: "Equippers Koblenz" }); + state.resources.some_group = { + type: "group", + id: 999, + key: "some_group", + fields: {}, + adoptedAt: "t", + updatedAt: "t", }; - // The group must not shadow the department catalog — that would be the misgrant #98 is about. expect(await tuplesFor(perm("equippers_koblenz"), state, [], client)).toEqual([ { authId: 102, dataId: [7], type: "grant" }, ]); @@ -344,7 +348,11 @@ describe("security-level scopes resolve by name (cc_securitylevel, #110)", () => { id: 3, name: "Stufe 3 (Hoch)", sortKey: 3 }, ]; const client = { - get: vi.fn(async (path: string) => (path === "/securitylevels" ? levels : [])), + get: vi.fn(async (path: string) => { + if (path === "/securitylevels") return levels; + const id = /^\/securitylevels\/(\d+)$/.exec(path)?.[1]; + return id ? levels.find((level) => level.id === Number(id)) : []; + }), } as unknown as CtClient; const perm = (scope: unknown[]): DesiredPermission => ({ @@ -354,9 +362,9 @@ describe("security-level scopes resolve by name (cc_securitylevel, #110)", () => grants: [{ right: "churchdb:security level person", scope: scope as never }], }); - it("resolves a level by its slugged name against the live catalog", async () => { - // No scopeKey: catalog-resolved ids are host-correct already and have no managed identity. - expect(await tuplesFor(perm([{ securityLevel: "stufe_3_hoch" }]), emptyState(HOST), [], client)).toEqual([ + it("resolves a level through its explicit external binding", async () => { + const state = stateWithExternal("security-level", "stufe_3_hoch", 3, { name: "Stufe 3 (Hoch)" }); + expect(await tuplesFor(perm([{ securityLevel: "stufe_3_hoch" }]), state, [], client)).toEqual([ { authId: 125, dataId: [3], type: "grant" }, ]); }); @@ -372,7 +380,7 @@ describe("security-level scopes resolve by name (cc_securitylevel, #110)", () => it("hard-errors on a level name this host does not have, instead of granting the wrong one", async () => { await expect( tuplesFor(perm([{ securityLevel: "stufe_9" }]), emptyState(HOST), [], client), - ).rejects.toThrow(/no live security-level at \/securitylevels matches key "stufe_9"/); + ).rejects.toMatchObject({ details: { reason: "EXTERNAL_BINDING_MISSING", key: "stufe_9" } }); }); }); @@ -386,7 +394,11 @@ describe("comment-viewer scopes resolve by name (cdb_comment_viewer, #102)", () { id: 2, name: "Admins", sortKey: 2 }, ]; const client = { - get: vi.fn(async (path: string) => (path === "/person/commentviewers" ? viewers : [])), + get: vi.fn(async (path: string) => { + if (path === "/person/commentviewers") return viewers; + const id = /^\/person\/commentviewers\/(\d+)$/.exec(path)?.[1]; + return id ? viewers.find((viewer) => viewer.id === Number(id)) : []; + }), } as unknown as CtClient; const perm = (scope: unknown[]): DesiredPermission => ({ @@ -397,23 +409,25 @@ describe("comment-viewer scopes resolve by name (cdb_comment_viewer, #102)", () }); it("resolves a viewer by name", async () => { - expect( - await tuplesFor(perm([{ commentViewer: "gemeindeleitung" }]), emptyState(HOST), [], client), - ).toEqual([{ authId: 113, dataId: [1], type: "grant" }]); + const state = stateWithExternal("comment-viewer", "gemeindeleitung", 1, { name: "Gemeindeleitung" }); + expect(await tuplesFor(perm([{ commentViewer: "gemeindeleitung" }]), state, [], client)).toEqual([ + { authId: 113, dataId: [1], type: "grant" }, + ]); }); it("resolves the id-0 row — a falsy id must not read as 'not found'", async () => { // "Alle" is id 0 on a real instance. Anything treating 0 as missing would silently drop the scope // (or worse, fall through to a different row), so this is pinned deliberately. - expect(await tuplesFor(perm([{ commentViewer: "alle" }]), emptyState(HOST), [], client)).toEqual([ + const state = stateWithExternal("comment-viewer", "alle", 0, { name: "Alle" }); + expect(await tuplesFor(perm([{ commentViewer: "alle" }]), state, [], client)).toEqual([ { authId: 113, dataId: [0], type: "grant" }, ]); }); it("hard-errors on a viewer name this host does not have", async () => { - await expect(tuplesFor(perm([{ commentViewer: "nope" }]), emptyState(HOST), [], client)).rejects.toThrow( - /no live comment-viewer at \/person\/commentviewers matches key "nope"/, - ); + await expect( + tuplesFor(perm([{ commentViewer: "nope" }]), emptyState(HOST), [], client), + ).rejects.toMatchObject({ details: { reason: "EXTERNAL_BINDING_MISSING", key: "nope" } }); }); }); diff --git a/tests/person-status-resource.test.ts b/tests/person-status-resource.test.ts index d0bfb78..aebc1dc 100644 --- a/tests/person-status-resource.test.ts +++ b/tests/person-status-resource.test.ts @@ -116,7 +116,11 @@ describe("ct.personStatus in the config DSL", () => { }); describe("resolving a personStatus reference", () => { - const client = { get: vi.fn(async () => [{ id: 3, name: "3 - Group Active" }]) }; + const client = { + get: vi.fn(async (path: string) => + path === "/statuses/3" ? { id: 3, name: "3 - Group Active" } : [{ id: 3, name: "3 - Group Active" }], + ), + }; it("prefers a MANAGED status in state over the live /statuses catalog", async () => { const state: State = { @@ -137,8 +141,16 @@ describe("resolving a personStatus reference", () => { expect(await resolver.resolve(ref.personStatus("group_active"), "site")).toBe(8); }); - it("still falls back to the /statuses catalog for a status this config does not own", async () => { - const resolver = new Resolver({ client: client as never, state: emptyState(HOST), desired: [] }); + it("resolves an explicitly bound external status", async () => { + const state = emptyState(HOST); + state.externals!["3_group_active"] = { + type: "person-status", + id: 3, + key: "3_group_active", + identity: { name: "3 - Group Active" }, + boundAt: "t", + }; + const resolver = new Resolver({ client: client as never, state, desired: [] }); expect(await resolver.resolve(ref.personStatus("3_group_active"), "site")).toBe(3); }); }); diff --git a/tests/plan-partial-honesty.test.ts b/tests/plan-partial-honesty.test.ts index 8bc943f..98296c1 100644 --- a/tests/plan-partial-honesty.test.ts +++ b/tests/plan-partial-honesty.test.ts @@ -95,12 +95,20 @@ describe("plan degradation under 429 (#126)", () => { adoptedAt: "t", updatedAt: "t", }; + state.externals!.parent = { + type: "group", + id: 20, + key: "parent", + identity: { name: "Parent" }, + boundAt: "t", + }; const desired: DesiredResource[] = [ { type: "group", key: "child", fields: { name: "Child" }, dependsOn: [], parents: ["parent"] }, ]; const client = { get: async (path: string): Promise => { if (path === "/groups/10") return { name: "Child" } as T; + if (path === "/groups/20") return { name: "Parent" } as T; throw new CtApiError("GET /groups/hierarchies failed (HTTP 429)", 429, null); }, }; diff --git a/tests/portable-refs.test.ts b/tests/portable-refs.test.ts index a38fb85..25b162a 100644 --- a/tests/portable-refs.test.ts +++ b/tests/portable-refs.test.ts @@ -36,12 +36,20 @@ function fakeHost(catalogs: Record, postIds: Record => {}; describe("buildPlan reference resolution", () => { - it("resolves a catalog groupType ref to a number so the diff stays number↔number", async () => { + it("resolves a bound external groupType ref to a number so the diff stays number↔number", async () => { const { resources } = await evaluateConfig((ct) => { ct.group({ key: "kids", name: "Kids", groupType: "ministry_team" }); }); - const client = fakeHost({ "/group/grouptypes": [{ id: 2, name: "Ministry Team" }] }); - const { plan } = await buildPlan(client, emptyState("h"), resources); + const state = emptyState("h"); + state.externals!.ministry_team = { + type: "group-type", + key: "ministry_team", + id: 2, + identity: { name: "Ministry Team" }, + boundAt: "t", + }; + const client = fakeHost({ "/group/grouptypes/2": { id: 2, name: "Ministry Team" } }); + const { plan } = await buildPlan(client, state, resources); const item = plan.items.find((i) => i.key === "kids")!; expect(item.action).toBe("create"); expect(item.changes).toContainEqual({ field: "groupTypeId", from: undefined, to: 2, source: "config" }); @@ -63,9 +71,9 @@ describe("buildPlan reference resolution", () => { ct.group({ key: "kids", name: "Kids", groupType: "ghost_type" }); }); const client = fakeHost({ "/group/grouptypes": [{ id: 2, name: "Ministry Team" }] }); - await expect(buildPlan(client, emptyState("h"), resources)).rejects.toThrow( - /Cannot resolve group-type:ghost_type referenced at group "kids"/, - ); + await expect(buildPlan(client, emptyState("h"), resources)).rejects.toMatchObject({ + details: { reason: "EXTERNAL_BINDING_MISSING", type: "group-type", key: "ghost_type" }, + }); }); }); @@ -175,12 +183,20 @@ describe("permission domainId resolution", () => { }); const client = { get: async (path: string): Promise => { - if (path === "/group/grouptypes") return [{ id: 9, name: "Ministry Team" }] as T; + if (path === "/group/grouptypes/9") return { id: 9, name: "Ministry Team" } as T; if (path === "/permissions/group_type_role") return [] as T; throw new CtApiError(`not found: ${path}`, 404, null); }, }; - const { items } = await buildPermissionPlan(client, emptyState("h"), permissions); + const state = emptyState("h"); + state.externals!.ministry_team = { + type: "group-type", + key: "ministry_team", + id: 9, + identity: { name: "Ministry Team" }, + boundAt: "t", + }; + const { items } = await buildPermissionPlan(client, state, permissions); expect(items).toHaveLength(1); expect(items[0]?.domainId).toBe(9); // resolved from the catalog, not a raw number }); @@ -192,12 +208,20 @@ describe("permission domainId resolution", () => { }); const client = { get: async (path: string): Promise => { - if (path === "/group/grouptypes") return [{ id: 9, name: "Ministry Team" }] as T; + if (path === "/group/grouptypes/9") return { id: 9, name: "Ministry Team" } as T; if (path === "/permissions/group_type_role") return [] as T; throw new CtApiError(`not found: ${path}`, 404, null); }, }; - await expect(buildPermissionPlan(client, emptyState("h"), permissions)).rejects.toThrow( + const state = emptyState("h"); + state.externals!.ministry_team = { + type: "group-type", + key: "ministry_team", + id: 9, + identity: { name: "Ministry Team" }, + boundAt: "t", + }; + await expect(buildPermissionPlan(client, state, permissions)).rejects.toThrow( /Duplicate permission target after resolution: group_type_role #9/, ); }); @@ -258,9 +282,16 @@ describe("acceptance: one config, two hosts", () => { async function planFor(groupTypeId: number, state: State) { const { resources, permissions } = await evaluateConfig(config); const catalogs = { - "/group/grouptypes": [{ id: groupTypeId, name: "Ministry Team" }], + [`/group/grouptypes/${groupTypeId}`]: { id: groupTypeId, name: "Ministry Team" }, "/permissions/group_type_role": [], }; + state.externals!.ministry_team = { + type: "group-type", + key: "ministry_team", + id: groupTypeId, + identity: { name: "Ministry Team" }, + boundAt: "t", + }; const client = fakeHost(catalogs); const resolver = new Resolver({ client, state, desired: resources, host: state.host }); const { plan } = await buildPlan(client, state, resources, { resolver }); @@ -323,7 +354,7 @@ describe("portable ruleset snapshot files (#76)", () => { }; writeFileSync(join(dir, rulesetFile), JSON.stringify(authoredRuleset)); - /** State with only the (already-managed) dynamic group — the campus resolves from the live `/campuses` catalog. */ + /** State with the managed dynamic group plus this host's explicit external campus binding. */ function stateWithGroup(host: string): State { const s = emptyState(host); s.resources.all_mainz = { @@ -334,6 +365,13 @@ describe("portable ruleset snapshot files (#76)", () => { adoptedAt: "t", updatedAt: "t", }; + s.externals!.mainz = { + type: "campus", + id: host.includes("dev") ? 42 : 7, + key: "mainz", + identity: { name: "Mainz" }, + boundAt: "t", + }; return s; } @@ -362,7 +400,7 @@ describe("portable ruleset snapshot files (#76)", () => { // group) so the manual ruleset is a create → a PUT on apply, whose body we can inspect. const client = fakeHost({ "/groups/100": { name: "All", groupTypeId: 1 }, - "/campuses": [{ id: campusId, name: "Mainz" }], + [`/campuses/${campusId}`]: { id: campusId, name: "Mainz" }, }); const { plan } = await buildPlan(client, state, resources, { configDir: dir }); await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: () => "t" }); @@ -393,7 +431,7 @@ describe("portable ruleset snapshot files (#76)", () => { }; const client = fakeHost({ "/groups/100": { name: "All", groupTypeId: 1 }, - "/campuses": [{ id: campusId, name: "Mainz" }], + [`/campuses/${campusId}`]: { id: campusId, name: "Mainz" }, "/dynamicgroups/100/ruleset": [liveRuleset], "/dynamicgroups/100/status": { dynamicGroupStatus: "manual" }, }); diff --git a/tests/resolver.test.ts b/tests/resolver.test.ts index 16b3659..1df4f43 100644 --- a/tests/resolver.test.ts +++ b/tests/resolver.test.ts @@ -22,6 +22,17 @@ function stateWith(resources: State["resources"]): State { return { ...emptyState("https://x.church.tools"), resources }; } +function stateWithExternals( + externals: NonNullable, + host = "https://x.church.tools", +): State { + return { ...emptyState(host), externals }; +} + +function external(type: string, key: string, id: number, identity: Record) { + return { type, key, id, identity, boundAt: "t" }; +} + const NO_DESIRED: DesiredResource[] = []; describe("Resolver.resolve", () => { @@ -35,39 +46,85 @@ describe("Resolver.resolve", () => { expect(client.calls).toEqual({}); // state hit, no /campuses fetch }); - it("resolves a campus from the live catalog by slug(name)", async () => { - const client = fakeClient({ - "/campuses": [ - { id: 3, name: "Berlin", shorty: "BE" }, - { id: 5, name: "Mainz" }, - ], - }); - const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED }); + it("resolves an external campus only after validating its bound id live", async () => { + const client = fakeClient({ "/campuses/5": { id: 5, name: "Mainz", shorty: "MZ" } }); + const state = stateWithExternals({ mainz: external("campus", "mainz", 5, { name: "Mainz" }) }); + const r = new Resolver({ client, state, desired: NO_DESIRED }); expect(await r.resolve(ref.campus("mainz"), "site")).toBe(5); + expect(client.calls).toEqual({ "/campuses/5": 1 }); }); - it("resolves a group type from the live catalog", async () => { - const client = fakeClient({ "/group/grouptypes": [{ id: 2, name: "Ministry Team" }] }); - const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED }); + it("resolves an external group type", async () => { + const client = fakeClient({ "/group/grouptypes/2": { id: 2, name: "Ministry Team" } }); + const state = stateWithExternals({ + ministry_team: external("group-type", "ministry_team", 2, { name: "Ministry Team" }), + }); + const r = new Resolver({ client, state, desired: NO_DESIRED }); expect(await r.resolve(ref.groupType("ministry_team"), "site")).toBe(2); }); + it("blocks a changed hard identity with a field diff and acceptance command", async () => { + const client = fakeClient({ + "/groups/9": { id: 9, name: "Renamed", information: { groupTypeId: 3, campusId: 99 } }, + }); + const state = stateWithExternals({ + team: external("group", "team", 9, { name: "Team", groupTypeId: 2 }), + }); + const r = new Resolver({ client, state, desired: NO_DESIRED, host: "hostA" }); + await expect(r.resolve(ref.group("team"), "ruleset")).rejects.toMatchObject({ + details: { + reason: "EXTERNAL_IDENTITY_MISMATCH", + identityDiff: expect.arrayContaining([ + expect.objectContaining({ field: "name", expected: "Team", actual: "Renamed" }), + expect.objectContaining({ field: "groupTypeId", expected: 2, actual: 3 }), + ]), + }, + message: expect.stringContaining("ct use group 9 --key team"), + }); + }); + + it("ignores display-only changes while validating a bound external", async () => { + const client = fakeClient({ + "/groups/9": { id: 9, name: "Team", information: { groupTypeId: 2, campusId: 99, groupStatusId: 4 } }, + }); + const state = stateWithExternals({ + team: external("group", "team", 9, { name: "Team", groupTypeId: 2 }), + }); + const r = new Resolver({ client, state, desired: NO_DESIRED }); + await expect(r.resolve(ref.group("team"), "ruleset")).resolves.toBe(9); + }); + + it("blocks a stale external id and directs repair to the owner, not ct use", async () => { + const client = fakeClient({}); + const state = stateWithExternals({ + team: { ...external("group", "team", 9, { name: "Team", groupTypeId: 2 }), owner: "master" }, + }); + const r = new Resolver({ client, state, desired: NO_DESIRED }); + await expect(r.resolve(ref.group("team"), "ruleset")).rejects.toMatchObject({ + details: { reason: "EXTERNAL_BINDING_STALE" }, + message: expect.not.stringContaining("ct use group 9"), + }); + }); + // PERSON statuses DO have a flat catalog (`GET /statuses`), unlike GROUP statuses in the test below (#90). - it("resolves a person status from the /statuses catalog by slug(name)", async () => { + it("resolves externally bound person statuses, including id 0", async () => { const client = fakeClient({ - "/statuses": [ - { id: 0, name: "Unbekannt" }, - { id: 4, name: "3 - Group Active" }, - { id: 6, name: "5 - Core" }, - ], + "/statuses/0": { id: 0, name: "Unbekannt" }, + "/statuses/4": { id: 4, name: "3 - Group Active" }, + "/statuses/6": { id: 6, name: "5 - Core" }, }); - const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED }); + const state = stateWithExternals({ + unbekannt: external("person-status", "unbekannt", 0, { name: "Unbekannt" }), + "3_group_active": external("person-status", "3_group_active", 4, { name: "3 - Group Active" }), + "5 - Core": external("person-status", "5 - Core", 6, { name: "5 - Core" }), + }); + const r = new Resolver({ client, state, desired: NO_DESIRED }); expect(await r.resolve(ref.personStatus("3_group_active"), "site")).toBe(4); // Exact-name fallback, for a name that does not survive slugging cleanly. expect(await r.resolve(ref.personStatus("5 - Core"), "site")).toBe(6); // Status id 0 must come back as 0, not be mistaken for "unresolved". expect(await r.resolve(ref.personStatus("unbekannt"), "site")).toBe(0); - expect(client.calls["/statuses"]).toBe(1); // one fetch, memoized across all three + expect(client.calls["/statuses/0"]).toBe(1); }); it("errors on a person-status ref with no catalog match", async () => { @@ -125,17 +182,19 @@ describe("Resolver.resolve", () => { ], }); const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED, host: "hostA" }); - await expect(r.resolve(ref.campus("mainz"), 'group "g"')).rejects.toThrow( - /Ambiguous campus:mainz referenced at group "g" on hostA: 2 live campuss match/, - ); + await expect(r.resolve(ref.campus("mainz"), 'group "g"')).rejects.toMatchObject({ + details: { reason: "EXTERNAL_BINDING_AMBIGUOUS" }, + message: expect.stringContaining("ct use campus 2 --key mainz"), + }); }); it("throws a clear error on an unknown reference (kind + key + site + host)", async () => { const client = fakeClient({ "/campuses": [{ id: 1, name: "Berlin" }] }); const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED, host: "hostB" }); - await expect(r.resolve(ref.campus("mainz"), 'group "g".campusId')).rejects.toThrow( - /Cannot resolve campus:mainz referenced at group "g".campusId on hostB/, - ); + await expect(r.resolve(ref.campus("mainz"), 'group "g".campusId')).rejects.toMatchObject({ + details: { reason: "EXTERNAL_BINDING_MISSING" }, + message: expect.stringContaining('resource: campus "mainz"'), + }); }); it("resolves a group_role (group, role) pair to the pairing domainId via the group's role list (#25)", async () => { @@ -166,12 +225,12 @@ describe("Resolver.resolve", () => { ); }); - it("errors when a group_role names a group that isn't managed", async () => { - const client = fakeClient({}); + it("errors when a group_role names a group without an external binding", async () => { + const client = fakeClient({ "/groups": [] }); const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED }); - await expect(r.resolve(ref.groupRole("ghost", "Leiter"), 'perm "p"')).rejects.toThrow( - /no managed group named "ghost".*pass a numeric id/is, - ); + await expect(r.resolve(ref.groupRole("ghost", "Leiter"), 'perm "p"')).rejects.toMatchObject({ + details: { reason: "EXTERNAL_BINDING_MISSING", type: "group", key: "ghost" }, + }); }); it("errors when a group_role names a same-run-declared (not-yet-created) group", async () => { @@ -183,17 +242,21 @@ describe("Resolver.resolve", () => { ); }); - it("errors on a group ref with no managed match (groups have no catalog)", async () => { - const client = fakeClient({}); + it("errors on an unbound group ref without consuming discovery", async () => { + const client = fakeClient({ "/groups": [] }); const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED }); - await expect(r.resolve(ref.group("ghost"), "site")).rejects.toThrow(/no managed group named "ghost"/); + await expect(r.resolve(ref.group("ghost"), "site")).rejects.toMatchObject({ + details: { reason: "EXTERNAL_BINDING_MISSING" }, + }); }); - it("falls back to an exact-name secondary match when the slug misses", async () => { + it("reports an exact-name discovery match but still requires ct use", async () => { const client = fakeClient({ "/group/grouptypes": [{ id: 8, name: "K-9" }] }); const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED }); - // slug("K-9") === "k_9", so ref.groupType("k_9") hits the slug path; "K-9" hits the exact path. - expect(await r.resolve(ref.groupType("K-9"), "site")).toBe(8); + await expect(r.resolve(ref.groupType("K-9"), "site")).rejects.toMatchObject({ + details: { reason: "EXTERNAL_BINDING_MISSING" }, + message: expect.stringContaining("ct use group-type 8 --key K-9"), + }); }); }); @@ -213,8 +276,16 @@ describe("Resolver.resolve — group-type-role (groupTypeRoleId, #76)", () => { ]; it("resolves a (group-type, role) pair to its groupTypeRoleId, disambiguating same-named roles", async () => { - const client = fakeClient({ "/group/grouptypes": groupTypesCatalog, "/group/roles": rolesCatalog }); - const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED }); + const client = fakeClient({ + "/group/grouptypes/12": groupTypesCatalog[0], + "/group/grouptypes/2": groupTypesCatalog[1], + "/group/roles": rolesCatalog, + }); + const state = stateWithExternals({ + local_lead: external("group-type", "local_lead", 12, { name: "Local Lead" }), + team: external("group-type", "team", 2, { name: "Team" }), + }); + const r = new Resolver({ client, state, desired: NO_DESIRED }); // Same role NAME ("Leiter"), different group type → different id: the pair disambiguates. expect(await r.resolve(ref.groupTypeRole("local_lead", "Leiter"), "site")).toBe(84); expect(await r.resolve(ref.groupTypeRole("team", "Leiter"), "site")).toBe(16); @@ -241,8 +312,9 @@ describe("Resolver.resolve — group-type-role (groupTypeRoleId, #76)", () => { }); it("errors clearly when no role of that name exists on the group type (lists candidates)", async () => { - const client = fakeClient({ "/group/grouptypes": groupTypesCatalog, "/group/roles": rolesCatalog }); - const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED, host: "hostA" }); + const client = fakeClient({ "/group/grouptypes/2": groupTypesCatalog[1], "/group/roles": rolesCatalog }); + const state = stateWithExternals({ team: external("group-type", "team", 2, { name: "Team" }) }); + const r = new Resolver({ client, state, desired: NO_DESIRED, host: "hostA" }); await expect(r.resolve(ref.groupTypeRole("team", "Ghost"), 'ruleset "r"')).rejects.toThrow( /group-type-role\(groupType=team, role=Ghost\) referenced at ruleset "r" on hostA: group type #2 has no role named "Ghost".*available: "Leiter", "Organisator".*pass a numeric id/is, ); @@ -250,13 +322,16 @@ describe("Resolver.resolve — group-type-role (groupTypeRoleId, #76)", () => { it("errors listing candidates when two roles on the same group type share the name (ambiguous)", async () => { const client = fakeClient({ - "/group/grouptypes": groupTypesCatalog, + "/group/grouptypes/12": groupTypesCatalog[0], "/group/roles": [ { id: 84, name: "Leiter", groupTypeId: 12 }, { id: 800, name: "Leiter", groupTypeId: 12 }, // duplicate on the SAME group type ], }); - const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED, host: "hostA" }); + const state = stateWithExternals({ + local_lead: external("group-type", "local_lead", 12, { name: "Local Lead" }), + }); + const r = new Resolver({ client, state, desired: NO_DESIRED, host: "hostA" }); await expect(r.resolve(ref.groupTypeRole("local_lead", "Leiter"), "site")).rejects.toThrow( /Ambiguous group-type-role\(groupType=local_lead, role=Leiter\).*2 roles on group type #12 match — "Leiter" \(#84\), "Leiter" \(#800\)/, ); @@ -265,9 +340,9 @@ describe("Resolver.resolve — group-type-role (groupTypeRoleId, #76)", () => { it("errors when the group-type key itself cannot be resolved", async () => { const client = fakeClient({ "/group/grouptypes": groupTypesCatalog, "/group/roles": rolesCatalog }); const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED, host: "hostB" }); - await expect(r.resolve(ref.groupTypeRole("ghost_type", "Leiter"), "site")).rejects.toThrow( - /Cannot resolve group-type:ghost_type referenced at site on hostB/, - ); + await expect(r.resolve(ref.groupTypeRole("ghost_type", "Leiter"), "site")).rejects.toMatchObject({ + details: { reason: "EXTERNAL_BINDING_MISSING", type: "group-type", key: "ghost_type" }, + }); }); it("rejects a same-run-declared (not-yet-created) group type — id only exists once it does", async () => { @@ -283,12 +358,14 @@ describe("Resolver.resolve — group-type-role (groupTypeRoleId, #76)", () => { describe("Resolver.resolveValue", () => { it("deep-rewrites refs to ids and fetches each catalog at most once", async () => { const client = fakeClient({ - "/campuses": [ - { id: 5, name: "Mainz" }, - { id: 6, name: "Berlin" }, - ], + "/campuses/5": { id: 5, name: "Mainz" }, + "/campuses/6": { id: 6, name: "Berlin" }, }); - const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED }); + const state = stateWithExternals({ + mainz: external("campus", "mainz", 5, { name: "Mainz" }), + berlin: external("campus", "berlin", 6, { name: "Berlin" }), + }); + const r = new Resolver({ client, state, desired: NO_DESIRED }); const value = { campusId: ref.campus("mainz"), query: { @@ -305,7 +382,8 @@ describe("Resolver.resolveValue", () => { query: { or: [{ "==": [{ var: "ctgroup.campusId" }, 5] }, { "==": [{ var: "ctgroup.campusId" }, 6] }] }, untouched: 42, }); - expect(client.calls["/campuses"]).toBe(1); // cached across the two mainz refs + the berlin ref + expect(client.calls["/campuses/5"]).toBe(1); // cached across the two mainz refs + expect(client.calls["/campuses/6"]).toBe(1); }); it("returns the original reference untouched when there are no refs", async () => { @@ -343,10 +421,13 @@ describe("catalogs are read PAGINATED (#99 review)", () => { [{ id: 42, name: "Koblenz" }], // page 2 — invisible to a single `get` ]; - it("resolves a campus that lives past CT's default first page", async () => { + it("discovers a campus past the default first page but still refuses an ephemeral binding", async () => { const client = pagingClient({ "/campuses": campusPages }); const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED }); - expect(await r.resolve(ref.campus("koblenz"), "site")).toBe(42); + await expect(r.resolve(ref.campus("koblenz"), "site")).rejects.toMatchObject({ + details: { reason: "EXTERNAL_BINDING_MISSING" }, + message: expect.stringContaining("ct use campus 42 --key koblenz"), + }); expect(client.calls["/campuses"]).toBe(1); // still fetched once per run }); diff --git a/tests/state.test.ts b/tests/state.test.ts index 4aff229..878e737 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -155,10 +155,54 @@ describe("state.loadState", () => { }); it("rejects an unsupported version", async () => { - await writeFile(statePath, JSON.stringify({ version: 2, host: HOST, resources: {} }), "utf8"); + await writeFile(statePath, JSON.stringify({ version: 3, host: HOST, resources: {} }), "utf8"); await expect(loadState(statePath, HOST)).rejects.toThrow(/Unsupported state file version/); }); + it("migrates version 1 in memory to version 2 with an empty externals map", async () => { + await writeFile( + statePath, + JSON.stringify({ + version: 1, + host: HOST, + resources: { + mainz: { + type: "campus", + id: 0, + key: "mainz", + fields: { name: "Mainz" }, + adoptedAt: "t", + updatedAt: "t", + }, + }, + }), + "utf8", + ); + const state = await loadState(statePath, HOST); + expect(state.version).toBe(2); + expect(state.externals).toEqual({}); + expect(state.resources.mainz?.id).toBe(0); + }); + + it("rejects managed/external key collisions in version 2", async () => { + const common = { type: "campus", id: 0, key: "mainz" }; + await writeFile( + statePath, + JSON.stringify({ + version: 2, + host: HOST, + resources: { + mainz: { ...common, fields: {}, adoptedAt: "t", updatedAt: "t" }, + }, + externals: { + mainz: { ...common, identity: { name: "Mainz" }, boundAt: "t" }, + }, + }), + "utf8", + ); + await expect(loadState(statePath, HOST)).rejects.toThrow(/both a managed and an external/); + }); + it("migrates a pre-rename campus snapshot: shortName → shorty (#17 item 4)", async () => { // A campus adopted before the shortName→shorty rename (Phase 4, no version bump). const file = { diff --git a/tests/use-command.test.ts b/tests/use-command.test.ts new file mode 100644 index 0000000..d48053e --- /dev/null +++ b/tests/use-command.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const host = "https://example.church.tools"; +const request = vi.fn(async () => { + throw new Error("ct use must never write ChurchTools"); +}); +const get = vi.fn(async (path: string) => { + if (path === "/groups/4711") { + return { id: 4711, name: "OJAHR Fuzzies", information: { groupTypeId: 17, campusId: 2 } }; + } + throw new Error(`unexpected GET ${path}`); +}); + +vi.mock("../src/api/session.js", () => ({ + authedSession: vi.fn(async () => ({ client: { get, request }, me: { id: 1 } })), +})); + +const { useCommand } = await import("../src/commands/use.js"); + +describe("ct use", () => { + let directory: string; + let statePath: string; + const originalHost = process.env.CT_HOST; + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "ct-use-command-")); + statePath = join(directory, "ct-state.json"); + process.env.CT_HOST = host; + request.mockClear(); + get.mockClear(); + }); + + afterEach(async () => { + if (originalHost === undefined) delete process.env.CT_HOST; + else process.env.CT_HOST = originalHost; + await rm(directory, { recursive: true, force: true }); + }); + + it("supports the deterministic form and is byte-idempotent without a ChurchTools write", async () => { + await useCommand().parseAsync(["group", "4711", "--key", "ojahr_fuzzies", "--state", statePath], { + from: "user", + }); + const first = await readFile(statePath, "utf8"); + await useCommand().parseAsync(["group", "4711", "--key", "ojahr_fuzzies", "--state", statePath], { + from: "user", + }); + expect(await readFile(statePath, "utf8")).toBe(first); + expect(JSON.parse(first).externals.ojahr_fuzzies).toMatchObject({ + type: "group", + id: 4711, + identity: { name: "OJAHR Fuzzies", groupTypeId: 17 }, + }); + expect(request).not.toHaveBeenCalled(); + }); + + it("refuses fuzzy selection without a TTY before contacting ChurchTools", async () => { + await expect( + useCommand().parseAsync(["group", "OJAHR", "--state", statePath], { from: "user" }), + ).rejects.toThrow(/Non-interactive use requires an exact numeric id and --key/); + expect(get).not.toHaveBeenCalled(); + }); +}); From 3844a8a1ea467be23e2800a393a9142355c52230 Mon Sep 17 00:00:00 2001 From: Bernhard Weichel Date: Thu, 27 Aug 2026 21:39:53 +0200 Subject: [PATCH 2/4] feat: add safe unuse and unadopt commands --- README.md | 25 +-- docs/adoption-contract.md | 7 + docs/external-resources.md | 25 ++- src/application/contracts.ts | 13 +- src/application/errors.ts | 1 + src/application/operations/ownership.ts | 2 +- src/application/operations/release.ts | 102 ++++++++++++ src/application/operations/state.ts | 48 +++++- src/commands/release.ts | 105 +++++++++++++ src/commands/state-removal-confirmation.ts | 25 +++ src/commands/state.ts | 38 ++++- src/completion/candidates.ts | 6 + src/completion/sources.ts | 6 +- src/index.ts | 3 + tests/application/ownership-operation.test.ts | 3 + tests/application/release-operation.test.ts | 92 +++++++++++ tests/application/state-operation.test.ts | 59 +++++++ tests/completion.test.ts | 19 ++- tests/release-command.test.ts | 145 ++++++++++++++++++ tests/state-rm-command.test.ts | 21 ++- 20 files changed, 707 insertions(+), 38 deletions(-) create mode 100644 src/application/operations/release.ts create mode 100644 src/commands/release.ts create mode 100644 src/commands/state-removal-confirmation.ts create mode 100644 tests/application/release-operation.test.ts create mode 100644 tests/release-command.test.ts diff --git a/README.md b/README.md index 336e667..9d17500 100644 --- a/README.md +++ b/README.md @@ -214,10 +214,12 @@ ct auth status # who am I? (`--env ` asks on another instan ct get groups # JSON to stdout — pipe into jq (every page, not just the first) ct adopt campus 0 # bring ONE existing resource under management +ct unadopt campus mainz --env prod # stop managing it; keep the live object ct use group 4711 --key shared_group # bind an existing object read-only +ct unuse group shared_group --env prod # remove the binding; keep the live object ct coverage # what the instance has that the config does not manage ct state list # managed and external entries, explicitly labelled -ct state rm campus mainz # remove either state kind. Never touches ChurchTools. +ct state rm campus mainz # low-level repair escape hatch; typed confirmation required ct state rekey group old new # rename a logical key; update every ref.* use too ct ownership check .. --env prod # validate visible owner/consumer projects ct plan # diff the config against ChurchTools (read-only) @@ -235,14 +237,19 @@ printed — only the login token and its host reach the Keychain. There is delib Windows there is no Keychain to store anything in, so the prompt is not offered at all: export `CT_HOST` and `CT_LOGINTOKEN` there. -`state rm` removes a managed or external entry from the state file, makes no -HTTP call, and leaves the ChurchTools object in place. For managed entries it -refuses a key the config still declares — that -would make the next plan propose creating a resource that already exists — so -delete the declaration first, or pass `--force` to do both in one change. -"Declares" covers permission declarations too, not only resources: a key named -by a `ct.groupRole` domain or a group scope is just as broken to remove, and the -refusal is what keeps that from surfacing one command later as a plan error. +Use `unuse` for external bindings and `unadopt` for managed ownership. Both +commands make no HTTP call, leave the ChurchTools object in place, fail closed +when the config cannot be inspected, and refuse a key that remains declared or +referenced. They show a preview and require the environment name to be typed; +automation must pass an exactly matching `--confirm-env `. `--force` +overrides only the config-reference guard, never the typed confirmation. + +`state rm` remains a low-level repair escape hatch for either state partition. +It performs the same typed confirmation and a best-effort config-reference +check, warning when a broken config cannot be inspected, and points at `unuse` +or `unadopt` as the normal lifecycle command. A project without a named +environment types the logical key instead (or supplies `--confirm-key ` in +automation). `--dry-run` never needs confirmation and writes nothing. `apply` reconciles **creates and updates** only, saving state after each action (crash-safe / resumable). It **never deletes**: a resource dropped from the diff --git a/docs/adoption-contract.md b/docs/adoption-contract.md index cc5363d..c71686b 100644 --- a/docs/adoption-contract.md +++ b/docs/adoption-contract.md @@ -163,6 +163,13 @@ Remedy 2 is the normal consumer workflow. Numeric-id + `TODO` output remains an honest signal from adoption that the reference is not portable until explicitly bound; adoption never claims or writes the external object as a side effect. +Lifecycle release is equally explicit and never means deletion. `ct unuse + --env ` removes only a consumer binding; `ct unadopt + --env ` relinquishes only managed ownership. Both leave the live +ChurchTools object untouched, refuse config references by default, and require +typed environment confirmation. Actual deletion remains exclusively `ct +destroy` and is restricted to managed state. + ### 5. Person-related data — permanently excluded Memberships, participants, registrations, attendance, and every other record diff --git a/docs/external-resources.md b/docs/external-resources.md index ed1d011..9dbe7c7 100644 --- a/docs/external-resources.md +++ b/docs/external-resources.md @@ -118,12 +118,28 @@ Inspect or maintain either state partition with the shared commands: ct state list --env prod # managed and external, with explicit kind ct state list --managed --env prod ct state list --external --env prod -ct state rm group ojahr_fuzzies --env prod +ct unuse group ojahr_fuzzies --env prod +ct unadopt group owned_group --env prod ct state rekey group old_key new_key --env prod ``` -Removal and rekeying never contact ChurchTools. Rekeying requires every config -and `ref.*` use to be changed consistently. +`unuse` removes only external bindings; `unadopt` removes only managed ownership. +Neither contacts ChurchTools or deletes the live object. Both first check every +known config declaration and `ref.*` position, show the exact entry and state +file, then require the environment name to be typed. A referenced key blocks by +default; `--force` overrides that check only when the config and state changes +are deliberately made together. `--dry-run` previews without confirmation or a +write. In non-interactive use, confirmation remains explicit: + +```bash +ct unuse group ojahr_fuzzies --env prod --confirm-env prod +``` + +`ct state rm` remains the low-level repair escape hatch and carries the same +typed confirmation plus a best-effort reference check; unlike the public +lifecycle commands it can proceed with a warning when a broken config cannot be +inspected. Prefer `unuse`/`unadopt`. Rekeying requires every config and `ref.*` +use to be changed consistently. ## Planning and safety boundary @@ -168,7 +184,8 @@ It recursively finds ct projects below that root, ignores `.git`, no network calls. It reports duplicate managed owners, missing or mismatching owner hints, different keys for the same `(type, id)`, conflicting bindings, and incompatible identity snapshots. Conflicts return a non-zero exit code for -CI and include `ct state rekey`, `ct state rm`, or broader-scope remediation. +CI and include `ct state rekey`, `ct unuse`, `ct unadopt`, or broader-scope +remediation. The guarantee is intentionally scope-limited. Projects outside the supplied root remain unknowable; global atomic ownership would require a separate shared diff --git a/src/application/contracts.ts b/src/application/contracts.ts index d2fbc8b..bf74ee4 100644 --- a/src/application/contracts.ts +++ b/src/application/contracts.ts @@ -3,7 +3,18 @@ export type JsonPrimitive = string | number | boolean | null; export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; export type OperationName = - "plan" | "apply" | "coverage" | "adopt" | "use" | "ownership" | "state" | "refresh" | "destroy" | "auth"; + | "plan" + | "apply" + | "coverage" + | "adopt" + | "unadopt" + | "use" + | "unuse" + | "ownership" + | "state" + | "refresh" + | "destroy" + | "auth"; /** Common project selection accepted by CLI and, later, HTTP adapters. */ export interface ProjectRequest { diff --git a/src/application/errors.ts b/src/application/errors.ts index 986b3fc..d026e16 100644 --- a/src/application/errors.ts +++ b/src/application/errors.ts @@ -13,6 +13,7 @@ export const APPLICATION_ERROR_CODES = [ "MUTATION_BUSY", "EXTERNAL_REFERENCE_BLOCKED", "EXTERNAL_CONFIRMATION_REQUIRED", + "STATE_RELEASE_CONFIRMATION_REQUIRED", ] as const; export type ApplicationErrorCode = (typeof APPLICATION_ERROR_CODES)[number]; diff --git a/src/application/operations/ownership.ts b/src/application/operations/ownership.ts index 2fc30fd..94dce8f 100644 --- a/src/application/operations/ownership.ts +++ b/src/application/operations/ownership.ts @@ -192,7 +192,7 @@ export async function checkOwnership( .slice(1) .map( (item) => - `cd ${item.project.path} && ct state rm ${item.entry.type} ${item.entry.key} --env ${request.environment}`, + `cd ${item.project.path} && ct unadopt ${item.entry.type} ${item.entry.key} --env ${request.environment}`, ), }); } diff --git a/src/application/operations/release.ts b/src/application/operations/release.ts new file mode 100644 index 0000000..c7032c6 --- /dev/null +++ b/src/application/operations/release.ts @@ -0,0 +1,102 @@ +import type { ExternalResource, ManagedResource } from "../../state/state.js"; +import { CtApplicationError } from "../errors.js"; +import { PreparedOperationStore } from "../prepared-operation-store.js"; +import { + removeStateEntry, + type StateOperationDependencies, + type StateRemoveRequest, + type StateRemoveResult, +} from "./state.js"; + +export type ReleaseKind = "managed" | "external"; +export type ReleaseOperation = "unadopt" | "unuse"; +export type ReleaseConfirmation = + { type: "environment"; expected: string } | { type: "key"; expected: string }; +export type ReleaseConfirmationProof = + { type: "environment"; value: string } | { type: "key"; value: string }; + +export interface ReleaseRequest extends StateRemoveRequest { + kind: ReleaseKind; +} + +export interface PreparedReleaseExecution { + request: StateRemoveRequest; + entry: ManagedResource | ExternalResource; + confirmation: ReleaseConfirmation; +} + +export interface PreparedRelease { + id: string; + preview: StateRemoveResult; + confirmation: ReleaseConfirmation; +} + +export interface ReleaseOperationDependencies extends StateOperationDependencies { + store?: PreparedOperationStore; + /** `null` disables wall-clock expiry for a CLI prompt; adapters get a bounded default. */ + preparedTtlMs?: number | null; +} + +const PREPARED_RELEASE_TTL_MS = 5 * 60 * 1000; +const defaultStore = new PreparedOperationStore(); + +function operation(kind: ReleaseKind): ReleaseOperation { + return kind === "managed" ? "unadopt" : "unuse"; +} + +function assertConfirmation(requirement: ReleaseConfirmation, proof?: ReleaseConfirmationProof): void { + if (proof?.type === requirement.type && proof.value === requirement.expected) return; + throw new CtApplicationError( + "STATE_RELEASE_CONFIRMATION_REQUIRED", + `${requirement.type === "environment" ? "Environment" : "Logical key"} ` + + `${JSON.stringify(requirement.expected)} was not confirmed. State was not changed.`, + { details: { confirmationType: requirement.type, expected: requirement.expected } }, + ); +} + +/** Prepare and retain the exact state entry the adapter must present for confirmation. */ +export async function prepareRelease( + request: ReleaseRequest, + dependencies: ReleaseOperationDependencies = {}, +): Promise { + const op = operation(request.kind); + const stateRequest: StateRemoveRequest = { + type: request.type, + key: request.key, + cwd: request.cwd, + configPath: request.configPath, + statePath: request.statePath, + environment: request.environment, + force: request.force, + expectedKind: request.kind, + requireReadableConfig: true, + operation: op, + dryRun: true, + }; + const preview = await removeStateEntry(stateRequest, dependencies); + const confirmation: ReleaseConfirmation = preview.project.environment + ? { type: "environment", expected: preview.project.environment } + : { type: "key", expected: request.key }; + const stored = (dependencies.store ?? defaultStore).put( + { + request: { ...stateRequest, dryRun: false }, + entry: preview.value.entry, + confirmation, + }, + dependencies.preparedTtlMs === undefined ? PREPARED_RELEASE_TTL_MS : dependencies.preparedTtlMs, + ); + return { id: stored.id, preview, confirmation }; +} + +/** Execute only the immutable entry that was previewed, after application-level proof validation. */ +export async function executePreparedRelease( + prepared: Pick, + proof: ReleaseConfirmationProof | undefined, + dependencies: ReleaseOperationDependencies = {}, +): Promise { + const store = dependencies.store ?? defaultStore; + const candidate = store.peek(prepared.id); + assertConfirmation(candidate.confirmation, proof); + store.take(prepared.id); + return removeStateEntry({ ...candidate.request, expectedEntry: candidate.entry }, dependencies); +} diff --git a/src/application/operations/state.ts b/src/application/operations/state.ts index 06d8aad..af2d12a 100644 --- a/src/application/operations/state.ts +++ b/src/application/operations/state.ts @@ -38,6 +38,13 @@ export interface StateRemoveRequest extends ProjectRequest { key: string; force?: boolean; dryRun?: boolean; + /** Restrict a public lifecycle command to its own state partition. */ + expectedKind?: "managed" | "external"; + /** Refuse a stale prepare/confirm/execute sequence if any binding metadata changed meanwhile. */ + expectedEntry?: ManagedResource | ExternalResource; + /** Safe public commands fail closed when their reference guard cannot load config. */ + requireReadableConfig?: boolean; + operation?: "state" | "unuse" | "unadopt"; } export type StateRemoveResult = OperationResult<{ @@ -88,6 +95,15 @@ async function declaredKeys( else if (ref.kind === "group-member-field") keys.add(ref.group); else keys.add(ref.key); }; + // Resource fields and dynamic rulesets carry typed ref.* values; parent/dependsOn + // remain portable string keys. Walk all of them so unuse/unadopt cannot leave a + // config that only fails on the next plan. + for (const resource of resources) { + if (resource.parent) keys.add(resource.parent); + for (const key of resource.parents ?? []) keys.add(key); + for (const key of resource.dependsOn ?? []) keys.add(key); + for (const ref of collectRefs([resource.fields, resource.dynamic?.ruleset])) addRef(ref); + } for (const ref of collectRefs(permissions)) addRef(ref); for (const permission of permissions) { for (const grant of permission.grants) { @@ -131,20 +147,40 @@ export async function removeStateEntry( } const kind = managed ? "managed" : "external"; + if (request.expectedKind && kind !== request.expectedKind) { + const command = kind === "managed" ? "unadopt" : "unuse"; + throw new Error( + `"${request.key}" is ${kind}, not ${request.expectedKind}. Use \`ct ${command} ${entry.type} ${request.key}\`.`, + ); + } + if (request.expectedEntry && JSON.stringify(entry) !== JSON.stringify(request.expectedEntry)) { + throw new Error( + `${kind} ${entry.type}.${request.key} changed while confirmation was pending. Inspect it and retry.`, + ); + } const warnings: CtWarning[] = []; - if (kind === "managed" && !request.force) { + if (!request.force) { try { const declared = await declaredKeys(project.configPath, dependencies); if (declared.has(request.key)) { + const consequence = + kind === "managed" + ? "the next plan could recreate the live object or fail to resolve one of its references" + : "the next plan would fail because the external prerequisite no longer resolves"; throw new Error( - `"${request.key}" is still declared in the config, so removing it from state would make the next ` + - `plan propose CREATING a resource that already exists on this host. Remove the ` + - `declaration first, or pass --force if you are deleting both in the same change.`, + `"${request.key}" is still declared or referenced in the config; ${consequence}. ` + + `Remove every declaration/ref first, or pass --force only when both changes belong together.`, ); } } catch (caught) { - if (caught instanceof Error && caught.message.includes("is still declared in the config")) + if (caught instanceof Error && caught.message.includes("is still declared or referenced")) throw caught; + if (request.requireReadableConfig) { + throw new Error( + `Could not read the config to verify that "${request.key}" is unused ` + + `(${caught instanceof Error ? caught.message : String(caught)}). Fix the config or pass --force after reviewing all references.`, + ); + } warnings.push({ code: "CONFIG_UNREADABLE", message: @@ -160,7 +196,7 @@ export async function removeStateEntry( await (dependencies.saveState ?? saveState)(project.statePath, state); } return { - operation: "state", + operation: request.operation ?? "state", project, warnings, value: { kind, entry, removed: !request.dryRun, churchToolsContacted: false }, diff --git a/src/commands/release.ts b/src/commands/release.ts new file mode 100644 index 0000000..ed42804 --- /dev/null +++ b/src/commands/release.ts @@ -0,0 +1,105 @@ +import { Command } from "commander"; +import { + executePreparedRelease, + prepareRelease, + type ReleaseConfirmationProof, +} from "../application/operations/release.js"; +import { info, success, warn } from "../ui.js"; +import { confirmStateRemoval } from "./state-removal-confirmation.js"; + +interface ReleaseOptions { + state?: string; + env?: string; + config?: string; + force?: boolean; + dryRun?: boolean; + confirmEnv?: string; + confirmKey?: string; +} + +type ReleaseKind = "managed" | "external"; + +function noun(kind: ReleaseKind): string { + return kind === "managed" ? "managed ownership" : "external binding"; +} + +function commandName(kind: ReleaseKind): "unadopt" | "unuse" { + return kind === "managed" ? "unadopt" : "unuse"; +} + +function releaseCommand(kind: ReleaseKind): Command { + const name = commandName(kind); + const command = new Command(name) + .description( + kind === "managed" + ? "Stop managing an adopted object without changing it in ChurchTools" + : "Remove an external read-only binding without changing ChurchTools", + ) + .argument("", "ct-cli resource type") + .argument("", "logical key to release") + .option("-s, --state ", "state file path (or set CT_STATE)") + .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") + .option("-c, --config ", "config file to check for declarations and refs (or set CT_CONFIG)") + .option( + "--force", + "allow removal while the key is still declared/referenced or the config cannot be checked", + ) + .option("--dry-run", "validate and report the removal without writing state") + .option("--confirm-env ", "confirm a named environment non-interactively (must match --env)") + .option( + "--confirm-key ", + "confirm a legacy project without --env non-interactively (must match the logical key)", + ); + + return command.action(async (type: string, key: string, opts: ReleaseOptions) => { + const request = { + type, + key, + kind, + statePath: opts.state, + configPath: opts.config, + environment: opts.env, + force: opts.force, + } as const; + // The person reading the preview may take as long as needed; exact-entry + // comparison, not wall-clock expiry, rejects stale confirmation. + const prepared = await prepareRelease(request, { preparedTtlMs: null }); + const preview = prepared.preview; + for (const warning of preview.warnings) warn(warning.message); + const entry = preview.value.entry; + warn( + `${opts.dryRun ? "Would remove" : "About to remove"} ${noun(kind)} ` + + `${entry.type}.${entry.key} (#${entry.id}) from ${preview.project.stateDisplayPath}.`, + ); + info("ChurchTools will not be contacted; the live object will remain unchanged."); + if (opts.dryRun) return; + + const confirmed = await confirmStateRemoval(preview.project, key, { + confirmEnv: opts.confirmEnv, + confirmKey: opts.confirmKey, + }); + if (!confirmed) { + warn(`Aborted — ${preview.project.environment ? "environment" : "logical key"} was not confirmed.`); + process.exitCode = 1; + return; + } + + const proof: ReleaseConfirmationProof = preview.project.environment + ? { type: "environment", value: preview.project.environment } + : { type: "key", value: key }; + const result = await executePreparedRelease(prepared, proof); + success( + `${name}: removed ${noun(kind)} ${entry.type}.${entry.key} (#${entry.id}) from ` + + `${result.project.stateDisplayPath}.`, + ); + info("ChurchTools was not contacted."); + }); +} + +export function unuseCommand(): Command { + return releaseCommand("external"); +} + +export function unadoptCommand(): Command { + return releaseCommand("managed"); +} diff --git a/src/commands/state-removal-confirmation.ts b/src/commands/state-removal-confirmation.ts new file mode 100644 index 0000000..d61ec7c --- /dev/null +++ b/src/commands/state-removal-confirmation.ts @@ -0,0 +1,25 @@ +import type { ResolvedProjectInfo } from "../application/contracts.js"; +import { confirmTyped } from "../ui/prompt.js"; + +export interface StateRemovalConfirmationOptions { + confirmEnv?: string; + confirmKey?: string; +} + +/** + * State-only lifecycle changes use the same typed environment gate as protected + * apply/destroy. Legacy projects without a named environment type the logical + * key instead; automation supplies the exact value explicitly. + */ +export async function confirmStateRemoval( + project: ResolvedProjectInfo, + key: string, + options: StateRemovalConfirmationOptions, +): Promise { + if (project.environment) { + if (options.confirmEnv !== undefined) return options.confirmEnv === project.environment; + return confirmTyped(project.environment); + } + if (options.confirmKey !== undefined) return options.confirmKey === key; + return confirmTyped(key); +} diff --git a/src/commands/state.ts b/src/commands/state.ts index 5360a98..3d50bdb 100644 --- a/src/commands/state.ts +++ b/src/commands/state.ts @@ -1,6 +1,7 @@ import { Command } from "commander"; import { listState, rekeyStateEntry, removeStateEntry } from "../application/operations/state.js"; import { info, out, success, warn } from "../ui.js"; +import { confirmStateRemoval } from "./state-removal-confirmation.js"; interface StateOptions { state?: string; @@ -13,6 +14,8 @@ interface StateRmOptions extends StateOptions { config?: string; force?: boolean; dryRun?: boolean; + confirmEnv?: string; + confirmKey?: string; } export function stateCommand(): Command { @@ -46,26 +49,45 @@ export function stateCommand(): Command { .option("-s, --state ", "state file path (or set CT_STATE)") .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") .option("-c, --config ", "config file to check the key against (or set CT_CONFIG)") - .option("--force", "remove even though the key is still declared in the config") + .option("--force", "remove even though the key is still declared/referenced or config is unreadable") .option("--dry-run", "report what would be removed without writing") + .option("--confirm-env ", "confirm a named environment non-interactively (must match --env)") + .option( + "--confirm-key ", + "confirm a legacy project without --env non-interactively (must match the logical key)", + ) .action(async (type: string, key: string, opts: StateRmOptions) => { - const result = await removeStateEntry({ + const request = { type, key, statePath: opts.state, environment: opts.env, configPath: opts.config, force: opts.force, - dryRun: opts.dryRun, - }); - for (const warning of result.warnings) warn(warning.message); - const entry = result.value.entry; - if (!result.value.removed) { + } as const; + const preview = await removeStateEntry({ ...request, dryRun: true }); + for (const warning of preview.warnings) warn(warning.message); + const entry = preview.value.entry; + if (opts.dryRun) { info( - `Would remove ${result.value.kind} ${entry.type}.${key} (#${entry.id}) from ${result.project.stateDisplayPath}.`, + `Would remove ${preview.value.kind} ${entry.type}.${key} (#${entry.id}) from ${preview.project.stateDisplayPath}.`, ); return; } + warn( + `About to remove ${preview.value.kind} ${entry.type}.${key} (#${entry.id}) from ` + + `${preview.project.stateDisplayPath}. Prefer ct ${preview.value.kind === "managed" ? "unadopt" : "unuse"} for normal lifecycle changes.`, + ); + const confirmed = await confirmStateRemoval(preview.project, key, { + confirmEnv: opts.confirmEnv, + confirmKey: opts.confirmKey, + }); + if (!confirmed) { + warn(`Aborted — ${preview.project.environment ? "environment" : "logical key"} was not confirmed.`); + process.exitCode = 1; + return; + } + const result = await removeStateEntry({ ...request, expectedEntry: entry }); success( `Removed ${result.value.kind} ${entry.type}.${key} (#${entry.id}) from ${result.project.stateDisplayPath}.`, ); diff --git a/src/completion/candidates.ts b/src/completion/candidates.ts index ce56595..db5531e 100644 --- a/src/completion/candidates.ts +++ b/src/completion/candidates.ts @@ -39,7 +39,9 @@ type DynamicSource = (position: Position, partial: string) => Promise */ const DYNAMIC_ARGUMENTS: Record = { "ct adopt type": () => resourceTypes(), + "ct unadopt type": () => resourceTypes(), "ct use type": () => resourceTypes(), + "ct unuse type": () => resourceTypes(), "ct state rm type": () => resourceTypes(), "ct state rekey type": () => resourceTypes(), // `ct state rm ` refuses a key belonging to another type, so the type @@ -47,6 +49,10 @@ const DYNAMIC_ARGUMENTS: Record = { "ct state rm key": async (position) => stateKeys(await statePathFor(position), position.positionals[0]), "ct state rekey old-key": async (position) => stateKeys(await statePathFor(position), position.positionals[0]), + "ct unadopt key": async (position) => + stateKeys(await statePathFor(position), position.positionals[0], "managed"), + "ct unuse key": async (position) => + stateKeys(await statePathFor(position), position.positionals[0], "external"), }; /** diff --git a/src/completion/sources.ts b/src/completion/sources.ts index 7e0caae..bcb8690 100644 --- a/src/completion/sources.ts +++ b/src/completion/sources.ts @@ -79,12 +79,14 @@ export function envStatePath(path: string, name: string): Promise * ` rejects a key of any other type: offering it would only complete into an error. */ -export function stateKeys(path: string, type?: string): Promise { +export function stateKeys(path: string, type?: string, kind?: "managed" | "external"): Promise { return offline(async () => { const state = JSON.parse(await readFile(path, "utf8")); const resources = objectField(state, "resources"); const externals = objectField(state, "externals"); - return Object.entries({ ...resources, ...externals }) + const entries = + kind === "managed" ? resources : kind === "external" ? externals : { ...resources, ...externals }; + return Object.entries(entries) .filter(([, entry]) => type === undefined || (isObject(entry) && entry.type === type)) .map(([key]) => key); }, []); diff --git a/src/index.ts b/src/index.ts index 393957e..5c6dc89 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { authCommand } from "./commands/auth.js"; import { getCommand } from "./commands/get.js"; import { adoptCommand } from "./commands/adopt.js"; import { useCommand } from "./commands/use.js"; +import { unadoptCommand, unuseCommand } from "./commands/release.js"; import { ownershipCommand } from "./commands/ownership.js"; import { stateCommand } from "./commands/state.js"; import { coverageCommand } from "./commands/coverage.js"; @@ -35,7 +36,9 @@ export function buildProgram(): Command { program.addCommand(authCommand()); program.addCommand(getCommand()); program.addCommand(adoptCommand()); + program.addCommand(unadoptCommand()); program.addCommand(useCommand()); + program.addCommand(unuseCommand()); program.addCommand(ownershipCommand()); program.addCommand(stateCommand()); program.addCommand(coverageCommand()); diff --git a/tests/application/ownership-operation.test.ts b/tests/application/ownership-operation.test.ts index ff366b5..275a119 100644 --- a/tests/application/ownership-operation.test.ts +++ b/tests/application/ownership-operation.test.ts @@ -110,6 +110,9 @@ describe("checkOwnership", () => { expect( result.value.findings.find((finding) => finding.reason === "KEY_MISMATCH")?.remediation?.[0], ).toContain("ct state rekey group alias shared --env prod"); + expect( + result.value.findings.find((finding) => finding.reason === "DUPLICATE_OWNER")?.remediation?.[0], + ).toContain("ct unadopt group alias --env prod"); }); it("does not search ignored build or node_modules directories", async () => { diff --git a/tests/application/release-operation.test.ts b/tests/application/release-operation.test.ts new file mode 100644 index 0000000..72fa6dc --- /dev/null +++ b/tests/application/release-operation.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from "vitest"; +import { + executePreparedRelease, + prepareRelease, + type PreparedReleaseExecution, +} from "../../src/application/operations/release.js"; +import { PreparedOperationStore } from "../../src/application/prepared-operation-store.js"; +import { emptyState } from "../../src/state/state.js"; + +const host = "https://example.church.tools"; + +function project(environment: string | null = "prod") { + return { + cwd: "/project", + configPath: "/project/ct.config.ts", + statePath: "/project/state.json", + environmentsPath: "/project/ct.envs.json", + configDisplayPath: "ct.config.ts", + stateDisplayPath: "state.json", + environment, + protected: false, + host, + }; +} + +describe("release operations", () => { + it("enforces environment proof inside the application operation", async () => { + const state = emptyState(host); + state.externals!.shared = { + type: "group", + id: 7, + key: "shared", + identity: { name: "Shared", groupTypeId: 2 }, + boundAt: "t", + }; + const saveState = vi.fn(); + const store = new PreparedOperationStore(); + const dependencies = { + resolveProject: vi.fn(async () => project()), + loadState: vi.fn(async () => state), + loadConfig: vi.fn(async () => ({ resources: [], permissions: [], configDir: "/project" })), + saveState, + store, + }; + const prepared = await prepareRelease({ kind: "external", type: "group", key: "shared" }, dependencies); + expect(prepared.confirmation).toEqual({ type: "environment", expected: "prod" }); + expect(prepared.preview.operation).toBe("unuse"); + + await expect(executePreparedRelease(prepared, undefined, dependencies)).rejects.toMatchObject({ + code: "STATE_RELEASE_CONFIRMATION_REQUIRED", + }); + await expect( + executePreparedRelease(prepared, { type: "environment", value: "dev" }, dependencies), + ).rejects.toMatchObject({ code: "STATE_RELEASE_CONFIRMATION_REQUIRED" }); + expect(saveState).not.toHaveBeenCalled(); + expect(state.externals?.shared).toBeDefined(); + + const result = await executePreparedRelease( + prepared, + { type: "environment", value: "prod" }, + dependencies, + ); + expect(result.operation).toBe("unuse"); + expect(result.value).toMatchObject({ kind: "external", removed: true, churchToolsContacted: false }); + expect(state.externals?.shared).toBeUndefined(); + expect(saveState).toHaveBeenCalledOnce(); + }); + + it("uses exact logical-key proof for a legacy project without --env", async () => { + const state = emptyState(host); + state.resources.owned = { + type: "campus", + id: 0, + key: "owned", + fields: { name: "Owned" }, + adoptedAt: "t", + updatedAt: "t", + }; + const store = new PreparedOperationStore(); + const dependencies = { + resolveProject: vi.fn(async () => project(null)), + loadState: vi.fn(async () => state), + loadConfig: vi.fn(async () => ({ resources: [], permissions: [], configDir: "/project" })), + saveState: vi.fn(), + store, + }; + const prepared = await prepareRelease({ kind: "managed", type: "campus", key: "owned" }, dependencies); + expect(prepared.confirmation).toEqual({ type: "key", expected: "owned" }); + await executePreparedRelease(prepared, { type: "key", value: "owned" }, dependencies); + expect(state.resources.owned).toBeUndefined(); + }); +}); diff --git a/tests/application/state-operation.test.ts b/tests/application/state-operation.test.ts index 2f8df82..7fb24e9 100644 --- a/tests/application/state-operation.test.ts +++ b/tests/application/state-operation.test.ts @@ -70,6 +70,65 @@ describe("state operations", () => { expect(state.resources.mainz).toBeDefined(); }); + it("fails closed when a safe removal cannot inspect config", async () => { + const state = emptyState(host); + state.externals!.shared = { + type: "group", + id: 7, + key: "shared", + identity: { name: "Shared", groupTypeId: 2 }, + boundAt: "t", + }; + await expect( + removeStateEntry( + { type: "group", key: "shared", expectedKind: "external", requireReadableConfig: true }, + { + resolveProject: vi.fn(async () => project()), + loadState: vi.fn(async () => state), + loadConfig: vi.fn(async () => { + throw new Error("broken config"); + }), + saveState: vi.fn(), + }, + ), + ).rejects.toThrow(/Could not read the config/); + expect(state.externals?.shared).toBeDefined(); + }); + + it("refuses to remove a binding that changed while confirmation was pending", async () => { + const state = emptyState(host); + state.externals!.shared = { + type: "group", + id: 8, + key: "shared", + identity: { name: "Changed", groupTypeId: 2 }, + boundAt: "t", + }; + await expect( + removeStateEntry( + { + type: "group", + key: "shared", + expectedKind: "external", + expectedEntry: { + type: "group", + id: 7, + key: "shared", + identity: { name: "Shared", groupTypeId: 2 }, + boundAt: "t", + }, + }, + { + resolveProject: vi.fn(async () => project()), + loadState: vi.fn(async () => state), + loadConfig: vi.fn(async () => ({ resources: [], permissions: [], configDir: "/project" })), + saveState: vi.fn(), + }, + ), + ).rejects.toThrow(/changed while confirmation was pending/); + expect(state.externals?.shared).toBeDefined(); + }); + it("lists, removes and rekeys external entries through the shared key namespace", async () => { const state = emptyState(host); state.externals!.shared = { diff --git a/tests/completion.test.ts b/tests/completion.test.ts index 2bee897..43cf64f 100644 --- a/tests/completion.test.ts +++ b/tests/completion.test.ts @@ -82,7 +82,18 @@ describe("completion candidates", () => { it("reflects the real command tree, nested commands included", async () => { const program = buildProgram(); expect(await complete(program, "ct ")).toEqual( - expect.arrayContaining(["auth", "use", "ownership", "state", "plan", "apply", "destroy", "completion"]), + expect.arrayContaining([ + "auth", + "use", + "unuse", + "unadopt", + "ownership", + "state", + "plan", + "apply", + "destroy", + "completion", + ]), ); expect(await complete(program, "ct auth ")).toEqual(expect.arrayContaining(["login", "logout"])); expect(await complete(program, "ct state ")).toEqual(expect.arrayContaining(["list", "rm", "rekey"])); @@ -192,14 +203,16 @@ describe("dynamic completion", () => { JSON.stringify({ version: 2, host: "https://x.church.tools", - resources: {}, + resources: { owned: { type: "group", id: 8, key: "owned", fields: {} } }, externals: { shared: { type: "group", id: 7, key: "shared", identity: {}, boundAt: "t" } }, }), ); expect(await complete(buildProgram(), "ct use ")).toEqual( expect.arrayContaining(["campus", "group", "group-role"]), ); - expect(await complete(buildProgram(), "ct state rekey group ")).toEqual(["shared"]); + expect(await complete(buildProgram(), "ct state rekey group ")).toEqual(["owned", "shared"]); + expect(await complete(buildProgram(), "ct unuse group ")).toEqual(["shared"]); + expect(await complete(buildProgram(), "ct unadopt group ")).toEqual(["owned"]); }); it("completes a path option from the filesystem", async () => { diff --git a/tests/release-command.test.ts b/tests/release-command.test.ts new file mode 100644 index 0000000..f050023 --- /dev/null +++ b/tests/release-command.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { emptyState, loadState, saveState } from "../src/state/state.js"; +import { unadoptCommand, unuseCommand } from "../src/commands/release.js"; + +const HOST = "https://example.church.tools"; +const saved = { host: process.env.CT_HOST, envs: process.env.CT_ENVS, config: process.env.CT_CONFIG }; + +describe("ct unuse / ct unadopt", () => { + let directory: string; + let statePath: string; + let configPath: string; + let envsPath: string; + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "ct-release-")); + statePath = join(directory, "state.json"); + configPath = join(directory, "ct.config.ts"); + envsPath = join(directory, "ct.envs.json"); + delete process.env.CT_HOST; + process.env.CT_ENVS = envsPath; + process.env.CT_CONFIG = configPath; + await writeFile( + envsPath, + JSON.stringify({ environments: { prod: { host: HOST, state: statePath, protected: true } } }), + ); + await writeFile(configPath, "export default () => {};"); + const state = emptyState(HOST); + state.resources.owned = { + type: "group", + key: "owned", + id: 10, + fields: { name: "Owned" }, + adoptedAt: "t", + updatedAt: "t", + }; + state.externals!.shared = { + type: "group", + key: "shared", + id: 20, + identity: { name: "Shared", groupTypeId: 2 }, + boundAt: "t", + }; + await saveState(statePath, state); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + process.exitCode = 0; + if (saved.host === undefined) delete process.env.CT_HOST; + else process.env.CT_HOST = saved.host; + if (saved.envs === undefined) delete process.env.CT_ENVS; + else process.env.CT_ENVS = saved.envs; + if (saved.config === undefined) delete process.env.CT_CONFIG; + else process.env.CT_CONFIG = saved.config; + await rm(directory, { recursive: true, force: true }); + }); + + it("unuses only an external after exact environment confirmation", async () => { + await unuseCommand().parseAsync(["group", "shared", "--env", "prod", "--confirm-env", "prod"], { + from: "user", + }); + const state = await loadState(statePath, HOST); + expect(state.externals?.shared).toBeUndefined(); + expect(state.resources.owned).toBeDefined(); + }); + + it("unadopts only a managed entry after exact environment confirmation", async () => { + await unadoptCommand().parseAsync(["group", "owned", "--env", "prod", "--confirm-env", "prod"], { + from: "user", + }); + const state = await loadState(statePath, HOST); + expect(state.resources.owned).toBeUndefined(); + expect(state.externals?.shared).toBeDefined(); + }); + + it("refuses missing or mismatching confirmation without changing state", async () => { + await unuseCommand().parseAsync(["group", "shared", "--env", "prod"], { from: "user" }); + expect(process.exitCode).toBe(1); + expect((await loadState(statePath, HOST)).externals?.shared).toBeDefined(); + + process.exitCode = 0; + await unuseCommand().parseAsync(["group", "shared", "--env", "prod", "--confirm-env", "dev"], { + from: "user", + }); + expect(process.exitCode).toBe(1); + expect((await loadState(statePath, HOST)).externals?.shared).toBeDefined(); + }); + + it("fails closed while the external key is referenced, unless --force is explicit", async () => { + await writeFile( + configPath, + `export default (ct) => { ct.groupRole({ key: "reader", group: "shared", role: "Reader", grants: [] }); };`, + ); + await expect( + unuseCommand().parseAsync(["group", "shared", "--env", "prod", "--confirm-env", "prod"], { + from: "user", + }), + ).rejects.toThrow(/still declared or referenced/); + expect((await loadState(statePath, HOST)).externals?.shared).toBeDefined(); + + await unuseCommand().parseAsync( + ["group", "shared", "--env", "prod", "--confirm-env", "prod", "--force"], + { from: "user" }, + ); + expect((await loadState(statePath, HOST)).externals?.shared).toBeUndefined(); + }); + + it("recognizes hierarchy references outside permission declarations", async () => { + await writeFile( + configPath, + `export default (ct) => { ct.group({ key: "child", name: "Child", parents: ["shared"] }); };`, + ); + await expect( + unuseCommand().parseAsync(["group", "shared", "--env", "prod", "--confirm-env", "prod"], { + from: "user", + }), + ).rejects.toThrow(/still declared or referenced/); + expect((await loadState(statePath, HOST)).externals?.shared).toBeDefined(); + }); + + it("rejects crossing the managed/external boundary", async () => { + await expect( + unuseCommand().parseAsync(["group", "owned", "--env", "prod", "--confirm-env", "prod"], { + from: "user", + }), + ).rejects.toThrow(/is managed, not external/); + await expect( + unadoptCommand().parseAsync(["group", "shared", "--env", "prod", "--confirm-env", "prod"], { + from: "user", + }), + ).rejects.toThrow(/is external, not managed/); + }); + + it("keeps dry-run side-effect free and requires no confirmation", async () => { + await unuseCommand().parseAsync(["group", "shared", "--env", "prod", "--dry-run"], { + from: "user", + }); + expect((await loadState(statePath, HOST)).externals?.shared).toBeDefined(); + }); +}); diff --git a/tests/state-rm-command.test.ts b/tests/state-rm-command.test.ts index 72e6f79..0f4af09 100644 --- a/tests/state-rm-command.test.ts +++ b/tests/state-rm-command.test.ts @@ -31,7 +31,11 @@ const originalHost = process.env.CT_HOST; const originalConfig = process.env.CT_CONFIG; async function run(args: string[]): Promise { - await stateCommand().parseAsync(["rm", ...args], { from: "user" }); + const key = args[1]; + await stateCommand().parseAsync( + ["rm", ...args, ...(key && !args.includes("--dry-run") ? ["--confirm-key", key] : [])], + { from: "user" }, + ); } /** A state file holding two adopted role definitions and one campus. */ @@ -92,6 +96,15 @@ afterEach(async () => { }); describe("ct state rm (#122)", () => { + it("requires typed confirmation before the low-level state mutation", async () => { + await stateCommand().parseAsync(["rm", "group", "youth", "--state", statePath], { + from: "user", + }); + expect(process.exitCode).toBe(1); + expect((await loadState(statePath, HOST)).resources.youth).toBeDefined(); + process.exitCode = 0; + }); + it("removes the entry and contacts nothing", async () => { await run(["group-role", "appmodule_write", "--state", statePath]); const state = await loadState(statePath, HOST); @@ -115,7 +128,7 @@ describe("ct state rm (#122)", () => { `export default (ct) => { ct.groupRole({ key: "youth_leiter", group: "youth", role: "Leiter", grants: ["churchcore:administer settings"] }); };`, ); await expect(run(["group", "youth", "--state", statePath])).rejects.toThrow( - /still declared in the config/, + /still declared or referenced in the config/, ); const state = await loadState(statePath, HOST); expect(state.resources.youth).toBeDefined(); @@ -127,7 +140,7 @@ describe("ct state rm (#122)", () => { `export default (ct) => { ct.groupRole({ key: "p", id: 77, grants: [{ right: "churchgroup:view group", scope: ["youth"] }] }); };`, ); await expect(run(["group", "youth", "--state", statePath])).rejects.toThrow( - /still declared in the config/, + /still declared or referenced in the config/, ); const state = await loadState(statePath, HOST); expect(state.resources.youth).toBeDefined(); @@ -149,7 +162,7 @@ describe("ct state rm (#122)", () => { `export default (ct) => { ct.roleDefinition({ key: "appmodule_write", name: "Write", groupTypeId: 2 }); };`, ); await expect(run(["group-role", "appmodule_write", "--state", statePath])).rejects.toThrow( - /still declared in the config/, + /still declared or referenced in the config/, ); const state = await loadState(statePath, HOST); expect(state.resources.appmodule_write).toBeDefined(); From 737f59eff055f9ef7d7595552c3ad3cebb72f5d0 Mon Sep 17 00:00:00 2001 From: Bernhard Weichel Date: Sat, 29 Aug 2026 10:36:19 +0200 Subject: [PATCH 3/4] docs: record external resource references design --- ...-27-external-resource-references-design.md | 441 ++++++++++++++++++ 1 file changed, 441 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-27-external-resource-references-design.md diff --git a/docs/superpowers/specs/2026-08-27-external-resource-references-design.md b/docs/superpowers/specs/2026-08-27-external-resource-references-design.md new file mode 100644 index 0000000..3cc867a --- /dev/null +++ b/docs/superpowers/specs/2026-08-27-external-resource-references-design.md @@ -0,0 +1,441 @@ +# External resource references across ct projects (#143) + +**Status:** Design decisions complete; implementation not started. This +document records the agreed design for +[#143](https://github.com/eqrm/ct-cli/issues/143). + +## Context + +Independently managed ct projects may use the same ChurchTools resource. One +project owns its lifecycle; the others need its host-specific id in portable +references without gaining permission to create, update, or delete it. + +The [adoption contract](../../adoption-contract.md) already establishes that a +shared referenced resource is never adopted transitively. This design defines +how a deliberately unmanaged resource can nevertheless be resolved. + +## Terminology + +A **ct project** is one lifecycle boundary consisting of a config and its +environment-specific state files. A project may be a directory in a monorepo or +its own repository. It does not need to be a separate Git repository. + +Example: + +```text +processes/ +├── cafeplan/ +│ ├── ct.config.ts +│ └── ct-state.prod.json +└── ojbp/ + ├── ct.config.ts + └── ct-state.prod.json +``` + +## Glossary and terminology boundary + +ct-cli spans several unrelated ChurchTools modules, APIs, and master-data +tables. ChurchTools has no single official umbrella term that exactly denotes +the set of things ct-cli can manage. In particular, neither unqualified +"resource" nor "domain object" is safe shorthand. + +| Term in this design | Meaning in ct-cli | ChurchTools distinction | +| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| **ct project** | One config plus its environment-specific state files; one lifecycle boundary. | Not a ChurchTools project or module. | +| **ct-cli resource** | An independently addressable top-level object represented by one entry in ct-cli's resource registry and, when managed, one state entry. Prefer the qualified form in user documentation. | ChurchTools uses "Resource" for its own product/API concepts; that term is not the umbrella category meant here. | +| **resource type** | A ct-cli registry discriminator such as `group`, `campus`, or `group-role`. | Not necessarily a ChurchTools domain, module, endpoint family, or UI object type. | +| **managed** | The ct project owns lifecycle responsibility; `plan`, `apply`, and explicit `destroy` may act on the object. | Does not mean a ChurchTools permission or UI ownership flag. | +| **external** | The ct project may resolve and consume the object read-only but does not own its lifecycle. | No corresponding ChurchTools flag is implied or written. | +| **owner project** | The one ct project whose managed state claims lifecycle responsibility for an object. | Not necessarily the ChurchTools creator, administrator, or permission owner. | +| **consumer project** | A ct project whose external state binds and references an owner project's object. | Does not describe ChurchTools memberships or participants. | +| **logical key** | Portable, user-controlled identity used by `ref.*`, independent of ChurchTools ids. | Not stored in ChurchTools unless a separate future design explicitly does so. | +| **host binding** | Mapping from `(resource type, logical key)` to one numeric ChurchTools id for one host. | The numeric id remains ChurchTools' record id; the binding itself belongs to ct-cli state. | +| **identity snapshot** | Minimal read-only live properties retained to detect that a binding's meaning changed. | It is not a managed-field snapshot and never causes reconciliation. | +| **owned structural child** | A non-person record whose lifecycle belongs to a top-level ct-cli resource, such as a group member-field definition. | It may live behind a separate ChurchTools endpoint but is not an independent ct-cli resource binding. | +| **relationship** | An edge between top-level objects, such as group hierarchy. | It may not be a standalone ChurchTools record or ct-cli state entry. | +| **coordination scope** | The explicit directory tree inspected by `ct ownership check`. | Not a ChurchTools environment or organisational boundary. | + +When prose could be read in the ChurchTools-specific sense, use the qualified +form **ct-cli resource** or name the concrete type (`group`, `campus`, and so +on). Do not introduce "domain object" as a replacement umbrella term: the +managed set crosses multiple ChurchTools domains and the phrase would suggest a +uniform ChurchTools abstraction that does not exist. + +## Decisions so far + +### 1. Separate portable identity, discovery, and host binding + +An external resource has three distinct identities: + +1. A **logical key** used by portable config references. +2. A minimal **live identity snapshot** used to validate a bound object, plus + non-validating display properties used during candidate selection. +3. A **host binding** from that key to the concrete ChurchTools id on one host. + +The logical `(resource type, key)` pair is the primary portable identity. It is +user-controlled, unique across managed and external entries within one ct +project, and never re-derived after initial creation. Live identity properties +validate the host binding; they do not replace the logical key. + +When `ct use` needs to propose a key, it uses this priority: + +1. If a visible owner project manages the same `(type, id)`, reuse the owner's + key. +2. If the consumer already binds the `(type, id)`, retain that existing key. +3. Otherwise derive a one-time proposal with the registry's `slug()` function + and let the user accept or edit it. + +When the owner is visible, owner and consumers must use the same key for the +same `(type, id)`. `ct ownership check` treats differing aliases as an error and +prints a repair command. A deliberate key change is a separate explicit state +operation: + +```bash +ct state rekey +``` + +It works for managed and external entries, rejects collisions, and warns that +all `ref.*` uses in config must be changed consistently. Neither a live rename +nor a changed ChurchTools id ever changes the logical key automatically. + +`name + groupType` may help discover a group, but it is not guaranteed to stay +unique. It must not be the only permanent identity mechanism. A user needs an +explicit way to select the intended live object when discovery is ambiguous. + +No additional `ct.external.*` config DSL is introduced. `ct use` records the +external declaration and host binding in state; portable config consumes the +logical key through the existing reference DSL: + +```ts +ref.group("vl_ojahr_teilnehmer_aktuell"); +``` + +### 2. External is generic for every managed resource type + +External binding is not a group-only feature. Every top-level resource type +that can receive its own entry in the managed `resources` state map must also be +bindable as external/read-only. At the time of this decision the registry +contains: + +- `campus` +- `group` +- `group-type` +- `age-group` +- `target-group` +- `relationship-type` +- `person-status` +- `department` +- `security-level` +- `comment-viewer` +- `group-role` (the shared role definition) + +The resource registry is the source of truth. Adding a future top-level managed +resource type must either provide the generic external contract or explicitly +explain why the type cannot be referenced externally; external support must not +grow as a second hand-maintained type list. + +Each registry entry supplies or derives external behaviour for: + +- collection and item reads; +- interactive search and candidate display; +- key derivation; +- identity capture and validation; +- useful disambiguating fields; +- logical reference kind and resolution. + +Identity fields are type-specific. They must not blindly reuse every managed +field: a consumer should not be blocked by an unrelated mutable property merely +because the owner manages it. The registry needs an explicit external identity +adapter. Version 1 has no user-defined `match`, `assert`, or optional identity +field selection. Each registry type fixes its hard identity and its +non-validating candidate display: + +| Resource type | Hard identity snapshot | Candidate display only | +| ------------------- | ---------------------- | --------------------------------- | +| `campus` | name | short name | +| `group` | name, group-type id | campus, group status | +| `group-type` | name | translated name | +| `age-group` | name | translated name, sort order | +| `target-group` | name | translated name, sort order | +| `relationship-type` | name | labels for both directions | +| `person-status` | name | short name, member-status meaning | +| `department` | name | short name | +| `security-level` | name | numeric level/id | +| `comment-viewer` | name | sort order | +| `group-role` | name, group-type id | leader/participant type | + +Only hard identity changes block a consumer plan. Display-only changes never +do. Thus moving a group to another campus or changing its status does not block +consumers, while renaming it or changing its group type does. A hard identity +change is accepted explicitly and idempotently by rerunning `ct use` with the +bound id and key; ct shows the field-level identity diff before confirmation. + +This decision covers independently addressable top-level resources. It does not +turn the following into separate external resources: + +- owned structural children such as group member-field definitions; +- synthetic fields such as dynamic-group configuration; +- relationship edges such as group hierarchy; +- permission declarations or grants; +- person-related data, which remains permanently excluded. + +Those categories may contain references to an external top-level resource, but +they do not acquire independent external bindings of their own. + +### 3. Persist external identity separately from managed state + +The existing `resources` map remains exclusively lifecycle-owned resources. +External bindings live in a structurally separate top-level map, for example: + +```json +{ + "version": 2, + "host": "https://example.church.tools", + "resources": {}, + "externals": { + "vl_ojahr_teilnehmer_aktuell": { + "type": "group", + "key": "vl_ojahr_teilnehmer_aktuell", + "id": 4711, + "owner": "shared-masterdata", + "identity": { + "name": "VL OJAHR Teilnehmer aktuell", + "groupTypeId": 17 + }, + "boundAt": "2026-08-27T12:00:00Z" + } + } +} +``` + +An external entry carries no managed-field snapshot and no lifecycle flags such +as `preventDestroy`. `apply` and `destroy` enumerate only `resources`; external +bindings are therefore outside write paths by construction, not merely by a +late conditional check. + +An existing binding is authoritative for that host. Live discovery must never +silently replace it. Changing the binding requires an explicit `use` +command that names the new id and confirms the replacement. + +Binding state is keyed by the portable logical key. The CLI exposes an +interactive discovery form and an explicit, scriptable form: + +```bash +ct use group "OJAHR Fuzzies" +ct use group 4711 --key ojahr_fuzzies +ct state list +ct state rm group ojahr_fuzzies +``` + +`use` is the consumer operation, symmetric with the owner operation `adopt`: + +```text +ct adopt --key # own and manage +ct use --key # consume read-only +``` + +The resource type is mandatory. The two `use` forms are therefore +`ct use ""` and `ct use --key `. + +The search form performs a live fuzzy search within the named resource type and +presents every matching candidate with its id, exact name, and useful +type-specific disambiguators such as group type and campus. It never guesses +when several candidates match. After the user selects one, it proposes a +logical key derived with the existing `slug()` rule (`"OJAHR Fuzzies"` → +`"ojahr_fuzzies"`). The user may accept or edit that key before confirming the +binding. + +The derived key is a one-time proposal, not a live derivation. A later rename of +the ChurchTools resource never changes it. An already-bound `(type, id)` keeps +its existing key, and a derived key colliding with any managed or external key +must be replaced explicitly. + +The search form requires an interactive terminal. In non-interactive use no +candidate or key is inferred; scripts use the deterministic form +`ct use --key `. + +`use` is strictly idempotent: + +- no existing binding for the key or `(type, id)`: validate the live resource, + capture its identity, and create the binding; +- the same key is already bound to the same `(type, id)` and the stored identity + still matches: validate it and return success without changing the state file; +- the same key and id still identify the resource but its identity properties + changed: show the identity diff and replace the snapshot only after explicit + confirmation; +- the key is bound to another id: show the old and new live resources and + replace the binding only after explicit confirmation; +- the `(type, id)` is externally bound under another key: fail rather than + create a second alias; +- the `(type, id)` is managed by this project: fail because a resource cannot + be both managed and external in one project. + +The no-op case must not update a timestamp: repeated `use` commands should +leave the state byte-stable. `boundAt` records creation of the binding, not its +most recent verification. + +`ct state list` shows managed resources and external bindings together by +default, with an explicit ownership/kind column. Optional `--managed` and +`--external` filters may narrow that view, but are never required to obtain a +complete project-state listing. Because managed and external keys are mutually +exclusive within one project, `ct state rm ` can remove either kind +without another mode flag; its output must say whether it removed a managed +state entry or an external binding. Removing state never deletes the live +ChurchTools resource. + +### 4. Resolution and validation + +The intended resolution order is: + +1. If an external host binding exists, read that id live and validate its type + and registry-defined hard identity snapshot. +2. Without a binding, use a visible owner's state and the unresolved logical + key as search hints only to find and describe live candidates for + remediation. +3. Missing, unique-but-unbound, or ambiguous discovery fails before any write + and lists actionable candidates or remedies. +4. A resolved external id is available to every existing reference position, + including dynamic rulesets, parents, and permissions. +5. Resolution never turns the external into a desired resource and never emits + create, update, or delete actions. + +External resources always require a persisted, host-specific binding created by +an explicit `ct use` invocation. Even when discovery finds exactly one live +candidate, `plan` must not consume it ephemerally and must not write the binding +itself. Instead it fails with the candidate's identifying details and the +complete deterministic `ct use --key ` command, followed by the +`ct plan --env ` verification command. This keeps plan read-only, makes the +consumer relationship auditable, and prevents a later same-named resource from +changing resolution. + +An identity mismatch is a hard plan error. Its explicit, local repair is the +same declarative binding command: `ct use --key `. +With the existing id it shows and, after confirmation, accepts identity changes; +with a new id it shows both live resources and replaces the id and identity +snapshot. The operation never writes to ChurchTools. Repeating it after the +desired binding and identity have been recorded succeeds as a no-op. + +### 5. Missing owner resources block the consumer + +An external prerequisite is never a pending consumer resource. `PendingRef` +means that the current apply is authorised and able to create the target later +in its own dependency graph; neither is true for a resource owned by another ct +project. + +If the owner project has not materialised the resource, consumer `plan` fails +before any write and identifies the owner and the next steps. Consumer `apply` +must not start with an unresolved external prerequisite, and ct never applies a +different project automatically. + +Diagnostics distinguish at least: + +- the visible owner state has no managed resource: apply the owner project + first; +- the owner state carries an id but the live read returns `404`: the owner state + is stale or the resource was deleted outside ct; +- the declared owner is not present below the explicitly analysed coordination + root: ownership cannot be verified there; +- the live resource exists but the consumer lacks its external binding: run + `ct use` in the consumer project. + +When the visible project layout permits it, the error may print concrete `cd`, +`ct plan`, `ct apply`, and `ct use` commands. It remains diagnostic: +cross-project apply ordering or execution is a separate future orchestration +feature, not part of #143. + +#### Actionable diagnostic contract + +A generic "cannot resolve external resource" message is insufficient. Every +blocking external-resource diagnostic must contain: + +1. **Context:** external type and key, consumer project, declared owner project, + environment, and ChurchTools host. +2. **Evidence:** which config, managed state, external state, and live lookup + were inspected, and the exact missing, stale, ambiguous, or mismatching fact. +3. **Consequence:** that consumer plan/apply is blocked before writes, and that + the consumer will not create or repair the owner's resource. +4. **Numbered remediation:** copyable commands using the discovered project + paths, environment, type, key, and id wherever those values are known. +5. **Verification:** the exact command to rerun after the repair. + +For example, when a visible owner has not materialised the group: + +```text +External prerequisite is not available + + resource: group "ojahr_fuzzies" + consumer: ojbp + owner: shared-masterdata + environment: prod + host: https://example.church.tools + +The owner config declares the group, but its prod state contains no managed id. +The consumer is blocked before writes and will not create the owner's group. + +Next steps: + 1. cd ../shared-masterdata + 2. ct plan --env prod + 3. Review the owner plan, then run: ct apply --env prod + 4. cd ../ojbp + 5. ct use group --key ojahr_fuzzies + 6. ct plan --env prod +``` + +The remediation changes by cause: + +- owner state id returns live `404`: run owner `plan` first and explain that its + state may be stale; do not suggest binding the missing id in the consumer; +- owner is outside the supplied coordination root: show how to rerun + `ct ownership check --env ` or correct the declared owner; +- owner resource exists and only the consumer binding is missing: print the + complete deterministic `ct use --key ` command; +- discovery is ambiguous: list every candidate with identifying properties and + print a complete bind command for each candidate; +- bound identity changed: show the field-level identity diff and print the bind + command that accepts the current id after confirmation. + +Paths and commands must be derived from the inspected coordination root rather +than hard-coded examples. The application layer should expose stable reason +codes and structured remediation details so terminal, future UI, and machine +readable output can present the same diagnosis without parsing prose. + +### 6. Ownership checks within an explicit coordination scope + +No local project can discover claims in unknown repositories. When several ct +projects are visible under an explicitly supplied directory, however, ct can +compare all of their environment states. + +From `processes/ojbp`, this command defines `processes` as the complete visible +coordination scope for this invocation: + +```bash +ct ownership check .. --env prod +``` + +The command recursively discovers ct projects below the explicit root while +ignoring unrelated directories such as `.git`, `node_modules`, and build +outputs. No directory outside the supplied root is searched. + +For each ChurchTools host it reports at least: + +- the same `(type, id)` managed by two projects: **error**; +- a resource managed by one project and consumed externally by others: **ok**; +- an external declaration naming an owner that does not manage the bound + resource: **error**; +- conflicting logical bindings or incompatible host data: **error**. + +Ownership conflicts produce a non-zero exit code so the analysis can be a CI +gate. Projects or repositories outside the explicit root remain unknowable; a +truly global guarantee would require a shared registry with atomic claims. + +An explicit directory is sufficient for the first implementation. A workspace +manifest may be added later for stable project ids, inclusion, or exclusion, +but is not required initially. + +## Non-goals for the first implementation + +- Importing another project's complete managed state as consumer state. +- Claiming that a directory scan can detect projects outside its explicit root. +- Silently taking lifecycle ownership as a side effect of reference resolution. +- Writing ownership markers into ChurchTools without a separate design and an + appropriate ChurchTools metadata contract. From 50d750b95ca8610d70e5da203a19a8a56871cae4 Mon Sep 17 00:00:00 2001 From: Bernhard Weichel Date: Sat, 29 Aug 2026 10:40:01 +0200 Subject: [PATCH 4/4] docs: reconcile external reference guidance with main --- docs/handbuch/blueprints.md | 4 ++-- docs/handbuch/dynamic-groups.md | 4 ++-- docs/handbuch/group-member-fields.md | 4 ++-- docs/handbuch/permissions.md | 4 ++-- src/engine/hierarchy.ts | 12 ++++++------ 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/handbuch/blueprints.md b/docs/handbuch/blueprints.md index 4345173..fb933c1 100644 --- a/docs/handbuch/blueprints.md +++ b/docs/handbuch/blueprints.md @@ -4,8 +4,8 @@ sources: - src/config/context.ts - src/engine/graph.ts - src/engine/hierarchy.ts -sources_hash: 6f4be8d3a93113ce -reviewed: 2026-08-28 +sources_hash: 3effed6bfffdc517 +reviewed: 2026-08-29 --- # Blueprints (parametrized, reusable config) diff --git a/docs/handbuch/dynamic-groups.md b/docs/handbuch/dynamic-groups.md index b2c142f..ea6a26c 100644 --- a/docs/handbuch/dynamic-groups.md +++ b/docs/handbuch/dynamic-groups.md @@ -6,8 +6,8 @@ sources: - src/engine/dynamic.ts - src/engine/synthetic.ts - src/application/operations/adopt-group.ts -sources_hash: e38b8c0f6032d5cc -reviewed: 2026-08-28 +sources_hash: efd69354990646f0 +reviewed: 2026-08-29 --- # Auto-groups (dynamic groups) diff --git a/docs/handbuch/group-member-fields.md b/docs/handbuch/group-member-fields.md index 1da01b5..49ab34f 100644 --- a/docs/handbuch/group-member-fields.md +++ b/docs/handbuch/group-member-fields.md @@ -1,5 +1,5 @@ --- -sources_hash: c18b710bff24503d +sources_hash: f95a3a4de0e47932 title: Group member fields sources: - src/engine/member-fields.ts @@ -7,7 +7,7 @@ sources: - src/config/context.ts - src/application/operations/adopt-group.ts - src/application/operations/destroy.ts -reviewed: 2026-08-28 +reviewed: 2026-08-29 --- # Group member fields (#135, #158) diff --git a/docs/handbuch/permissions.md b/docs/handbuch/permissions.md index 6c2f956..e298c96 100644 --- a/docs/handbuch/permissions.md +++ b/docs/handbuch/permissions.md @@ -7,8 +7,8 @@ sources: - src/resolve/resolver.ts - src/resolve/refs.ts - src/config/context.ts -sources_hash: c3a6740c4d4bc134 -reviewed: 2026-08-28 +sources_hash: 4d424efd9bf1b459 +reviewed: 2026-08-29 --- # Permissions (`ct.groupRole` / `ct.groupTypeRole` / `ct.status`) diff --git a/src/engine/hierarchy.ts b/src/engine/hierarchy.ts index e28d8ca..e5f5c64 100644 --- a/src/engine/hierarchy.ts +++ b/src/engine/hierarchy.ts @@ -3,10 +3,10 @@ * can have several parents. `GET /groups/hierarchies` returns, per group, the * ids of its parents and children. * - * We surface hierarchy in the plan as an opt-in `parents` set-field on a group, - * resolved to logical keys and **restricted to managed groups** — an edge to an - * unmanaged group is invisible (managed-guard), never diffed or proposed for - * removal. + * We surface hierarchy in the plan as an opt-in `parents` set-field on a managed + * group. Parent ids are mapped only when state gives them a logical key, either + * as another managed group or as an explicit external group binding. Every other + * live edge stays invisible (managed-guard), never diffed or proposed for removal. */ import { externalResources, type State } from "../state/state.js"; @@ -30,8 +30,8 @@ export function parentIdsByGroupId(entries: HierarchyEntry[]): Map): string[] { const keys: string[] = [];