Skip to content

feat: support read-only external references across ct projects #143

Description

@bwl21

Context

Independently managed ct projects may consume the same ChurchTools object. One project owns its lifecycle; other projects need its host-specific id in portable ref.* positions without gaining permission to create, update, or delete it.

This issue introduces a generic external/read-only binding for every independently addressable top-level type in ct-cli's resource registry. It implements the category-4 boundary from the adoption contract: shared referenced objects are never adopted transitively.

Terminology

ChurchTools has no single official umbrella term for the set of things ct-cli manages. In particular, ChurchTools uses “Resource” for different product/API concepts, and “domain object” is not a suitable replacement.

  • ct project — one config plus its environment-specific state files; one lifecycle boundary. It may be a directory in a monorepo or its own repository.
  • ct-cli resource — an independently addressable top-level object represented by one resource-registry entry and, when managed, one state entry. User-facing documentation should qualify “resource” where it could be confused with ChurchTools terminology.
  • managed — this ct project owns lifecycle responsibility; plan, apply, and explicit destroy may act on the object.
  • external — this ct project may resolve and consume the object read-only but does not own its lifecycle. No ChurchTools flag is implied or written.
  • owner project — the one ct project whose managed state claims lifecycle responsibility.
  • consumer project — a ct project with an external binding to the owner’s object.
  • logical key — portable, user-controlled identity used by ref.*; never stored in ChurchTools.
  • host binding(resource type, logical key) → ChurchTools id for one host.
  • identity snapshot — minimal read-only live properties used to detect changed binding meaning; never reconciled.
  • coordination scope — the explicit directory tree inspected by ct ownership check; not a ChurchTools boundary.

Required model

An external object has three identity layers:

  1. The logical key used by portable config.
  2. A minimal live identity snapshot used to validate the binding, plus non-validating display properties used during selection.
  3. The host-specific ChurchTools id.

The logical key is the primary portable identity. It is globally unique across managed and external entries in one ct project and is never recalculated after initial creation.

No ct.external.* config DSL is introduced. ct use records the declaration and binding in state. Config consumes it through the existing reference DSL, for example:

ref.group("vl_ojahr_teilnehmer_aktuell");

Generic type coverage

External binding is on the same abstraction level as adopt and must work for every independently addressable top-level type in RESOURCES:

  • campus
  • group
  • group-type
  • age-group
  • target-group
  • relationship-type
  • person-status
  • department
  • security-level
  • comment-viewer
  • group-role (shared role definition)

The registry is the source of truth; do not create a second hand-maintained type list. A future top-level managed type must provide the generic external adapter or explicitly explain why it cannot be externally referenced.

Each registry entry provides or derives:

  • collection and item reads;
  • interactive search and candidate display;
  • key derivation;
  • hard identity capture and validation;
  • display-only disambiguators;
  • logical reference kind and resolution.

This does not create independent bindings for owned structural children, synthetic fields, relationship edges, permissions/grants, or person-related data. Those structures may reference an external top-level object.

Identity contract

Do not reuse all managed fields as identity. Version 1 has no user-selectable match, assert, or optional identity fields.

ct-cli 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 consumer plan. Display-only changes never block. For example, changing a group’s campus or status is visible but does not block; changing its name or group type does.

State model

Bump state to version 2 and migrate version 1 in memory with an empty externals map. Preserve the existing resources map as managed-only.

{
  "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"
    }
  }
}

owner is optional coordination metadata. Ownership analysis derives the actual owner from visible managed states and validates an owner hint when present.

An external entry has no managed-field snapshot and no lifecycle flags such as preventDestroy. apply and destroy enumerate only resources; external bindings are outside write paths by construction.

CLI contract

use is the consumer verb, symmetric with the owner verb adopt:

ct adopt <type> <id>       --key <key>  # own and manage
ct use   <type> <selector> --key <key>  # consume read-only

The resource type is always mandatory.

Interactive discovery

ct use group "OJAHR Fuzzies"

The string is a fuzzy search within the named type. Show all candidates with id, exact name, and the registry-defined display fields. Never guess when multiple candidates match. After selection, propose a key in this order:

  1. Reuse the visible owner project’s managed key for the same (type, id).
  2. Retain the consumer’s existing key for an existing binding.
  3. Otherwise derive a one-time slug() proposal and let the user accept or edit it.

The interactive form requires a TTY.

Deterministic/scriptable form

ct use group 4711 --key ojahr_fuzzies

Non-interactive use never infers a candidate or key.

Idempotency and conflict rules

ct use is strictly idempotent:

  • no binding: validate live, capture identity, create binding;
  • same key/id and matching identity: successful byte-stable no-op;
  • same key/id with changed hard identity: show field diff and update snapshot only after explicit confirmation;
  • same key with a different id: show old and new live objects and replace only after explicit confirmation;
  • (type, id) already external under another key: error;
  • (type, id) already managed by this project: error;
  • key collision with any managed or external entry: error.

A no-op must not update a timestamp. boundAt records creation of the binding, not verification.

State commands

ct state list shows managed and external entries together by default with an explicit kind/ownership column. Optional --managed and --external filters may narrow the view.

ct state list
ct state rm group ojahr_fuzzies
ct state rekey group old_key new_key

