❯ 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. The names move guard placement out of each command and into the runtime. The same names keep the refusal alive under a dry run.
This is the tool as it is. This page is the readable half of two documents. Both are published in full: the architecture cwp is built to, and what was verified against the world it runs in.
The architecture document, verbatimWhat was verified, and whenThe same ground, in the manual
The spine
Every effectful command is an instance of the same seven steps. Two of them behave in opposite ways under --dry-run: Refuse runs, Consent does not. Merge them into one guard phase, the obvious simplification, and cwp push prod --dry-run stops refusing. It prints 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
The probe() convention is a phase of its own. A dry run inspects reality and prints a plan from what it found, not a guess.
Plan
Testable without doubles. It renders the confirmation text, and --dry-run prints it.
Refuse
Protected environments, paths escaping tracked, an empty tracked, and an upward effect with no source in the repository. A guard phase merged with Consent destroys this step.
Consent
Confirmation, remote backup, local snapshot. It runs after Refuse, so a refused push has no side effects by construction.
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. cwp checks the filesystem, not whether the installer ran. 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 instead of guessing. The dry run prints it as it is.
Ten steps, in the order they run
The spine above is the shape. This is a whole run, including the parts you did not ask for. Two of them are cwp checking itself: after the work, the run compares what it did against what its plan said, and reports when the two disagree.
Every line answers the same question: why here and not somewhere else.
- survey
Reads the environment and writes nothing. It runs under --dry-run too: a dry run prints what it found, not a guess.
- refuse
The guards. They run before the consent and before the write: a refused run has no side effects and needs no tidy-up.
A refusal stops here, and cwp writes it into the environment's log.
- consent
You are asked, with the plan's own words: the pages that would be overwritten, the classes that would go.
--dry-run stops here. cwp prints the plan. The refusals have already run, and cwp has neither asked nor written anything.
- restore point
After you agree: a run you declined backs nothing up. Before the write: there is a way back.
- tree snapshot
The same idea for your repository: a dangling commit the run names. A pull that overwrote local work has a way back.
- apply
The work, one declared step at a time. A step the plan calls fatal stops the run; the rest warn and it carries on.
- check
The run against its own plan: anything written that was not declared, and anything declared that was not done.
- assert
Post-conditions about the world. A pull that imported a database asserts that the mail guard intercepts outgoing mail.
- record
What happened, into the environment's log, where cwp log reads it back weeks later.
- commit
After the post-conditions held. A commit of a state that failed its own checks would make the failure permanent.
The plan is what the guards read
A plan is not a preview of the run. Each entry says where it writes, whether a failure there stops everything, what committed file it can be reproduced from, and whether it destroys anything. The first of those decides whether a protected environment refuses and whether cwp takes a restore point.
So a write nobody declared is a write outside both. It has happened: a composite declared that it wrote nowhere while its parts wrote to production, and nothing refused it or backed it up. The run now compares itself against its plan at the one place every subprocess passes through, and names the commands when they disagree.
One command, six operations, one refusal
cwp pull is not six runs in a row. Six operations in sequence would ask six times and take six restore points, and a failure at the fourth would leave an environment matching no commit at all.
The composite carries every sub-plan's effects into its own, so the ladder sees the whole run.
cwp pull reads six artifacts down- content
- bricks
- settings
- widgets
- roles
- inventory
every effect into one plan
one refusal · one restore point · one confirmation
the committed tree
Two seams
Everything host-specific sits behind one interface and everything builder-specific behind another. No operation knows which of them it is talking to: cwp push against a Cloudron app, a plain VPS over SSH and a container on your own machine is the same code.
What differs is what each host can promise. The guard ladder reads that promise rather than assuming it. A host that cannot take a restore point refuses the write instead of skipping the step.
Provider
How the site is reached, and what the host can promise.
- cloudrona native backup, and no way to say in advance what a transfer would move
- sshany plain host. rsync says exactly what would move, and the restore point is yours to arrange
- ddevthis machine. A container snapshot stands in for a backup, and both trees are here, so a dry run is exact
Builder
What the page builder stores, and how it travels.
- bricksa design system in its own tree, element trees in postmeta, and site-wide options its own export leaves behind
- nonenothing to carry. Every command still runs; the design-system steps have no work
Why verification happens per effect
Apply is a fold: cwp verifies each effect as it lands, not in a phase afterwards. Moving that verification out is the obvious simplification, and it is worse than doing nothing. 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. Re-running the push will not repair it: cloudron sync compares size and modification time and reports the file as up to date forever.
The layers
Five directories with one rule between them, and the rule is the half people get backwards: the layering test forbids upward imports only. Sibling imports are composition: pull uses scrub, doctor uses its check modules. 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. The cli drives an op; an op drives nothing.
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. The filesystem is allowed: a push planner may stat the tracked paths, and 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
Naming the exceptions keeps the spine from eroding into an escape hatch. All three are first-class types, not opt-outs.
- Conduits
cwp wp,cwp shell,cwp tail. They announce their target, hand over, and forward the exit code. stdio is inherited, so the output you came for never enters the journal. 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 first, then a pure scaffold planner that returns created, skipped and repaired before it writes anything.