❯ Design document
docs/ARCHITECTURE.md
The v1.0 rebuild
Nothing on this page is a claim about what cwp does today. It is a target: a design for a rebuild that is under way on a branch, superseding SPEC.md section by section as each phase lands. Read every mechanism here as intended rather than shipped, and take SPEC.md for what exists.
Rendered verbatim from docs/ARCHITECTURE.md on the main branch. Nothing on this page is edited, summarised or reordered. If the file changes, this page changes with it.
cwp architecture, v1.0
Status: Built, and amended once. F-040 – F-043 landed on main on
2026-08-02, and this document describes the code as it is rather than a target.
F-083 – F-097 amended it on 2026-08-23. See The git model below. It is the
only part of this document written after the code it describes.
The git model: an amendment, 2026-08-23
The command surface flattened. Thirteen commands became two verbs and three nouns:
cwp pullandcwp pushcarry the whole tree,cwp db pullandcwp media pullcarry the database and the files, andcwp adoptmints the relationship an environment and a tree have. Every replaced name is kept as an alias for one minor cycle (F-095, S-0010, ADR-002).Nothing in the five layers, the two seams, the effect model or the guard ladder changed. This is an amendment rather than a rewrite. The crossings are ops like any other; they compose the sub-operations that already existed rather than replacing them. What did change is what an operation may assume: a composite op has to carry its sub-plans’ effects. The ladder decides off effects, and a summary is not a plan (B-080).
Three things are genuinely new to the model. The environment ref:
basemoved only by a transfer,seenby any read (F-084). The tree snapshot taken before the first overwriting write (F-085). And the run ledger,cwp log <env>(F-086).Where an older spelling appears below, read it as the payload it names:
cwp content pushiscwp push --only content, and so on. The passages were written when those were the commands, and the argument each one makes is about the payload rather than the word. It was written as a design document and kept its shape when the design shipped. It still argues for its own decisions in places, and that is deliberate. The reasoning is the part that does not survive in the source. Owner: BERNSTEINKRAFT n.e.V. Tracked as: F-040 (the rebuild), F-041 (providers), F-042 (builders), F-043 (reproducibility) inFEATURES.md. All four shipped.
Relationship to SPEC.md. This document used to say SPEC.md wins “until the
corresponding phase of F-040 lands”. Every phase has landed. That rule has expired.
The two documents now split by subject rather than by date:
- This document is authoritative for cwp’s own shape: the five layers, the session, the command runtime, the two seams, the effect model, the guard ladder.
SPEC.mdis authoritative for the world cwp runs against. It records what the external tools do, verified rather than derived:cloudron syncsemantics (§5.4),--allow-rootunder a non-interactivecloudron exec(§5.6), the Bricks WP-CLI subcommands that exist (§9), the DDEV custom-provider schema (§10). Nothing here restates those.CLAUDE.mdpoints at them by section number.
The safety invariants are identical in both, by design. src/domain/policy/invariants.ts
now enforces them rather than either document. §19 records the five
places where a naive version of this architecture would have weakened one.
What it superseded, and did. SPEC §1’s non-goal was “no support for hosts other than Cloudron”. §9 replaced it with a provider seam that ships three implementations. SPEC §5 assumed throughout that Bricks is the page builder rather than a page builder; §10 measured that assumption and §10.3 removed it.
Audience: a developer who has never seen the codebase and must be productive in a day.
Context
cwp today is 15 500 lines of source and 10 200 lines of tests, built in eight days across four
releases. It works, it is unusually well documented, and the safety model it encodes is sound. But
nobody designed its shape. flows/ grew to 27 procedural scripts. Each script carries its own
bespoke context struct, its own hand-written dry-run branches, its own copy of the guard
sequence.
The cost shows up in four places:
- Duplication that guards live inside. The command shell is copy-pasted 17 times, the
confirmation gate 7 times, the settings precedence chain once per option. Every copy is a place a
guard can be forgotten. B-015 is that bug: a generated DDEV provider shipped an unguarded
production
db_push_command. Guards are applied by hand, and that file was written by hand. - Two of everything. Two error models (thrown
CwpError, returnedCheckResult), two logging paths (Reporter, and theRunner’s stderr echo that bypasses it), two version files. - Twenty context structs. Every flow invents its own. Each flow re-decides its wiring, and one flow gives another nothing without an import.
- Nothing runs in parallel. One
Promise.allin 15 500 lines.cwp doctormakes ~22 sequential remote round-trips and callscloudron listtwice. A ten-pagecwp push --only contentpays roughly fifty WordPress bootstraps.
And three things the tool cannot yet do. This design treats them as first-class:
- It is welded to one host. Cloudron is not an implementation detail anywhere; it is spelled out in flows, in the DDEV provider template, and in fix hints.
- Not every upward change is reproducible. Code and content are, design-system state mostly is,
but
cwp bricks ability <name> '<json>'writes arbitrary state to a live site with no committed trace at all. - Bricks is named where a builder should be. Thirty per cent of the source says “Bricks”. Far less of it is genuinely Bricks-shaped, as §10 measures.
The thesis: almost every command is an instance of one pipeline, over two pluggable seams, with one source of truth. Name those three things. Duplication, dry-run handling, guard placement, host portability and reproducibility become properties of the architecture. No command has to remember them.
The counter-thesis, held throughout: the current guards are hand-placed and correct. Moving them into a runtime trades explicitness for coverage. §13 prices that trade, and §19 records five places where a naive version of this design would silently weaken an invariant.
1. What cwp is
Strip the commands away and the essential complexity is small:
- There are two sides: a local DDEV container and a remote site. Almost every operation is the same work against either one.
- Data is directional. Direction is a property of what an effect touches, not of the
command name.
cwp pushwith no environment argument is a local write.cwp content pullwrites a uid upward to adopt a post. A design keyed on command names gets both wrong. - The external tools lie.
cloudron sync pullexits 0 having moved nothing (B-003).sync pushis additive, so deletions never propagate (B-005). A file count cannot distinguish a completed transfer from a corrupted one (B-007). Verification is the reason the tool exists. - Mechanisms have narrower coverage than their promises, and fail invisibly. B-019 (an uploads
proxy that only covered the attachment API), B-017 (every listing paginates at 25 and says so
quietly), B-001 and B-011 (a scrub that reported success and left visitor IPs behind).
FEATURES.mdnames this as the recurring shape of the entire project. - Every effect must be describable before it happens: for
--dry-run, for the confirmation text, and because a machine caller needs a plan it can inspect. Some effects are genuinely not knowable in advance. A design that cannot say so makes--dry-runless truthful. - A live site is not a source of truth, and it is not backed by one either. Cloudron backs up the container; nothing versions the decisions. A page layout, a set of global classes, a plugin activation state: each is a change someone made. None of them is diffable, reviewable or revertable unless the tool puts it in the repository first.
2. Three principles
Everything below follows from these. This section states them once.
P1. Direction asymmetry (unchanged from S-0016)
Bulk database and uploads move down. Code moves up. Individually named items may move up under the full guard set, with the affected items listed before the write. Direction lives on the effect, not on the command (§13).
P2. Every upward write has a committed source
Nothing changes on a remote site that does not originate in a file in the repository.
This is new, and it is the strongest constraint in the document. Its consequences:
- The repo holds the desired state for everything that can travel upward: code (
tracked), content (content/), the design system (bricks/, named for its builder), the runtime inventory (inventory.yml), builder and post-type settings. - Every upward effect carries
source: RepoPath. An upward effect with no repo source is refused at the applier, the same enforcement point as the write-scope check (§13). cwp bricks ability bricks/set-global-settings '{…}'is today an unlogged arbitrary write to a live site. It becomes--from <repo-path>, or an explicit--adhocthat refuses protected environments outright and writes the payload tocwp/adhoc/<timestamp>-<ability>.jsonso a trace exists.- Artifacts must be diffable: an engineering requirement, not a formatting preference. §11 specifies it.
- Drift becomes a three-way question: repo vs local vs remote.
cwp statusanswers it.
This sharpens rather than contradicts S-0016’s rule that cwp invents no values: the values now have a stated home.
What it does not mean: cwp still runs no git commands. It writes directories, not commits. A
project requires a clean tree in the pre-push hook.
P3. Two seams, each with more than one implementation
| Seam | Question it answers | First implementation | Second, to validate the interface |
|---|---|---|---|
| Provider | Where does the site live, and how do I reach it? | cloudron | ssh (ssh + rsync) |
| Builder | How are pages built, and what site-wide state do they depend on? | bricks | none (Gutenberg/classic), then elementor |
An interface with one implementation is speculative generality; SPEC §1 rightly listed other hosts as a non-goal on those grounds. This design supersedes that decision. Each seam ships with a second implementation that is deliberately unlike the first. As §9 shows, the capability degradation that a second provider forces makes the Cloudron path safer rather than merely more general.
3. What carries over unchanged
This is a redesign of shape, not of judgement.
| Kept | Why |
|---|---|
One subprocess boundary, no stray execa | The only reason --verbose/--dry-run are coherent |
| A required fix hint on every raised failure, enforced by the type | The best quality property the codebase has. §7.4 keeps it required |
Human → stderr, machine → stdout, --json is one object | S-0021; the envelope shape stays byte-identical |
| Stable exit codes 0–6, with 5 reserved for “refused by a guard” | A public contract |
| Signal translation: 128 + signum, not a flattened 1 | B-010; Ctrl-C on cwp tail -f is not a failure |
| Prompting injected as a callback, never imported into the core | Keeps @clack/prompts out of every testable layer |
The WpConduit idea — one WP-CLI target, either side, one interface | Prevents the remote path from being less tested |
The wp eval-file shim with sentinel-framed output | SPEC §5.3; WordPress emits _doing_it_wrong HTML on stdout |
| URL placeholder normalisation on stored payloads | Already implemented; §11 generalises it |
| The hand-written settings key registry with prose refusal reasons | §8.1 — a considered decision, not a shortcut |
| Templates as raw-text imports, bundled into a single-file binary | Zero runtime asset resolution |
High comment density carrying B-0xx / F-0xx rationale | Where the tool’s knowledge lives. §15 makes it checkable |
| Docs-first process, six non-negotiable safety rules | CLAUDE.md. The architecture serves them, never the reverse |
| Six runtime dependencies | commander, execa, zod, yaml, fflate, @clack/prompts |
4. The spine
Every effectful command is an instance of seven named steps.
Survey ─▶ Plan ─▶ Refuse ─▶ Consent ─▶ Apply ─▶ Assert ─▶ Report
│ │ │ │ │ │ │
read-only a pure always skipped a fold post- renders
runs even value runs, under over conditions the
under incl. dry-run effects; that must journal
--dry-run dry-run (nothing each hold on
to consent verified every path
to) as it
lands
| Step | Rule | What it makes structural |
|---|---|---|
| Survey | Read-only. Runs even under --dry-run. | Today’s probe() convention becomes a phase. A dry run inspects reality and prints a truthful plan. |
One host cannot be read without writing to it: a survey of shared hosting goes through the PHP agent cwp installs. --with-agent is the operator letting a dry run install and remove it, and without the flag the run refuses rather than planning from failed reads (ADR-029, F-128). | CommandSpec.runsUnderDryRun is the only exemption, one file sets it, and a structural test fails the build on a second. | |
| Plan | A value. May contain unresolved slots and explicit unknowns. | Testable without doubles. Renders the confirmation text. Snapshot-able where complete. |
| Refuse | Protected environments, escaping tracked paths, an empty tracked, an upward effect with no repo source (P2). Runs under --dry-run too. | cwp push prod --dry-run still refuses. §19.1 — the invariant a merged guard phase destroys. |
| Consent | Confirmation, remote backup, local snapshot. Skipped under --dry-run. | “A refused push has no side effects” becomes ordering, not discipline. |
| Apply | A fold over the effects. Each effect verified as it lands; a failure aborts the fold. | A three-path push whose second fails its digest never transfers the third. §19.2. |
| Assert | Post-conditions that hold no matter which path ran. | The mail guard. §19.4 — where this design is stronger than today’s. |
| Report | A renderer over the event journal. | Human and JSON are two renderings of one stream, not two code paths. |
The shapes that are not this one
Naming the exceptions stops the spine eroding into an escape hatch. All are first-class types, not opt-outs.
Conduits: cwp wp, cwp shell, cwp tail. announce → hand over → forward the exit code.
The conduit inherits stdio, so the output the user came for never enters the journal and cannot.
Capturing would break wp shell and wp db cli and strip WP-CLI’s tty colour. No --json. S-0021 calls
this “the one documented exception”. Unguarded by design (SPEC §3: cwp cannot classify WP-CLI
subcommands, and a reflexive --force on wp plugin list is worse than no guard); they announce
their target instead. Signal translation lives here.
Surveys: cwp doctor, status, projects, content status, bricks audit. Survey → Report,
result Finding[].
And init stays outside all three. Its survey target is an answer the user has not given. It
asks for the app, then probes it. Interleaving Survey and Plan is the pipeline with its guarantee
removed. init keeps its own shape: conversational input resolution, then a pure scaffold
planner returning created/skipped/repaired before writing. The planner makes init --dry-run name the files it would touch instead of printing one summary line.
5. Layers
cli ─────▶ ops ─────▶ tools ─────▶ kernel
│ │ ▲
│ providers/ │
│ builders/ │
└──────────▶ domain ────────┘
src/
kernel/ Executor, Journal, Workspace, Findings, Facts, concurrency.
Knows nothing about WordPress, hosts or builders.
domain/ Values and rules. Config, settings resolution, paths, policy,
plan and effect types, parsers, the content model, canonical
serialisation. No subprocesses, no network.
tools/ wpcli/, abilities/, ddev/ — plus the two seams:
providers/ cloudron | ssh | local
builders/ bricks | none | elementor
ops/ Pipeline instances, conduits, surveys. No commander, no prompts.
cli/ The command manifest, the shared runtime, renderers, prompts.
templates/ PHP shims, nginx snippets, scaffold files. Raw text.
On domain purity, precisely. The rule is no subprocesses and no network, not no filesystem.
A push planner must stat the tracked paths; buying a filesystem port to defend the word “pure”
would add mocking to the highest-value test file for no safety gain. A domain planner may read
disk. It may not write. Writes go through the Workspace (§7.3), a dry-run and
journaling mechanism rather than a general filesystem abstraction.
The layering test (§14) forbids upward imports only. Intra-layer imports are composition:
pull using scrub, doctor using its check modules, conduits sharing an announcer. A rule that
forbids sibling imports invents a shared/ directory within a month.
6. The spine’s runtime
export async function run<I, P extends Plan, R>(op, session, input): Promise<R | null> {
const plan = await op.survey(session, input); // read-only; runs in dry-run
op.refuse(session, plan); // refusals ignore dry-run
if (session.settings.dryRun.value) { op.report(session.journal, plan, null); return null; }
await op.consent(session, plan); // confirm, backup, snapshot
const result = await op.apply(session, plan); // fold, verifying as it goes
await op.assert(session, plan, result); // post-conditions
op.report(session.journal, plan, result);
return result;
}
Read that function and you know how the entire tool works. It is the on-ramp.
7. The kernel
No domain knowledge. Roughly 600 lines.
7.1 Commands are values
export interface CommandSpec {
readonly tool: ToolId;
readonly argv: readonly string[];
/** Only `read` runs under --dry-run, and only `read` may be parallelised. */
readonly intent: "read" | "write";
readonly cwd?: string;
readonly stdin?: string;
readonly env?: Readonly<Record<string, string>>;
/** Travels with the failure. No subprocess error reaches a user without a hint. */
readonly onFailure: FixHint;
}
intent is not new information. Today which method you call carries it (run vs probe
vs passThrough). Moving it onto the value buys two things a method split cannot. The executor
decides dry-run behaviour uniformly. The same field types the fan-out helper.
onFailure is the genuine fix. Today SubprocessError deliberately carries no hint, so any
un-wrapped call site degrades to exit 1 with a bare Command failed:. Putting the hint on the spec
means the adapter that knows why a command might fail supplies it. With providers (§9), that
adapter is the only code that knows whether “permission denied” means a Cloudron login or an SSH key.
export interface Executor {
exec(spec: CommandSpec): Promise<Execution>; // throws, carrying spec.onFailure
attempt(spec: CommandSpec): Promise<Attempt>; // never throws; failure is data
handover(spec: CommandSpec): Promise<Handover>; // stdio inherited; conduits only
fanOut(specs: readonly ReadSpec[], limit?: number): Promise<Attempt[]>;
}
fanOut accepts ReadSpec = CommandSpec & { intent: "read" }. “Writes are never parallelised” is a
compile error, not a review comment.
7.2 The journal replaces the reporter
Today Reporter both records and renders, and the Runner’s --verbose echo writes straight to
process.stderr, bypassing it. Split them:
export type Event =
| { kind: "step"; name: string; status: Severity; detail?: string }
| { kind: "warn"; message: string }
| { kind: "command"; spec: CommandSpec; outcome: "planned" | "ok" | "failed"; ms: number }
| { kind: "progress"; label: string; done: number; total: number }
| { kind: "data"; payload: Record<string, unknown> }
| { kind: "finish"; failure: Failure | null };
Renderers subscribe: Tty (glyphs, colour with NO_COLOR, a progress line for long remote work),
Plain, Json (buffers, one envelope on finish), Memory (tests). The S-0021 envelope comes
from one renderer and has a shape-conformance test.
Ordering under concurrency. Parallel work must not interleave output, or the JSON steps array
becomes nondeterministic and the §12 contract breaks. The journal buffers per task and flushes in
submission order.
7.3 The workspace: one dry-run gate for writes
export type WriteOutcome = "created" | "updated" | "unchanged" | "planned";
export interface Workspace {
readonly root: string;
write(rel: string, contents: string, opts?: { mode?: number }): WriteOutcome;
ensureDir(rel: string): void;
remove(rel: string): void;
}
Every filesystem write goes through this, as every subprocess goes through the executor. It deletes
roughly thirty hand-written if (ctx.dryRun) { report "would write…"; return } blocks.
unchanged is not cosmetic. S-0017 requires init to repair rather than overwrite and to
report every change. A write that changed nothing is not a change. Under P2 it matters
more. A pull writing an identical file must leave the git tree clean. Otherwise every pull
produces noise and the diff stops being trustworthy.
7.4 Findings and failures: one vocabulary, two types
The current asymmetry is correct and must survive: CwpError makes fixHint a required
constructor argument, while CheckResult.fixHint is optional because a doctor warn legitimately
has no fix. Collapsing both into one optional-hint type would silently delete the type-level
enforcement of CLAUDE.md rule 6 across the whole codebase.
export interface Finding { // what doctor reports
readonly code: FindingCode; // stable + greppable
readonly severity: "ok" | "warn" | "fail";
readonly message: string;
readonly fixHint?: string;
readonly ref?: SpecRef; // "SPEC §2" | "B-007" — checked (§15)
}
export interface Failure extends Finding { // what gets raised
readonly severity: "fail";
readonly fixHint: string; // required. The rule survives.
readonly exitCode: ExitCode;
}
export class CwpError extends Error { readonly failure: Failure }
7.5 Facts, and the one class that must never be cached
cloudron list, the remote package layout, the PHP version, ddev describe, the machine config,
the --allow-root capability: stable for a run, memoised once. cwp doctor calling cloudron list
twice stops being possible.
Database-derived facts are not accepted by the type at all. The current code is explicit about
why the ability user is never cached: which users exist is database state, and cwp pull replaces it
wholesale mid-command. A typed distinction beats an invalidate() someone must remember.
8. The domain layer
No subprocesses, no network. The highest-value tests go here: they need no doubles.
domain/
config/ schema, the settings resolver, the key registry
paths.ts path derivation per provider layout
policy/ guard decisions, the invariant catalogue
plan.ts the Plan and Effect types
planners/ push, pull, scrub, content, design-system
parsers/ transfer output, digest comparison, ability envelopes
content/ item model, normalisation, hashing, conflicts
canonical/ the diffable serialisation format (§11)
Most of today’s content.ts is pure: planning, hashing, normalisation, reference collection,
conflict detection. It runs to 922 lines, the largest file and the thinnest tested relative to size.
It is untestable without a fake runner only because it sits next to the effects.
8.1 Settings: build the resolver, keep the registry
The real repeated pain is precedence: flag > project > machine > default re-inlined per option.
options.backup !== false && (ctx.backupDefault ?? true) appears three times in one command file.
export interface Resolved<T> { value: T; source: "flag" | "project" | "machine" | "default" }
export function resolveSettings(flags, project, machine): Settings;
Provenance costs nothing and pays for itself: cwp config get <key> --explain.
This design does not derive the key registry from the zod schema. That looks like removing
duplication and is not. The current registry exists partly to exclude keys: a derived one would
expose version and the capability cache. Its refusal reasons are prose no schema can
generate. Sixty pure lines with a 202-line test is the right answer.
8.2 The invariant catalogue
export const INVARIANTS = {
PROTECTED_NEEDS_FORCE: { ref: "S-0004", test: "policy: protected refuses without --force" },
REFUSAL_SURVIVES_DRY_RUN: { ref: "S-0004", test: "ops/push: --dry-run on protected refuses" },
BACKUP_BEFORE_UPWARD: { ref: "S-0004", test: "policy: no remote write without a restore point" },
REFUSAL_HAS_NO_EFFECTS: { ref: "S-0004", test: "ops/push: refused push runs no hook" },
SNAPSHOT_ABORTS_PULL: { ref: "`docs/commands/pull.md`", test: "ops/pull: failed snapshot aborts" },
MAIL_GUARD_ALWAYS: { ref: "S-0019", test: "ops/pull: present on every terminating path" },
VERIFY_BEFORE_NEXT: { ref: "S-0004", test: "ops/push: failed digest stops the fold" },
NEVER_RAW_SQL: { ref: "`docs/commands/pull.md`", test: "tools/wpcli: search-replace only" },
UPWARD_HAS_A_SOURCE: { ref: "P2", test: "policy: upward effect without RepoPath refused" },
} as const;
A test asserts every entry names a test that exists and passes.
9. Seam one: providers
9.1 What a provider is
A provider answers four questions. Where this site lives, how to run a command there, how to move
files, and whether it can take a restore point. It is a pure argv builder plus a small number of
transfer routines. It returns CommandSpecs rather than running them. The single subprocess
boundary survives, and providers become the highest-value test target CLAUDE.md describes.
export interface Provider {
readonly id: ProviderId; // "cloudron" | "ssh" | "local"
readonly capabilities: ProviderCapabilities;
/** Existence, paths, PHP version, content layout. Never guessed — probed. */
resolve(env: EnvironmentConfig): Promise<Target>;
exec(t: Target, argv: readonly string[]): CommandSpec;
shell(t: Target): CommandSpec;
logs(t: Target, o: LogOptions): CommandSpec;
pushTree(t: Target, local: string, remote: string, o: MirrorOptions): Promise<Transferred>;
pullTree(t: Target, remote: string, local: string): Promise<Transferred>;
listFiles(t: Target, remote: string): Promise<string[] | null>;
digests(t: Target, remote: string): Promise<DigestMap | null>;
backup(t: Target): Promise<BackupRef>; // may be unsupported — see below
}
9.2 Capabilities, and degradation that refuses rather than skips
This is the part that makes the abstraction pay for itself rather than merely generalise.
export interface ProviderCapabilities {
commands: "shell" | "agent" | "none"; // ADR-027
backup: "native" | "synthetic" | "none";
transferDryRun: boolean; // rsync --dry-run: yes. cloudron sync: no.
mirrorDelete: boolean;
remoteDigests: boolean; // needs md5sum in the image
logs: "follow" | "tail" | "none";
shell: boolean;
}
cloudron | ssh | ftps / sftp | |
|---|---|---|---|
| backup | native — cloudron backup create | synthetic — mysqldump + tar of wp-content into a retained path | none — an upward write is refused without --no-backup |
| transferDryRun | false | true — rsync --dry-run | true — rclone --dry-run |
| mirrorDelete | true (--delete) | true (rsync --delete) | true (rclone sync) |
| remoteDigests | true | true | false — FTP has no checksum |
| logs / shell | follow / true | tail / true | none / false |
Two payoffs fall straight out:
The backup invariant survives a host that cannot back up: it refuses. BACKUP_BEFORE_UPWARD
consults capabilities.backup. none means cwp refuses the upward write, with a fix hint naming
what to do, unless the caller passes --no-backup explicitly. CLAUDE.md’s standing rule is degrade
to a clear instruction, never to a silent failure. Here it reaches a capability the current code
cannot express: that code assumes cloudron backup create always exists.
A documented limitation becomes a per-provider fact. S-0004 says “step 3 is not a diff…
cloudron sync has no dry-run mode, so cwp does not claim to know which individual files differ.”
The claim holds of Cloudron. On the ssh provider, transferDryRun: true means cwp push --dry-run
can show the real file-level diff. The plan model already carries unknown effects (§12.1). The
same op then produces a vaguer or sharper plan depending on the provider, with no branch anywhere
in the op.
9.3 The providers worth naming
cloudron: first, and the reference implementation. Everything current.ssh: ssh + rsync. Covers a plain VPS, and by extension most managed WordPress hosts that expose SSH (Kinsta, WP Engine, SpinupWP, Ploi, RunCloud). Deliberately unlike Cloudron: no app registry, no native backup, explicit paths, a transfer tool that does have a dry run. The unlikeness proves the interface is an interface.ftps/sftp: shared hosting, added by ADR-027. An account that moves files and runs nothing, and the reasoncommandsbecame a capability: where there is no shell, WordPress answers through a signed PHP agent cwp installs for the length of a run and removes when it ends. One provider over two protocols, because the difference between them is a scheme and a port.local: a same-machine target (a second DDEV project, a staging container). Cheap, and it makes an end-to-end test possible with no network at all.wp-alias. Worth noting, not scheduling: WP-CLI has built-in SSH aliases (wp @prod …), which coversexecbut not transfer. It would be ansshvariant, not a fourth implementation.
What stays out: anything requiring an API client (a hosting REST API, a control panel). An API client is a new dependency class and a different reliability model. The interface does not forbid one; nothing schedules one.
9.4 What this costs
The DDEV provider template (.ddev/providers/cloudron.yaml) becomes per-provider. B-015’s lesson
applies with more force. The stanza set matched the schema in every name. Nobody executed it. It
could not run at all. Every provider’s generated DDEV config needs an
executed test, not a reviewed one. §20 puts that in the build order.
10. Seam two: builders
10.1 How mandatory is Bricks today? Measured.
| Measure | Value |
|---|---|
| Lines in Bricks/ability-named files | 4 683 of 15 525 — 30 % |
Of which is generic WP Abilities API transport (abilities.ts, ability-registry.ts) | 559 lines |
bricks.enabled schema default | false |
Bricks mentions in flows/content.ts | 36 |
The answer: cwp already runs on plain WordPress today. Three pieces of evidence, all in the current source:
bricks.enableddefaults tofalse, andpull.tsgates every Bricks step behind it.- The content transfer already has a builder switch.
ContentContextcarriesbricks: boolean, documented as: “False on a project that is not a Bricks site, where the post row and its meta are still worth transferring andbricks/get-page-elementsis not registered to ask. The artifact recordsbricks: nullfor those.” The non-builder path is deliberate, not accidental. - The transport is not Bricks.
wp eval-filewith a sentinel-framed shim over the WordPress Abilities API is generic infrastructure;abilities.tsis Bricks-flavoured only in its namespace allow-list, its three probe abilities and its fix-hint wording.
So Bricks is additive, not foundational. The seam is missing a name, not decoupling. With the name, a second builder costs a hundred lines instead of another thirty per cent.
10.2 The interface
export interface Builder {
readonly id: BuilderId; // "none" | "bricks" | "elementor"
/** Present and usable on this site? Probed, never assumed — B-014's whole lesson. */
detect(site: Site): Promise<BuilderPresence>;
/** Post meta this builder owns — excluded from the generic meta copy. */
readonly ownedMeta: readonly string[];
readPage(site: Site, id: PostId): Promise<BuilderPayload | null>;
writePage(site: Site, id: PostId, payload: BuilderPayload): Promise<void>;
/** Site-wide state a page points at: element types, classes, components. */
references(payload: BuilderPayload): References;
held(site: Site): Promise<References>; // what the target actually holds
/** Site-wide state that lives in the repo and is diffable. Null when there is none. */
readonly designSystem: DesignSystemPort | null;
/** Repo form ⇄ site form. URL placeholders, key ordering, volatile stripping (§11). */
toRepoForm(payload: BuilderPayload, siteUrl: string): Canonical;
toSiteForm(canonical: Canonical, siteUrl: string, media: MediaMap): BuilderPayload;
/** Steps after a database import: asset regeneration, signatures. Capability-probed. */
postImport(site: Site): Promise<Finding[]>;
}
Delivered in two halves, decided 2026-08-01.
The seam’s first callers need detect, ownedMeta and postImport. The page,
reference and canonical members have no caller until content and the design
system land in step 8. Shipping all twelve at once would be an interface with
one implementation and no consumer: the speculative generality §9 avoids,
one level up. The second half arrives with the callers that use it.
10.3 The three implementations, and why these three
none: Gutenberg, classic, block themes.ownedMeta: [],readPage → null,designSystem: null,postImport → []. The whole payload ispost_content. It is trivial, and its triviality is the proof the interface is not Bricks-shaped: ifnoneneeds a special case anywhere above the seam, the seam is in the wrong place.bricks: everything current. Element trees via abilities, the design system (export/import transfer package), three reference classes,regenerate_assetsand code signatures on import, thepostTypesglobal setting, the readiness chain of SPEC §5.2.elementor: the honest stress test, and the reason to believe the interface generalises. Written on paper in step 7, before thebricksport.noneis trivial by construction, so it cannot do forBuilderwhatlocaldid forProvider: stand deliberately unlike the first implementation while the interface is still cheap to change. Elementor stores_elementor_dataas JSON in postmeta and has no abilities API at all, soreadPage/writePagego throughwp post meta get/updateinstead of the shim. Its design system is a “kit” stored as a post, and it has its own CSS regeneration (elementor flush_css). Different transport, different state model, same interface. If the interface survives Elementor on paper, it is real.
10.3a A site can run more than one builder
Decided 2026-08-01.
Bricks is a theme; Elementor, Beaver Builder and Oxygen are plugins. Only one theme is
active, but several builder plugins can be, and individual pages carry one builder each: an
_elementor_data page beside a _bricks_page_content_2 one. Mid-migration sites and inherited sites
look exactly like this.
So a builder is not 1:1 with anything on the command line, and a provider is. The positional
environment argument determines a provider, so cwp push can be
neutral. Nothing on the command line determines a builder. A neutral cwp design pull would have
to guess which one, or take a --builder flag to disambiguate a fiction of its own making. S-0016’s
rule is that cwp invents no values. Builder-named commands keep multi-builder support purely
additive: add an implementation, its commands appear.
The flag sets differ for real, too. Elementor has no abilities API at all: its page read is
wp post meta get, its design system is a kit stored as a post, its regeneration is flush_css. One
command would carry the union of both builders’ flags, with half erroring on each. §9.2’s
capability model exists to degrade honestly, not to paper over two different tools with one surface.
What is deliberately not built yet: builder.id is singular, so on a site running two builders
only one contributes ownedMeta to the content transfer. The shape is right for today:
elementor is step 12, “if and when a project needs it”. The naming above keeps the
growth additive rather than breaking.
10.4 What is generic and moves out of Bricks
| Today | Belongs in |
|---|---|
abilities.ts, ability-registry.ts (559 lines) | tools/abilities/ — the WP Abilities API over wp eval-file. Namespace allow-list becomes a parameter |
hasMore pagination (B-017) | tools/abilities/readAll() — a property of the API, not of Bricks |
content.ts post row, meta, media, uid adoption, conflicts | domain/content/ — plain WordPress |
content.ts element trees + reference checking | builders/bricks/ behind readPage/references |
child-theme.ts | builders/bricks/domain/. The B-001 config repair calls it during config load, and the builder id comes from the config being loaded. Every export is a themes-directory lookup with no builder involved; the Bricks behaviour that uses it moves behind the seam and imports downward |
| URL placeholder normalisation | domain/canonical/ — every builder needs it |
bricks: config block | builder: { id, options } — bricks.child_theme/wp_user become options |
Naming follows. : declined 2026-08-01. It
becomes the design-system artifact of cwp bricks export becomes cwp design pullcwp pull, and the builder’s own commands stay grouped under
it. A provider is 1:1 with an environment and a builder is 1:1 with nothing. Bricks is a theme,
Elementor and Oxygen are plugins, so a site can run several and individual pages carry different
ones. A neutral command would have to guess which, or invent a --builder flag to disambiguate a
fiction of its own making.
Their flag sets differ for real, too. Elementor has no abilities API, its design system is a kit
stored as a post, its regeneration is flush_css. : the directory keeps the builder’s name (decided 2026-08-01). It holds
Bricks-format JSON, not a generic design system, and Elementor’s equivalent is a kit stored as a
post. A neutral name would claim a portability that does not exist, and cost every existing project
a whole-tree git move for it. The default derives from bricks/ becomes
design-system/builder.id instead, so it is glanceable and
accurate per project. The command rename stands: the command genuinely is builder-neutral
and the artifact is not.
And cwp bricks ability stays Bricks-named because it genuinely is. A v1.0 is the place for that
rename; aliases can keep the old spellings working.
Two corrections, 2026-08-01. The project declines aliases (ADR-026). And
cwp bricks ability is not Bricks-named-because-it-is: ABILITY_NAME_PATTERN accepts any
namespace, so only the three ALWAYS_ON_ABILITIES probes and some fix-hint wording are
Bricks-flavoured. It becomes cwp ability, alongside cwp abilities, with the transport.
11. The repository as desired state
P2 says every upward write originates in a committed file. The claim holds only if the files are genuinely reviewable. This section is a specification, not a preference.
11.1 The tracked tree
content/ one post per file, named by stable uid + slug
content/terms/ one term per file, per taxonomy the group declares (F-051)
content/menus/ one *menu* per file — the term, its entries nested in order, its
theme locations. A menu entry has no slug to name a file after,
and a navigation is what anybody reviews (F-056)
bricks/ one item per file: classes, variables, components, templates, settings
— named for the builder that owns the format (§10.4)
inventory.yml WordPress, plugins and themes
public/… code, via `tracked`
cwp/adhoc/ payloads of writes that escaped the model (§11.4)
11.2 Canonical form: six rules
-
One item per file. A 4 000-line design-system dump has no reviewable diff; forty files of a hundred lines each do.
-
Deterministic key order: sorted throughout, recursively, with a trailing newline. Bricks nests its element trees; sorting the keys is the difference between “one element changed” and “the file changed”.
The format differs by what the file is for (F-057).
content/is YAML, because a content item is mostly strings and some of them are long._cssCustomis a stylesheet, and on abuilder: nonesite the whole page is onepost_contentblob. JSON writes those on one line with\nescapes, so a one-value change is a long line replaced by a long line. A YAML block scalar puts the changed line on a line of its own.bricks/stays JSON with a two-space indent: it is the builder’s own export package, machine interchange rather than a document.The hash is not the storage form.
contentHash,termHashandmenuHashgo on hashing canonical JSON. A hash is identity. Moving it with the format would have made every item differ from its sync marker at once, and taught the reader to pass--overwrite-conflicts. -
Stable identity, not database identity. File paths and cross-references key on the uid or slug, never the auto-increment post ID. The ID⇄uid map is a separate lock file, not part of the diffable payload.
-
Volatile fields stripped. Timestamps, revision ids, author ids, counters. If a field differs between two identical sites, it does not belong in the tracked form.
-
URLs as placeholders. Already implemented (
toStoredForm/toSiteForm); generalised so that the same page pulled fromdevand fromprodproduces byte-identical files. -
A pull that changes nothing writes nothing. Enforced by
Workspace.writereturningunchanged(§7.3). Without this, every pull dirties the tree and nobody reads the diff.
Rules 3–6 make the difference between a directory of exports and a source of truth.
11.3 Three-way drift
The repo is one of three states, and cwp status reports all three:
artifact repo local prod detail
! bricks/ ✓ ✓ ✗ 3 classes differ on prod
! content/ ✓ ✗ ✓ 2 pages edited locally, uncommitted
✓ inventory.yml ✓ ✓ ✓
One column per side, each headed by the side it is about and coloured by it: tree cyan, local magenta, remote blue (F-063). Until 1.0 cwp drew this as a chain. The chain read as though the three questions depended on each other. They are independent, and a table says so. A column nobody asked for stays out entirely rather than going blank.
This subsumes F-037’s design-system drift check and generalises it. It must work with Docker
stopped, so remote comparison is opt-in (--remote). F-037’s requirement that status “works on a
train” survives.
11.4 The escape hatch, made honest
cwp bricks ability <name> '<json>' is today an unlogged arbitrary write to a live site. Under P2:
--from <repo-path>is the normal form. The effect carries that path as its source.--adhocis the escape: refused outright on protected environments, writes the payload tocwp/adhoc/<timestamp>-<ability>.json, and warns that the change is not reproducible.- Read-only abilities are unaffected.
The point is not to forbid ad-hoc writes. An ad-hoc write leaves a file behind.
12. Ops
One Session replaces the ~20 bespoke context structs.
export interface Session {
readonly exec: Executor;
readonly journal: Journal;
readonly workspace: Workspace;
readonly facts: Facts;
readonly project: LoadedProject;
readonly settings: Settings;
readonly builder: Builder; // resolved once from config
site(env: EnvName | null): Site; // provider-backed; null = local
readonly ask: Interrogator; // injected; CLI supplies clack, tests a constant
}
Session has no commander and no prompt library beneath it. The ops layer is therefore callable
without the CLI: for tests today, and for a cwp mcp serve or a programmatic caller later,
without another rebuild. This design does not build that API; it declines to make it impossible.
export interface Operation<Input, P extends Plan, Result> {
readonly id: OpId;
survey(s: Session, input: Input): Promise<P>;
refuse(s: Session, plan: P): void;
consent(s: Session, plan: P): Promise<void>;
apply(s: Session, plan: P): Promise<Result>;
assert(s: Session, plan: P, result: Result): Promise<void>;
report(j: Journal, plan: P, result: Result | null): void;
}
12.1 The plan, honest about what it does not know
interface EffectBase {
/** Where this effect writes. Per effect, never per command (§13). */
readonly writes: "none" | "local" | "remote";
/** P2: required whenever writes === "remote". Refused without it. */
readonly source: RepoPath | null;
/** No default, ever (§19.5). */
readonly bestEffort: boolean;
readonly verify: Verification | "none";
}
export type Effect = EffectBase & (
| { kind: "transfer"; direction: "up" | "down"; from: string; to: string; files: number }
| { kind: "db-import"; source: string; target: Side }
| { kind: "wp"; argv: readonly string[]; target: Side; destructive: boolean }
| { kind: "write"; path: string; outcome: "create" | "update" }
| { kind: "delete"; target: Side; paths: readonly string[] }
| { kind: "ability"; name: AbilityName; annotation: Annotation; items: readonly string[] }
| { kind: "create-post"; slug: string; id: null } // unresolved slot, filled in apply
| { kind: "unknown"; reason: string } // an explicit non-prediction
);
Two kinds exist because the current code is more honest than a naive plan model would be:
create-post with id: null. A post that does not exist has no id, and pre-resolving it means
creating it during the phase that must not write. apply fills the unresolved slots and produces
a ledger. The plan need not be complete.
unknown. Today’s pull refuses to predict the Bricks post-import steps. The steps depend on
what the installed Bricks version can do. Only a probe knows that, and a dry run does not pretend
to. That refusal has no representation in “a list of typed intended effects”. Without an
unknown kind from day one, someone invents a fake effect for it and --dry-run becomes more
confident and less truthful. The same applies to scrub, where pre-counting deletions means extra
queries against a database the run is about to wipe. It applies now to transferDryRun: false
providers too (§9.2).
would push 3 tracked paths → dev (app.example.com, provider: cloudron)
✓ public/wp-content/plugins/example-site 47 files ← tracked
! 12 remote files absent locally would be left in place — pass --delete to mirror
? file-level diff — the cloudron provider has no transfer dry run; use --provider-dry-run on ssh
? Bricks post-import steps — depends on the installed version, probed at runtime
12.2 The catalogue
ops/
init/ resolve → pure scaffold planner → write (its own shape)
doctor/ checks/*.ts, each a Finding producer survey
status/ projects/ config/ open/ survey
pull/ fetch/ scrub/ db/ operation
push/ plan, transfer+verify fold, deploy steps operation
content/ list, status, pull, push survey + operation
bricks/ pull, push, audit, snapshot, mcp, post-types builder-specific
(`export`/`import` renamed to `pull`/`push` — §10.4)
ability/ abilities/ conduit + survey (generic)
plugins/ snapshot, apply survey + operation
conduits/ wp, shell, logs conduit
doctor gets the one genuine registry. It is where the seams show up as checks:
export interface Check {
readonly id: CheckId;
readonly needs: readonly CheckId[]; // prerequisites, not an implicit sequence
readonly scope: "host" | "project" | "provider" | "local-site" | "remote-site" | "builder";
run(s: Session, ctx: CheckContext): Promise<Finding[]>;
}
Providers and builders contribute their own checks. Declaring prerequisites means adding one is a one-file change instead of an edit to a 90-line orchestrator, and independent checks run in one wave. The scheduler is a topological grouping of ~20 lines, not a framework.
13. Safety
A first draft put direction: "up" | "down" on the command as a discriminated union, so an upward
op could not compile without declaring force, backup and confirm. The guarantee is false:
cwp pushwith no environment argument is a local write. The tool rests on the local database being disposable. The same command is “up” on Tuesday and “local” on Wednesday, decided by a positional argument. A compile-time union becomesdirection: "up"plus a runtime early-out: today’s code with more types.- A downward crossing performs an upward write. The adoption path writes a uid onto the remote,
under the full guard set, inside a command whose name says “down”. B-080 is
what happens when a composite forgets it:
cwp pulldeclaredwrites: "none"for the whole run and executed a sub-plan that declaredwrites: "remote". Nothing asked the ladder.
So the policy goes on the effect. The applier refuses at runtime to execute any effect where
writes === "remote" unless the guard ledger records that the remote-write guard set passed for
that target and source !== null (P2). One enforcement point, one test suite. cwp discovers
direction rather than declaring it.
export interface RemoteWriteGuard {
force: ForcePolicy; // protected-environment refusal — a Refuse-step guard
backup: BackupPolicy; // consults provider capabilities (§9.2) — a Consent-step guard
confirm: ConfirmSpec; // must enumerate when the blast radius is named items
}
blast: "named" forces the confirmation to enumerate. The renderer builds that text from
plan.effects. S-0016’s refinement becomes mechanical.
The product decision this rests on, taken
--force meant two unrelated things, both reachable in a single cwp push prod --force: override
the protected-environment refusal, and overwrite a post that changed remotely since cwp last saw it.
A policy layer that mapped force to one named policy would have made that fusion permanent.
They were split. --force keeps the first meaning and --overwrite-conflicts takes the
second. ADR-013 kept the split when the surface flattened and gave the reason in one line. The
alternative to a narrow flag is --force. That flag overrides every refusal at once. An operator
resolving one conflict would be reaching for the instrument that waives the protected-
environment guard as a side effect.
The tension the passage names has not gone away; it has moved. cwp pull and cwp push now carry
three refusals each, and --force overrides all of them together. Whether the dirty-tree refusal and
the non-fast-forward refusal deserve their own flags is the same question one level up. It is
open.
The trade, priced
Declarative guards buy coverage and cost auditability. Three mitigations. refuse/consent are two
small functions with exhaustive tests. The plan records which guards fired and the JSON envelope
exposes them, so a guard that silently did not run is visible in output. The invariant catalogue
fails the build if a named test disappears. Better than seventeen hand-placed copies. Those two functions
become the most safety-critical code in the project.
14. Testing
| Tier | Doubles | Target | What it catches |
|---|---|---|---|
domain/ | none — plain values, real temp dirs where a planner stats | ≥ 95 % | planning, precedence, parsing, canonical form |
tools/ incl. providers & builders | none — exact argv assertions | ≥ 90 % | the highest-value tests: a wrong --app, host or path |
ops/ | TestSession | ≥ 85 % | step ordering, guard placement, dry-run behaviour |
cli/ | manifest-driven conformance | ≥ 70 % | flag plumbing, exit codes, envelope shape |
Five changes from today.
A real interface instead of a cast. Today’s tests attach the fake with fake as unknown as Runner. A new method on Runner still compiles everywhere and goes silently unimplemented.
Executor is an interface; ScriptedExecutor implements it and breaks at compile time.
Structured transcripts instead of substring matching. An ordered CommandSpec[] with structured
matchers, not runner.ran("tar czf /tmp/cwp-uploads-") against a rendered shell string. This does
not replace exact-argv assertions in tools/. They stay primary: a wrong --app
is the class of bug that damages a site, and only argv assertions catch it.
One suite per seam, run against every implementation. One Provider conformance suite runs
against cloudron, ssh and local. One Builder conformance suite runs against bricks, none
and elementor. A behaviour that only holds for the first implementation fails immediately.
A second implementation exists to catch it.
Golden plan snapshots for the three commands whose plans are complete: push --delete,
design push, content push. Not for push on a transferDryRun: false provider, where SPEC §2
already says the plan is not a diff.
Canonical-form round-trip tests. toRepoForm(toSiteForm(x)) === x, sorted-key stability, and the
property that matters most under P2: the same page pulled from two identical sites produces
byte-identical files. Without that test, P2 degrades into a directory of exports.
Plus five structural tests: checks that read the tree rather than run it, so a rule survives
the person who wrote it. The layering check, the ref integrity check (§15), and the S-0021
envelope-shape check landed with pass one. The builder-id check (§10.3) landed with the seam: no
comparison against builder.id outside tools/builders/. ops/init/index.ts is the one permanent
exception. init chooses the id.
The fifth is the upward-guard check, added 2026-08-02: an op that declares writes: "remote"
also calls refuseRemoteWrites. Every op did already, so the check fixes nothing. They did so
by convention, and B-022 is what a required step looks like when it lives in a call site
instead of a structure. Its worse form is available here: the plan would keep declaring a refusal
the apply had stopped performing.
Each of the five is verified by breaking it. A structural test that has never been seen red is a comment with a test runner attached.
A check can be right about the thing it looks at and look at the wrong half. Four defects on the git-model branch were one shape. The shape will recur. This section records it. The accuracy gate read inline code spans and not the fenced block a reader copies from (B-076). The transcript check compared a version string and not the code that produced it (B-078). The flag rule asked whether a flag exists anywhere and not whether it exists on the page documenting it (B-082). F-095’s acceptance checked that every old command name still runs, and not that the new one can do what the old one did (B-083).
None of the four was found by review. Each turned up by walking into it: writing a page, taking a
recording, checking a sentence before publishing it. Three now have structural tests of their own:
fix-hints, alias-flags, and the fenced-example scanner. An invariant holds the fourth.
The question to ask of a new check is therefore not whether it passes, but which half of its subject
it is not looking at.
Tooling: biome, the one dev dependency added. exactOptionalPropertyTypes,
noUnusedLocals and noImplicitReturns. v8 coverage with per-layer thresholds. CI on Node 22 and
24, since engines says >=22.
15. Rationale, made checkable
About 20 % of the current source is comments, much of it durable rationale tied to bug IDs. Kept
verbatim. But the link between a B-0xx in a comment and its entry in BUGS.md is prose, and
nothing notices when it rots.
Finding.ref and the invariant catalogue carry structured references, and a test asserts each
resolves. A generated docs/WHY.md indexes every referenced bug and feature to the code implementing
the response. “Why is the transfer verified by digest?” becomes one link, not a grep through 230 KB.
16. Performance
Startup is already fine (70 ms, single-file bundle) and this design does not touch it; lazy command loading is explicitly not proposed. The slowness is entirely sequential remote round-trips.
| Operation | Today | Target | How |
|---|---|---|---|
cwp doctor | ~22 sequential round-trips, cloudron list twice | ~5 waves | the check registry’s needs graph; Facts memoisation |
cwp pull --only content (N posts) | ~2N bootstraps | 2–3 | batched reads |
cwp push --only content (10 pages) | ~50 bootstraps | ~15 | batched reads; writes stay sequential |
cwp pull --only bricks | 12 types × pages | 1–2 | readAll() inside a read batch |
cwp push (M paths) | sequential | sequential — deliberately | each transfer verifies before the next begins |
Batched ability transport: reads yes, writes only with streaming
Every wp eval-file pays a full WordPress bootstrap inside a remote exec: comfortably 0.5–2 s.
Today a content push reads two abilities per item and writes three, so ten pages is ~50 bootstraps.
- Reads batch freely. Independent, unordered, no partial-failure semantics. Most of the latency is here. Ship this alone first.
- Writes batch only when the op declares it, and only for annotated-safe abilities. SPEC §5.2’s
rule holds: 46 of 175 abilities declare nothing, one that declares nothing is a write, and
nullstays distinct fromfalse. - A batched write must stream one framed result line per item, flushed as it completes, not one array at the end. A batch killed mid-PHP by a fatal error, a memory limit or an exec timeout returns nothing, and cwp then cannot distinguish “nothing happened” from “six of ten happened”. B-003’s failure class returns, re-introduced by cwp’s own transport. Without streaming, do not batch writes at all.
- Never batch across a destructive boundary, and bound by payload bytes. F-033’s
import --deletewants a sequential, observable loop. And nobody has measured the stdin limit forwp eval-file -under a remote exec, while media bytes travel the same pipe.
Three rules govern concurrency, all enforced by types: only intent: "read" parallelises; writes stay
sequential and in plan order; the journal flushes in submission order so --json stays deterministic.
Honest expectation: doctor from ~15 s to under 4 s, content operations roughly 3×. Two of doctor’s
wins are worth a few lines each and would be worth doing to the current codebase tomorrow:
deduplicating cloudron list, and running the four independent host probes in one wave.
17. Adding things: the on-ramp
- A new command. One manifest entry plus an op module. The runtime supplies flags, session, guards, exit codes and the envelope; conformance tests run automatically.
- A new host. One
Providerimplementation plus its capability declaration. The conformance suite tells you what is missing. Nothing inops/changes. - A new page builder. One
Builderimplementation. The conformance suite runs it against the same content and design-system tests as Bricks. - A new doctor check. One file declaring
needsandscope, returningFinding[]. - A new config setting. One schema field, one registry entry with its prose, one resolver line.
- A new safety invariant. One catalogue entry plus the test it names. The build fails if that test goes away.
18. What this deliberately does not do
- No plugin system. S-0016 rules it out; shell hooks cover extensibility. Providers and
builders are compiled-in implementations of closed interfaces, chosen by config. Not a discovery
mechanism, not third-party loadable. The manifest is a plain array in one file, statically
imported, with no
loadCommands(). AloadCommands()is exactly what grows a plugin system. - No hosting-API providers. Control-panel REST clients are a new dependency class and a different reliability model. The interface does not forbid one; nothing schedules one.
- No dependency-injection container. One
composeSession()wires everything explicitly. - No new runtime dependencies. The six stay. Biome is the one dev dependency added.
- No effect system, no monads, no DSL. Plans are arrays of plain objects.
- No schema-derived settings registry. §8.1. The hand-written one is a feature.
- cwp still runs no git commands. P2 means diffable files, not commits. Directories, not history.
- No event sourcing, no undo. Provider backups and DDEV snapshots are the right primitives.
- No i18n. Output is English.
- No replacement of commander, zod, execa or yaml. Commander’s parent/child quirk (B-012) is
contained in
cli/runtime.tswhere exactly one piece of code knows it. - No lazy command loading. No measured problem.
- No programmatic API as a product. The ops layer is shaped so it could become one; it ships no stability promise.
19. Traps this design deliberately avoids
Five invariants a naive version of this architecture would have weakened. Recorded so nobody re-proposes them.
19.1 One guard phase breaks the protected-environment refusal under --dry-run. Today’s ordering
hides an invariant: the refusal runs even in a dry run; the consent does not. Merge them into one
phase with one dry-run switch and cwp push prod --dry-run stops refusing and starts printing a plan
for a write it would never permit. Hence Refuse and Consent are separate steps with opposite
dry-run behaviour. Every guard declares which it is, with no default.
19.2 A Verify phase after Apply is strictly more damage than today. The current push is a fold:
transfer item 1, verify item 1 (throws), transfer item 2… A three-path push whose second path fails
its digest never transfers the third. Move verification to a phase and all three transfer first.
SPEC §2 says a failed push cannot be repaired by re-running: cloudron sync compares size
and mtime, so it calls a corrupt remote file up to date forever. Verification is per-effect,
inside the fold.
19.3 Command-level direction is a false guarantee. bricks import with no env is local;
content pull writes upward to adopt. Direction lives on the effect (§13).
19.4 The mail guard must be a post-condition, not a plan entry. CLAUDE.md rule 5: every pull
installs it, even with scrubbing disabled. As a plan entry, someone adds a scope fast-path, the
fast-path stops contributing the entry, and nothing complains. As an Assert it holds regardless of path. The one
place this architecture is strictly stronger than today’s.
19.5 bestEffort must be mandatory with no default. Pull has seven best-effort steps and exactly
one that must be fatal: a failed pre-pull snapshot aborts the pull. Give the field a default and
the majority wins, and the snapshot becomes fatal-by-accident-of-authorship. The codebase already
makes this argument about ability annotations: silence is not a licence.
20. Build order
Ordered so something runs end-to-end early, and so each seam gets its second implementation while the interface is still cheap to change.
- Kernel: executor, journal + renderers, workspace, findings, facts. ~600 lines, fully tested.
- Domain: settings resolver, schema, paths, plan and effect types, policy, the invariant catalogue, canonical serialisation. Pure, so fast to write and test.
- Providers, both at once.
cloudronandlocaltogether, against the shared conformance suite. Building the second one now stops the interface calcifying around the first. - Runtime + manifest +
cwp doctorend to end. A survey: exercises the check registry, parallel reads, findings, both renderers, and provider capability reporting. Cannot damage anything. - The downward path:
pull,db,scrub,fetch. Snapshot policy, the mail-guard assertion, theunknowneffect kind. - The upward path:
push/deploy,plugins apply. Therefuse/consentsuite and the golden plans land before the first transfer works. P2’s source check lands here. This is the first place an upward effect exists. - Builders:
noneandbricks.nonefirst: it is trivial and it proves the seam. Then Bricks, migrating the ability transport intotools/abilities/on the way. - Content and design-system, on the canonical form from step 2. Three-way drift in
status.cwp abilitylands here too (moved from step 7, 2026-08-01): F-035’s net for a destructive design-system call is the design-system export, so porting the command earlier would either grow the bridge to wrap what it is waiting for, or ship the command without its net: B-022’s shape exactly.cwp bricks export|import→cwp bricks pull|pushlands in the same commit, so the rename is one break rather than two. Read batching only; write batching only once the shim streams framed lines. sshprovider. By now the interface has four consumers and the capability model is exercised; this is wherebackup: "synthetic"andtransferDryRun: trueprove it. Its generated DDEV provider config must be executed in a test, not reviewed (B-015’s lesson).- Conduits and surveys:
wp,shell,logs,status,projects,config,open. initcomes last. It scaffolds artefacts whose shape everything else has fixed by then, and it now has to ask which provider and which builder.elementor, if and when a project needs it. The interface is validated by then; this is a hundred-line exercise or it is evidence the seam is wrong.
Templates, PHP shims and the rationale corpus carry over verbatim throughout.
21. Honest assessment
What this buys. Seventeen copies of the command shell become one runtime. Thirty hand-written dry-run branches become one workspace. Two error models become one vocabulary. One resolver expresses precedence. Guard placement moves from memory to a runtime with an audit trail in the JSON output. Nothing welds cwp to one host any more. The capability model that a second provider forces makes the first provider safer: “this host cannot back up” becomes a refusal instead of an assumption that never fails. Bricks becomes one builder among several at roughly its true cost, about 4 100 lines rather than 4 700, with 559 lines of generic transport recovered. And every upward change acquires a reviewable source, the difference between a deployment tool and a deployment record.
What it costs. refuse and consent become the most safety-critical code in the project, and a
runtime-applied guard is harder to audit at a glance than a hand-placed one. The plan model must
carry unresolved slots and explicit unknowns from its first version. Write batching changes what a
partial failure leaves on a live site, and is only safe with a streaming shim. P2 is the most
expensive item here. Canonical serialisation is fiddly, and rule 6 (a pull that changes nothing
writes nothing) is easy to state and easy to break. If it breaks, every
diff fills with noise and nobody uses the principle again.
Where the value concentrates. Three changes carry most of the benefit and none of the risk: the
Session, the shared command runtime, the settings resolver. The Session replaces twenty context
structs. The runtime retires 2 067 untested lines. Worth doing whether or not the pipeline ever ships.
What would make this a bad idea. If the pipeline needs a fourth shape beyond operation, conduit
and survey, the abstraction is wrong. The honest fallback is to keep procedural ops and take
only the kernel, the Session, the resolver, the manifest and the two seams. The fallback is
roughly 60 % of the value for 25 % of the work, and a perfectly good place to stop.