Because keys cannot collide across managed and external entries, state rm and state rekey can locate either kind without a mode flag. Output must say which kind changed. State removal never deletes the live ChurchTools object. Rekey rejects collisions and warns that all ref.* uses must change consistently.

Resolver and plan semantics

  1. A bound external is read live by id and checked against the registry-defined hard identity.
  2. A resolved external id is available in every existing reference position, including dynamic rulesets, hierarchy parents, and permissions.
  3. External objects never become desired resources and never emit create, update, or delete actions.
  4. destroy can never delete an external object.
  5. An unbound external prerequisite always blocks plan, even if live discovery finds exactly one candidate.
  6. plan may discover and describe candidates for remediation but remains read-only and never persists or consumes an ephemeral binding.

For a unique unbound candidate, print its identifying details and the complete deterministic command:

ct use <type> <id> --key <key>
ct plan --env <env>

Missing owner resources

An external prerequisite is never a consumer PendingRef. PendingRef means the current apply is authorised and able to create the target later; neither is true for another project’s object.

If the owner has not materialised the object, consumer plan fails before writes and apply must not start. ct never applies another project automatically.

Distinguish:

  • visible owner state has no managed object: apply the owner first;
  • owner state has an id but live read returns 404: owner state is stale or the object was deleted outside ct;
  • declared owner is outside the explicit coordination scope: ownership cannot be verified there;
  • live owner object exists but consumer binding is missing: run ct use in the consumer.

Cross-project plan/apply ordering is a separate future orchestration feature.

Actionable diagnostics

Every blocking diagnostic must include:

  1. Context: type/key, consumer, owner when known, environment, and host.
  2. Evidence: inspected config/state/live sources and the exact missing, stale, ambiguous, or mismatching fact.
  3. Consequence: consumer plan/apply is blocked before writes and will not repair the owner’s object.
  4. Numbered remediation: copyable commands using discovered paths and known values.
  5. Verification: the exact command to rerun.

Examples and remedies must be cause-specific:

  • stale owner id: direct the user to owner plan; never suggest binding the missing id;
  • owner outside scope: show ct ownership check <broader-root> --env <env> or correction of owner metadata;
  • missing consumer binding: print the complete ct use <type> <id> --key <key> command;
  • ambiguous discovery: list all candidates and one complete ct use command per candidate;
  • identity mismatch: show a field-level diff and the ct use command that accepts the current id after confirmation.

Expose stable reason codes and structured remediation details from the application layer so CLI, future UI, and machine-readable output share the same diagnosis without parsing prose.

Ownership analysis

From processes/ojbp:

ct ownership check .. --env prod

The explicit path defines the complete coordination scope for the invocation. Recursively discover ct projects below it, ignoring .git, node_modules, and build output. Search nowhere outside it.

Group results by ChurchTools host and report at least:

  • same (type, id) managed by two projects: error;
  • managed by one project and external in consumers: ok;
  • owner hint does not match visible managed owner: error;
  • same visible (type, id) uses different keys across owner/consumers: error with ct state rekey remediation;
  • conflicting bindings or incompatible host data: error.

Conflicts produce a non-zero exit code for CI. Projects outside the explicit root remain unknowable; a truly global guarantee would require a shared registry with atomic claims. A workspace manifest is not required for version 1.

Implementation requirements

  • Extend state validation/migration, state list/rm, and add generic rekey support.
  • Add the generic external adapter to the resource registry; do not branch per type throughout commands/resolver.
  • Add ct use as an application operation plus terminal presentation, following the application-boundary architecture.
  • Extend the shared resolver to consult managed state, then external bindings, without ever adding externals to desired/execute/destroy inputs.
  • Add structured external error reason/remediation data at the application layer.
  • Add ct ownership check <root> --env <env> with recursive project discovery and host grouping.
  • Extend logical reference coverage so every top-level registry type can be consumed where a reference position supports it.
  • Update user documentation and include the terminology boundary above.

Acceptance criteria

  1. One project manages an object and another uses it through a persisted external binding on the same host.
  2. The same portable config works across environments after one explicit ct use binding per host.
  3. Every current top-level RESOURCES type supports ct use, state listing, identity validation, and ownership analysis through the generic contract.
  4. Consumer refs resolve to the bound host id in rulesets, parents, permissions, and other supported reference positions.
  5. An external object never appears as create/update/delete, receives no consumer write, and cannot be deleted by consumer destroy.
  6. Missing, ambiguous, stale, and identity-mismatched bindings fail before writes with structured, actionable remediation.
  7. ct use is byte-idempotent for an unchanged binding.
  8. Hard identity changes block until explicitly accepted by ct use; display-only changes do not block.
  9. Managed/external collisions within a project are rejected.
  10. ct ownership check .. --env prod detects duplicate owners and key mismatches across visible projects and returns non-zero on conflicts.
  11. Version-1 states load as version 2 with no external entries and no loss of managed state.
  12. Unit tests are registry-parameterised so a newly added top-level resource type cannot silently omit external support.

Non-goals

  • Importing another project’s complete managed state as consumer state.
  • Searching or claiming knowledge of projects outside an explicitly supplied coordination scope.
  • Automatically applying another project.
  • Silently taking lifecycle ownership while resolving a reference.
  • Writing ownership markers into ChurchTools.

Metadata

Metadata

Assignees

No one assigned

    Labels

    triageUnsorted intake — decide in the weekly sweep

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions