❯ Architecture
Seven steps. One of them refuses, even in a dry run.
Almost every command in cwp is one pipeline with the same seven named steps. Naming them is what turns guard placement from something each command remembers into a property of the runtime — and it is what makes the refusal survive a dry run.
This describes cwp v1.0, which is being built. The rebuild is under way on a branch and supersedes the current design section by section as each phase lands. Read it as the target rather than as a description of the released tool.
The spine
Every effectful command is an instance of the same seven steps. Two of them behave in opposite ways under --dry-run, and that is not a detail: Refuse runs, Consent does not. Merge them into one guard phase — the obvious simplification — and cwp push prod --dry-run stops refusing and starts printing a plan for a write it would never permit.
Pick a command, then set the conditions. The refusal fires under a dry run; only consent, apply and assert stand down.
- SurveyRead-only. Runs even under --dry-run.
- PlanA pure value. May carry unresolved slots and explicit unknowns.
- RefuseAlways runs, including under --dry-run.
- ConsentSkipped under --dry-run — there is nothing to consent to.
- ApplyA fold over the effects. Each one verified as it lands.
- AssertPost-conditions that must hold on every path.
- ReportA renderer over the event journal.
Survey
Today's probe() convention becomes a phase. A dry run inspects reality and prints a truthful plan rather than a guess.
Plan
Testable without doubles. It renders the confirmation text, and it is the thing --dry-run prints.
Refuse
Protected environments, paths escaping tracked, an empty tracked, and an upward effect with no source in the repository. This is the step a merged guard phase destroys.
Consent
Confirmation, remote backup, local snapshot. Ordering it after Refuse is what makes “a refused push has no side effects” structural rather than a matter of discipline.
Apply
A three-path push whose second path fails its digest never transfers the third. Verification is per-effect, inside the fold — never a phase after it.
Assert
The mail guard is one: checked against the filesystem rather than against having called the installer, so no scope or setting can route around it.
Report
Human output and --json are two renderings of one stream, not two code paths that drift.
✓ plan rendered — 3 effects, 1 unknown
✗ refused — prod is protected→ pass --force if you mean itexit 5
✗ refused — upward effect has no source in the repository→ use --from <path>, or --adhocexit 5
✓ survey complete — 0 findings above warning
The plan, as --json renders it
{
"effects": [
{ "kind": "transfer", "path": "wp-content/themes/site",
"writes": "remote", "source": "wp-content/themes/site" },
{ "kind": "transfer", "path": "wp-content/plugins/site",
"writes": "remote", "source": "wp-content/plugins/site" },
{ "kind": "unknown",
"reason": "provider cannot predict deletions without a remote listing" }
]
}The unknown entry is the honest part. A plan that cannot know something says so rather than guessing — and the dry run prints it.
Why verification happens per effect
Apply is a fold, and each effect is verified as it lands rather than in a phase afterwards. Moving that verification out is the obvious simplification and it is strictly more damage than doing nothing — because a push that fails cannot be repaired by running it again.
Three transfers. The second one fails its digest.
wp-content/themes/site
transferverify
wp-content/plugins/site
transferverify
wp-content/mu-plugins/cwp
transferverify
✗ stopped after two transfers. The third path never moved, and the remote is missing one file rather than carrying a corrupt one.
✗ all three transferred, then the failure landed. The remote now holds a file that is the wrong size on disk and the right size in the index — and re-running the push will not repair it, because cloudron sync compares size and modification time and reports it as up to date forever.
The layers
Five directories with one rule between them, and it is the half people get backwards: the layering test forbids upward imports only. Sibling imports are composition — pull using scrub, doctor using its check modules — and a rule against those invents a shared/ directory within a month.
The command manifest, the shared runtime, renderers, prompts.
The only layer that knows commander exists.
Pipeline instances, conduits, surveys.
No commander, no prompts — an op is driven, it does not drive.
wpcli/, abilities/, ddev/ — and the two seams below.
Everything that talks to something outside this process.
- providers/cloudron · ssh · local
- builders/bricks · none · elementor
Config, settings resolution, paths, policy, plan and effect types, the content model, canonical serialisation.
No subprocesses and no network — which is not the same as no filesystem. A push planner may stat the tracked paths; writes go through the Workspace.
Executor, Journal, Workspace, Findings, Facts, concurrency.
Knows nothing about WordPress, hosts or builders — and imports nothing above it.
The layering test forbids upward imports only. Select a layer to see what it may reach.
- 171 runtime
- copies of the command shell
- ~301 workspace
- hand-written dry-run branches
- ~201 Session
- bespoke context structs
Figures from the architecture document's own accounting, not measured here.
The shapes that are not this one
Being honest about the exceptions is what stops a spine eroding into an escape hatch, so all three are first-class types rather than opt-outs.
- Conduits
cwp wp,cwp shell,cwp logs. They announce their target, hand over, and forward the exit code. stdio is inherited, so the output you came for never enters the journal and cannot — capturing it would breakwp shelland strip WP-CLI's colour. No--json, and unguarded by design: cwp cannot classify WP-CLI subcommands, and a reflexive--forceonwp plugin listis worse than no guard at all.- Surveys
cwp doctor,status,projects. Survey → Report, and the result is a list of findings. Nothing to refuse, nothing to consent to, nothing to apply.init, outside all three- Its survey target is an answer nobody has given yet — it asks for the app, then probes it. Interleaving Survey and Plan is the pipeline with its guarantee removed, so
initkeeps its own shape: conversational input, then a pure scaffold planner that returns created, skipped and repaired before writing anything.