ADR 0027: Plan/apply as the engine's execution model
Amendments.
- Vocabulary: current pointers use
standards(formerlyratchets),done(formerlyfinish),accept(formerlygraduate), andimpactwherescopesnames the verb; the decision and reasoning are unchanged.- ADR 0028 — plan locations: the plan vocabulary (
StepKind,EnginePlan,renderPlan,planToJson) was consolidated intosrc/shared/result.tsalongside the result envelope, and the per-verb plan builders live insrc/engine/gate/plan.ts,src/engine/gate/standard_plan.ts, and the worktree lifecycle. The decision stands; only the file locations moved (the citedengine/plan/*paths below are updated to match).- ADR 0335 — preview enrollment: the live
OPERATION_EFFECTSregistry decides which command paths require--dry-run; the class guard no longer infers policy from the flag. Applied Discern effects must be a subset of the plan, while later runtime facts may safely skip or refine planned work and project-authored commands remain bounded by invocation disclosure.- Result serialization (2026-09-03): applied commands serialize their typed
DiscernResultthroughserializeResult; previews serializeEnginePlanthroughplanToJson. The unused transitionalresultsToJsonhelper was retired before v1.
Status: accepted
Context
§discern is two halves that share one binary: the installer (src/commands/**) and the engine (src/engine/**). The installer half already had the right execution shape. setup/preset compute a pure Plan — a flat list of operations, built before anything is written (src/lib/fs_plan.ts) — and a thin executor applies it, with rendering kept separate (src/lib/plan_view.ts) so planning stays pure. That one split is why those verbs get --dry-run, a review screen, and near-free idempotency.
The engine half never adopted it. Every effectful engine verb computed and executed in a single imperative pass:
- The gate (
gate/finish.ts) loaded config, queried git, derived jobs, spawned subprocesses, and formatted output in one function — and then re-derived the--jsonreport by callingjobsInStageagain after execution, rather than serializing what it actually ran. accept,worktreeSetup,worktreeTeardown,worktreePrune(worktree/lifecycle.ts) were imperative effect-chains. The scary, stateful ones (prune's GC; the teardown / checkout or fast-forward dance inaccept) fired with no inspectable preview — onlyprunehad an ad-hoc--dry-run.
The costs of that asymmetry:
- No
--dry-runon the verbs that most need it. You could not preview a acceptance or a prune-GC pass before it mutated git and destroyed resources. --jsonwas reconstructed, not serialized. The gate report re-walked the config after execution; a divergence between "what ran" and "what was reported" was a latent bug the structure invited.- The decision logic was unreachable without spawning subprocesses. Testing "given this config and these changed scopes, which jobs run" meant scaffolding a temp repo and driving the CLI — 100–500 ms per assertion instead of 1–5 ms.
Decision
§Generalize the installer's plan/apply pattern across the engine seam. Every effectful engine verb now computes a pure plan first, then a thin executor applies it; --dry-run renders the plan and touches nothing, and --json is a serialization of (plan, results), never a re-derivation.
Engine ops are not filesystem writes
§We did not force-fit fs_plan.ts's PlanOp (whose kind is write/merge/append). We modelled the shape — a pure data description with a disposition + note, separate from both the executor and the renderer — but gave the engine its own op vocabulary in src/shared/result.ts: a StepKind of job / scope-gate / merge-check / resource-create / resource-destroy / git / setup-step / env / refresh / standard, with a StepDisposition of run (will act) / skip (in the plan but won't act — a configured-but-unchanged scope gate) / gate (a read-only precondition that can block but mutates nothing — the merge check).
Each verb keeps its own typed plan (a GatePlan carries job groups with commands; an AcceptPlan carries the diagnosed git state) so its executor stays type-safe, and projects that plan to a common EnginePlan for presentation. A gate plan and a worktree plan are different types sharing one renderer.
Where the read/effect line sits
§Following the fs_plan precedent: planning may do read-only I/O — load the typed config, classify the changed scopes, query the merge status, read the resource ledger. The executor owns every mutation and every job spawn. Then, only where it pays, the decision logic is factored into pure functions that take state as arguments and run with zero I/O:
- gate job derivation and scope-gate selection —
planStageJobs,planScopeGates,buildGatePlaningate/plan.ts; - the prune-GC reclaim decision —
classifyOrphansinworktree/resources.ts, the load-bearing safety logic (an entry is kept when its worktree is live by git_key, path, OR resource handle, or it opted out of GC; everything else is reclaimable), now rebuilt under the effectfulgcOrphanResourcesand unit- tested directly; - standard derivation —
buildStandardPlaningate/standard_plan.ts.
We did not dogmatically inject every read. The plumbing (resolving a git dir, reading a ledger file) stays where it is; only the decisions were lifted out.
One renderer
§src/shared/result.ts is the engine mirror of plan_view.ts: renderPlan and planToJson own plan presentation, while serializeResult owns applied-result JSON. Every converted verb routes its --dry-run and --json through these authorities. It writes through a minimal RenderSink that both the gate's Out and the installer's Logger implement (via outSink / loggerSink), which begins collapsing discern's two presentation paths into one — a welcome side-effect, not the goal.
The gate plan is a substrate for the next thread
§The gate plan carries its stages as data — an ordered list of JobGroup ({stage, mode: serial|parallel, jobs}) — not baked into the type. The current gate still builds that list by unrolling fix → build → check∥test → scope-gates by hand, but a future change can resolve a needs/provides DAG by topological sort and produce the same GatePlan; the executor and the report never learn which produced it.
done --json is preserved byte-for-shape
§The published report contract — { ok, jobs:[{name,kind,stage,status,duration_s}], scope_gates:[…], scopes_changed:[…], failed_stage } — is now built by buildGateReport, which serializes the plan it executed plus the per-job results (walking the plan's groups, looking each job up by label, missing → skipped). It is no longer re-derived from the config. The existing done --json tests stay green unchanged.
Consequences
§--dry-runworks on every effectful verb —done,accept,worktree(setup),worktree teardown,worktree prune, andstandards— free, because the plan is computed before any effect. Each also accepts--dry-run --json(the plan as JSON) and, when applied,--json(a serialization of plan + results). The pure-query verbsidentityandimpact, and the thinprepare/testgate variants, were left alone — they have no apply step to plan.- The planners are unit-tested without a subprocess.
gate_plan_test.ts,worktree_plan_test.ts, andstandard_plan_test.tsexercise the whole "what would run / what would be reclaimed / how does it serialize" decision in ~1 ms per assertion (a full file in single-digit milliseconds), versus ~150–200 ms for the subprocess-driven integration tests, which stay as the behavioural-parity oracle. - No schema bump, no migration, and no
MIGRATION_PROMPT.md. Unlike the typed-config change, which tightened the validation a project's config could trip on, this is internal control- flow restructuring: it changes no config shape, no on-disk format, and no project file.SCHEMA_VERSIONis untouched. A dogfooding project needs nothing but the rebuilt binary — so, deliberately, none was written. - One intended human-output change.
worktree prune --dry-runnow renders the shared plan listing instead of the old per-function "Would reclaim N …" narration. Its scan stays read-only and its apply narration is byte- identical; only the dry-run preview text changed (folding prune's pre-existing--dry-runinto a real plan rather than leaving a parallel path was the point). Every other existing human string and thedone --json,scopes --json, andidentity --jsoncontracts are unchanged. - Scope timing is preserved exactly. The gate's apply path still classifies the changed scopes after the stage groups run (
buildStageGroups→scopes→scopeGatesGroup), not before — becausescopesreads the working tree, which a fix-stage codemod can mutate, and classifying earlier could run fewer scope gates than the post-fix tree warrants, against the fail-open bias.--dry-runclassifies once, read-only (it runs no fixers, so the tree it sees is the one the apply would start from); the purebuildGatePlancomposes the same two halves for that preview and for the unit tests. - One genuinely safe ordering shift. Acceptance now creates the worktree's branch in the executor (after the read-only preconditions pass) rather than during diagnosis, so a detached+behind worktree fails the precondition before a branch is created rather than after — strictly better. The only observable consequence is cosmetic: in the doubly-rare detached-HEAD-plus-dirty-main case, the refusal message names the base branch (
agent/foo) rather than a disambiguated created one (agent/foo-a1b2c3d4).
Alternatives considered
§- Force-fit
fs_plan.ts'sPlanOp. Rejected: the engine's operations are job spawns, resource lifecycles, and git surgery, not file writes. Sharing the shape (pure description + disposition + separate executor and renderer) while giving the engine its own op vocabulary keeps each side honest. - One universal engine plan type. Rejected: a gate plan (job groups) and a accept plan (diagnosed git state) carry genuinely different data. Letting each verb keep its typed plan and project to a common
EnginePlanfor rendering keeps the executors type-safe without a lowest-common-denominator union. - Keep prune's dry-run as a parallel
if (dryRun)path. Rejected: that is the exact "compute-and-execute, plus a second branch that lies a little" shape this ADR removes. Prune now builds one plan and either renders or applies it; the resource-GC decision (classifyOrphans) is shared by the preview and the real reclaim, which is what de-risks the future resource-ledger work — a GC pass is inspectable before it destroys anything. - Inject every read to make the verbs fully pure. Rejected as dogma: factoring the decisions (job derivation, GC reclaim) buys the fast tests; injecting the plumbing (git-dir resolution, ledger reads) would add ceremony for no test or clarity gain.
This generalizes the installer's fs_plan pattern across the engine seam, preserves ADR 0004's structured-output contract, and deliberately shapes the gate plan so a future job-graph resolver can produce it.