ADR-0033: ChangePlan is a Versioned Structural Contract
Status: Accepted
Date: August 2026
Context
Commands that change a project on the user's behalf (a deterministic add, a
versioned upgrade) need a shape for "a proposed change." Without it, a command
cannot show what it would do, ask for confirmation, and then apply the same thing
it showed. The change has to be data first: something you can build, print, diff,
store, and only later execute.
Two properties matter. The change must be inert until applied: producing it
touches no files, so a preview is safe to run and a plan is safe to pass around.
And the change is a contract between producers and a consumer that ship on
different schedules: the add engine and versioned upgrades both emit plans,
and an applier (a later epic) executes them. A contract read by code that was
written against a different version of it has to fail loudly rather than
misread, so the shape is versioned.
The add/change machinery is neither IR nor CLI glue, so it gets its own package,
protean.scaffold, that later scaffolding work populates.
Decision
We define a ChangePlan: an ordered tuple of operations plus an optional
description, homed in protean.scaffold.
The operation set is a discriminated union tagged by a kind field, with three
variants:
- create (
kind="create") carries apathand the whole filecontent. - edit (
kind="edit") carries apathand adiff, a unified-diff string. Unified diff is reviewable and standard; a structural (LibCST) applier can come with the patch engine later without changing this shape. - config (
kind="config") is a structured key-path set/merge overdomain.toml: an orderedkey_path(e.g.("databases", "default", "provider")), avalue, and anoperationof"set"or"merge". Config is structured data, not a text diff. A line-context diff over a reformatted, commenteddomain.tomlbreaks easily on the one operationaddruns most, and it would contradict the tomlkit-for-config direction the program has already committed to.
The plan follows the existing serialization house style, matching protean.ir: a
frozen dataclass serialized by an explicit to_dict/from_dict JSON dump, not
pydantic (pydantic is reserved for domain elements). The serialized form carries a
plan_version marker, and a versioned JSON Schema lives at
schema/v<version>/schema.json, starting at 0.1.0. The schema pins
plan_version to its own version with const, so validating a plan against the
wrong schema fails there too, not just in from_dict. from_dict rejects a
plan_version it does not understand, a non-object entry in operations, an
unknown operation kind, and any missing or wrongly-typed required field with a
clear ValueError, the same way load_config and load_stored_ir reject
malformed input. to_json sorts its keys, so the same plan always serializes to
the same bytes and previews and stored plans diff cleanly.
Preview is read-only and separate from an operation's representation. A
render_preview(plan) function builds a human-readable summary as pure strings: a
create shows a header plus its content, an edit shows its unified diff, and a
config op shows key.path = value (set|merge). The renderer opens, stats, and
creates nothing.
Applying a plan is out of scope here. This issue defines the shape and the
preview; the transactional applier is a later epic, and the tomlkit dependency
that writes domain.toml lands with the config applier, not now.
Consequences
- A command can build one
ChangePlan, preview it, and hand the same object to an applier, so show and apply stay in sync because they read one shape. - The preview is safe to run anywhere: it is provably read-only, so it needs no confirmation and no rollback.
- Versioning the schema means an old build meeting a newer plan fails loudly
instead of silently misapplying it. The cost is that
PLAN_VERSION, the schema directory name, and the schema'splan_versionconst must all be bumped together whenever the shape changes. - Keeping config structured means the applier works against parsed config, not
text, so a reformatted or commented
domain.tomlcannot break a config edit. The cost is that config is not one uniform "diff" with file edits; a renderer and an applier each special-case it. - Deferring the applier leaves a shape with no executor in this epic. That is
deliberate: the contract is designed alongside its first consumer (the
addengine) rather than in isolation.
Alternatives Considered
- A text diff for config. Rejected: a line-context diff over a reformatted,
commented
domain.tomlis fragile, and it contradicts the committed tomlkit-for-config direction. Config stays structured. - pydantic models for the plan. Rejected for consistency: the IR serialization house style is frozen dataclasses with an explicit JSON dump. pydantic is reserved for domain elements.
- One combined "file operation" covering create and edit. Rejected: a create carries whole content and an edit carries a diff; they are different shapes, and a discriminated union keeps each one's required fields honest.