Skip to content

docs: define the resource-independent adoption contract (#141) - #147

Merged
2000game merged 2 commits into
mainfrom
docs/141-adoption-contract
Aug 24, 2026
Merged

2000game merged 2 commits into
mainfrom
docs/141-adoption-contract

Conversation

@2000game

@2000game 2000game commented Aug 24, 2026 •

Copy link
Copy Markdown
Member

Adds docs/adoption-contract.md — one resource-independent contract for what comes along when ct adopt adopts something, replacing the per-resource-type argument. Decision only; no adoption code changes.

The five categories

# Category Default Switch
1 Root resource — what the user selected adopted none (it is the argument)
2 Owned structural children — non-person records whose lifecycle belongs to the root (group member fields, event registration groups) adopted --no-<child> to opt out
3 Relationships — hierarchy edges, resource bookings adopted, both-ends-managed only; an edge to an unmanaged object is dropped and reported, never a reason to adopt that object --no-<relationship>
4 Shared referenced resources — campuses, calendars, rooms, groups inside a ruleset never adopted, at any depth, under any flag — emitted as a portable ref if already managed, else numeric id + TODO none, deliberately
5 Person-related data never read, never managed none, ever

The both-ends rule in row 3 is forced by the engine, not chosen: applyHierarchy diffs parents only over managed parents, so an edge to an unmanaged group would be a phantom change every plan re-proposes. Row 4 has no opt-in because a state entry is a claim of lifecycle ownership — the exact silent claim #143 exists to prevent. The remedies are an explicit second ct adopt, or (once #143 lands) an external/read-only declaration.

Other decisions

  • Switch style — negatable --no-* for default-on, --with-* for default-off, plus --minimal defined as "root only" (not "today's defaults"), which is what makes a future default flip non-breaking for scripts.
  • Recursion — selection recursion (--children-of) stays unbounded and cycle-guarded; composition recursion is depth 1 and never transitive, so cycles are structurally impossible there.
  • Failures — three meanings, three behaviours: a per-root 404 meaning "no such child" is silently omitted (already how captureDynamic works); an unsupported endpoint warns once and continues; a denied/transient read (401/403/429/5xx) fails the run — a composite written from a partial read is a wrong blueprint that looks complete. Re-running with --no-<category> / --minimal succeeds, so the incompleteness is a recorded decision. Mirrors plan degrades into a WRONG plan under HTTP 429 instead of aborting #126's unreadable seam on the plan side.
  • Output — one fixed five-verb vocabulary (adopted / referenced / unmanaged / skipped / excluded), identical in --dry-run, following adopt grants --all-declarable's "never a silent cap" discipline. excluded is a constant line, not a count — counting person records would mean reading them.
  • Backwards compatibility — an opt-in ships first and keeps working forever as a deprecated no-op (--portable-rulesets precedent, bug(dynamic): unportablized ruleset ids are written silently — wrong-host rulesets fail quietly, not loudly #101); the negation ships in the same release as the flip; a flip is a feat:, never BREAKING CHANGE:. Justified structurally: a default flip changes only what adopt emits, never what plan/apply do to an existing config — the config file stays the sole source of truth for what is managed.

Worked examples

Group and event tables walk every item #141 listed. The group table shows the concrete gap: --children-of today gets the roots, the fields and the exclusions right, and loses the shape (no hierarchy captured at all) and the member fields.

Events are explicitly not committed to as an adoptable type — runbook-manual-surface.md puts them outside the mandate. The example is there to show the contract answers the question before the code exists, which is the point of #141: booking = category 3, the booked room = category 4, and that falls out of the categories with no new judgement.

Follow-ups (listed in the doc, not done here)

Shared adoption-scope module; a composite descriptor on AdoptableResource so adoption is table-driven like SYNTHETIC_FIELDS; hierarchy capture in adopt; promoting --with-dynamic to default-on; the five-verb summary; #143.

#135 is unblocked and unchanged — it should ship exactly as specified, opt-in --with-member-fields, treating member fields as a category-2 owned child (scoped identity, no host ids, member rows never read) and stating in the help text that the opt-in is transitional. The flip to default-on is a separate follow-up gated on the shared flag surface existing, so --no-member-fields can ship in the same release.

Closes #141.

https://claude.ai/code/session_018JShVZYNLaRb4hF5KbHCXG

`ct adopt` followed technical API boundaries rather than the domain object
users see, and the grouping was re-decided per resource type: hierarchy is
not captured at all, dynamic rulesets need --with-dynamic, member fields are
proposed as --with-member-fields in #135.

Records the one contract instead: five categories (root, owned structural
children, relationships, shared referenced resources, person-related data)
with a default per category, the switch style, recursion and cycle rules,
failure classification, output vocabulary, and the policy for promoting an
opt-in to a default. Worked through the group and event examples, with the
implied follow-up work and how #135 applies it.

Closes #141.

Claude-Session: https://claude.ai/code/session_018JShVZYNLaRb4hF5KbHCXG
@2000game
2000game merged commit d678bc5 into main Aug 24, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

design: define aggregate adoption boundaries for composite resources

1 participant