❯ 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 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.

  1. SurveyRead-only. Runs even under --dry-run.
  2. PlanA pure value. May carry unresolved slots and explicit unknowns.
  3. RefuseAlways runs, including under --dry-run.
  4. ConsentSkipped under --dry-run: there is nothing to consent to.
  5. ApplyA fold over the effects. Each one verified as it lands.
  6. AssertPost-conditions that must hold on every path.
  7. 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 rendered: 3 effects, 1 unknown

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.

  1. survey

    Reads the environment and writes nothing. It runs under --dry-run too: a dry run prints what it found, not a guess.

  2. 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.

  3. 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.

  4. restore point

    After you agree: a run you declined backs nothing up. Before the write: there is a way back.

  5. tree snapshot

    The same idea for your repository: a dangling commit the run names. A pull that overwrote local work has a way back.

  6. 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.

  7. check

    The run against its own plan: anything written that was not declared, and anything declared that was not done.

  8. assert

    Post-conditions about the world. A pull that imported a database asserts that the mail guard intercepts outgoing mail.

  9. record

    What happened, into the environment's log, where cwp log reads it back weeks later.

  10. 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.

  1. wp-content/themes/site

    transferverify

  2. wp-content/plugins/site

    transferverify

  3. 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.

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.

  1. The command manifest, the shared runtime, renderers, prompts.

    The only layer that knows commander exists.

  2. Pipeline instances, conduits, surveys.

    No commander, no prompts. The cli drives an op; an op drives nothing.

  3. wpcli/, abilities/, ddev/, and the two seams below.

    Everything that talks to something outside this process.

    • providers/cloudron · ssh · local
    • builders/bricks · none · elementor
  4. 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.

  5. 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 break wp shell and strip WP-CLI's colour. No --json, and unguarded by design: cwp cannot classify WP-CLI subcommands, and a reflexive --force on wp plugin list is 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 init keeps its own shape. Conversational input first, then a pure scaffold planner that returns created, skipped and repaired before it writes anything.