❯ 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 — Target Architecture (v1.0)
Status: Design. Nothing here is implemented.
Owner: BERNSTEINKRAFT n.e.V.
Tracked as: F-040 (the rebuild), F-041 (providers), F-042 (builders),
F-043 (reproducibility) in FEATURES.md.
Relationship to SPEC.md. SPEC.md is authoritative for cwp as it is. This
document is authoritative for cwp as it is meant to become. Where the two disagree,
SPEC.md wins until the corresponding phase of F-040 lands — at which point SPEC.md
is updated and this paragraph gets shorter. The safety invariants are the exception:
they are identical in both, by design, and §19 records the five places where a naive
version of this architecture would have weakened one.
What it supersedes on landing. SPEC §3’s non-goal “no support for hosts other than Cloudron” (§9 replaces it with a provider seam that ships a second implementation), and the implicit assumption throughout SPEC §9 that Bricks is the page builder rather than a page builder (§10 measures how much of that assumption is already false).
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
its shape was discovered rather than designed: flows/ grew to 27 procedural scripts, each carrying
its own bespoke context struct, its own hand-written dry-run branches, and 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, because 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, so wiring is re-decided per flow and nothing is shared without an import between flows.
- Nothing runs in parallel. One
Promise.allin 15 500 lines.cwp doctormakes ~22 sequential remote round-trips and callscloudron listtwice. A ten-pagecwp content pushpays roughly fifty WordPress bootstraps.
And three things the tool cannot yet do, which this design treats 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” — though, as §10 measures, far less of it is genuinely Bricks-shaped.
The thesis: almost every command is an instance of one pipeline, over two pluggable seams, with one source of truth. Name those three things and duplication, dry-run handling, guard placement, host portability and reproducibility all become properties of the architecture instead of things each command remembers.
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 actually is
Strip the commands away and the essential complexity is small:
- There are two sides — a local DDEV container and a remote site — and almost every operation is the same work against either one.
- Data is directional, but direction is a property of what an effect touches, not of what a
command is called.
cwp bricks importwith 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 not a nicety; it 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. But some effects are genuinely not knowable in advance, and 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, and 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. They are stated once, here.
P1 — Direction asymmetry (unchanged from SPEC §2)
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 — Reproducibility: 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 (design-system/), the plugin inventory (plugins.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 '{…}'— today an unlogged arbitrary write to a live site — becomes--from <repo-path>, or an explicit--adhocthat refuses protected environments outright and writes the payload to.cwp/adhoc/<timestamp>-<ability>.jsonso a trace exists.- Artifacts must be diffable, which is a real engineering requirement, not a formatting preference. §11 specifies it.
- Drift becomes a three-way question — repo vs local vs remote — and
cwp statusanswers it.
This sharpens rather than contradicts SPEC §2’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. The
pre-push hook is where a project can require a clean tree.
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 §3 rightly listed other hosts as a non-goal on those grounds. This design supersedes that decision, because each seam ships with a second implementation that is deliberately unlike the first — and, 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 |
SPEC §12; 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 logs -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 §9.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. |
| 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
Being honest about exceptions is what stops the spine eroding into an escape hatch. All are first-class types, not opt-outs.
Conduits — cwp wp, cwp shell, cwp logs. announce → hand over → forward the exit code.
stdio is inherited, 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 — SPEC §12 calls
this “the one documented exception”. Unguarded by design (SPEC §5.6: 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. That planner is what 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. Disk reads are allowed in
domain planners. Writes are not — those go through the Workspace (§7.3), which is a dry-run and
journaling mechanism, not 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. That 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 it is carried by which method you call (run vs probe
vs passThrough). Moving it onto the value buys two things a method split cannot: the executor
decides dry-run behaviour uniformly, and 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 — and 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 SPEC §12 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 — deleting
roughly thirty hand-written if (ctx.dryRun) { report "would write…"; return } blocks.
unchanged is not cosmetic. SPEC §6.1 requires init to repair rather than overwrite and to
report every change; a write that changed nothing must not be reported as one. Under P2 it matters
more: cwp content pull writing an identical file must leave the git tree clean, or 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 §5.4" | "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. Where the highest-value tests go, because 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 — 922 lines, the largest file, thinnest tested relative to size — is
pure: planning, hashing, normalisation, reference collection, conflict detection. 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 — and 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: "SPEC §5.4", test: "policy: protected refuses without --force" },
REFUSAL_SURVIVES_DRY_RUN: { ref: "SPEC §5.4", test: "ops/push: --dry-run on protected refuses" },
BACKUP_BEFORE_UPWARD: { ref: "SPEC §5.4", test: "policy: no remote write without a restore point" },
REFUSAL_HAS_NO_EFFECTS: { ref: "SPEC §5.4", test: "ops/push: refused push runs no hook" },
SNAPSHOT_ABORTS_PULL: { ref: "SPEC §5.3", test: "ops/pull: failed snapshot aborts" },
MAIL_GUARD_ALWAYS: { ref: "SPEC §8", test: "ops/pull: present on every terminating path" },
VERIFY_BEFORE_NEXT: { ref: "SPEC §5.4", test: "ops/push: failed digest stops the fold" },
NEVER_RAW_SQL: { ref: "SPEC §5.3", 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. The rule stops being prose.
9. Seam one — Providers
9.1 What a provider is
A provider answers: where does this site live, how do I run a command there, how do I move files,
and can I take a restore point? It is a pure argv builder plus a small number of transfer
routines — it returns CommandSpecs rather than running them, so 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 {
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 |
|
|---|---|---|
| backup | native — cloudron backup create |
synthetic — mysqldump + tar of wp-content into a retained path |
| transferDryRun | false | true — rsync --dry-run |
| mirrorDelete | true (--delete) |
true (rsync --delete) |
| remoteDigests | true | true |
| logs / shell | follow / true |
tail / true |
Two payoffs fall straight out:
The backup invariant survives a host that cannot back up — by refusing. BACKUP_BEFORE_UPWARD
consults capabilities.backup. none means the upward write is refused, with a fix hint naming
what to do, unless --no-backup is given explicitly. That is CLAUDE.md’s standing rule — degrade
to a clear instruction, never to a silent failure — applied to a capability the current code cannot
express because it assumes cloudron backup create always exists.
A documented limitation becomes a per-provider fact. SPEC §5.4 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.”
That is true 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), so the
same op produces a vaguer or sharper plan depending on the provider, without a 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. That unlikeness is the point — it is what proves the interface is an interface.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). That is a new dependency class and a different reliability model; the interface does not forbid it, and nothing schedules it.
9.4 What this costs
The DDEV provider template (.ddev/providers/cloudron.yaml) becomes per-provider, and B-015’s
lesson applies with more force: a stanza set that was verified against the schema and never executed
was correct in every name and 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 designed, 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. What is missing is not decoupling — it is a name for the seam, so that 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[]>;
}
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 §9.2.elementor— the honest stress test, and the reason to believe the interface generalises. 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; 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.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/ — a Bricks concept |
| URL placeholder normalisation | domain/canonical/ — every builder needs it |
bricks: config block |
builder: { id, options } — bricks.child_theme/wp_user become options |
Naming follows: cwp bricks export becomes cwp design pull, bricks/ becomes design-system/,
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.
11. The repository as desired state
P2 says every upward write originates in a committed file. That is only true if the files are genuinely reviewable, so this section is a specification, not a preference.
11.1 The tracked tree
content/ one post per file, named by stable uid + slug
design-system/ one item per file: classes, variables, components, templates, settings
plugins.yml the inventory
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. JSON, two-space indent, trailing newline. Bricks element trees are nested JSON; pretty-printing with sorted keys is the difference between “one element changed” and “the file changed”.
- 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 the diff stops being read.
Rules 3–6 are what 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:
repo ─── local ─── remote
design-system/ ✓ ═══════ ✓ ═══════ ✗ 3 classes differ on prod
content/ ✓ ═══════ ✗ ✓ 2 pages edited locally, uncommitted
plugins.yml ✓ ═══════ ✓ ═══════ ✓
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” is preserved.
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 to.cwp/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. It is that 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. That is what makes the ops layer
callable without the CLI — for tests today, and for a cwp mcp serve or a programmatic caller later,
without another rebuild. We are not building that API; we are declining 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. Unresolved slots are filled by apply, which
produces a ledger; the plan need not be complete.
unknown. Today’s pull says of the Bricks post-import steps: the steps depend on what the
installed Bricks version can do, which is only knowable by probing it — so a dry run does not pretend
to know. 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 about to be wiped — and now to transferDryRun: false providers (§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
design/ pull, push, audit, snapshot survey + operation (was `bricks`)
builder/ ability, abilities, mcp, post-types, preview builder-specific
plugins/ snapshot, apply survey + operation
conduits/ wp, shell, logs conduit
doctor gets the one genuine registry — and 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, ~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. That is a false guarantee:
cwp bricks importwith 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.cwp content pullperforms an upward write. Its adoption path writes a uid onto the remote, under the full guard set, inside a command whose name says “down”.
So the policy goes on the effect, and 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, honest about the
fact that direction is discovered rather than declared.
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, because the text is generated from
plan.effects. SPEC §2’s refinement becomes mechanical.
One product decision to make first
--force currently means two unrelated things, both reachable in a single cwp content push prod --force: override the protected-environment refusal, and overwrite a post that changed remotely
since cwp last saw it. A policy layer that maps force to one named policy makes that fusion
permanent. Split them — --force keeps the first meaning, --overwrite-conflicts takes the
second. A breaking flag change is exactly what a v1.0 is for.
The trade, priced
Declarative guards buy coverage and cost auditability. 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 — but 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. The current fake is attached with fake as unknown as Runner, so a new method on Runner still compiles everywhere and is 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. But note
what this does not replace: exact-argv assertions in tools/ stay primary, because a wrong --app
is the class of bug that damages a site and only argv assertions catch it.
Every seam is tested twice, and the suites are shared. 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 — which
is the entire reason to have a second implementation.
Golden plan snapshots for the three commands whose plans are actually complete — push --delete,
design push, content push. Not for push on a transferDryRun: false provider, where SPEC §5.4
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 three structural tests: the layering check, the ref integrity check (§15), and the SPEC §12
envelope-shape check. Tooling: biome (the one dev dependency added), exactOptionalPropertyTypes
/ noUnusedLocals / 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 — so “why is the transfer verified by digest?” is 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 content pull (N posts) |
~2N bootstraps | 2–3 | batched reads |
cwp content push (10 pages) |
~50 bootstraps | ~15 | batched reads; writes stay sequential |
cwp design pull |
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 §9.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”. That is B-003’s failure class re-introduced by our 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 — deduplicating cloudron list and running the four independent host probes in one wave — are
worth a few lines each and would be worth doing to the current codebase tomorrow.
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. SPEC §2.7 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(). Written down here because that is exactly what grows one. - 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, and 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 — and
SPEC §5.4 says a failed push cannot be repaired by re-running, because cloudron sync compares size
and mtime, so a corrupt remote file is reported as 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 entry
stops being contributed, 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 is what 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 are written before the first transfer works. P2’s source check lands here, because 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. 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. init— last, because it scaffolds artefacts whose shape everything else has just fixed, 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. Precedence is expressed once. Guard placement moves from memory to a runtime with an audit trail in the JSON output. The host stops being welded in, and the capability model that a second provider forces makes the first provider safer, because “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, which is 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 the kind of property that is easy to state and easy to break; if it breaks, every
diff fills with noise and the principle quietly stops being used.
Where the value concentrates. Three changes carry most of the benefit and none of the risk: the
Session replacing twenty context structs, the shared command runtime retiring 2 067 untested lines,
and the settings resolver. 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 — and the honest fallback is to keep procedural ops and take
only the kernel, the Session, the resolver, the manifest and the two seams. That is roughly 60 % of
the value for 25 % of the work, and a perfectly good place to stop.