Design document

docs/ARCHITECTURE.md

The v1.0 rebuild

cwp as it is meant to become — the v1.0 architecture.

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, returned CheckResult), two logging paths (Reporter, and the Runner’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.all in 15 500 lines. cwp doctor makes ~22 sequential remote round-trips and calls cloudron list twice. A ten-page cwp content push pays 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:

  1. There are two sides — a local DDEV container and a remote site — and almost every operation is the same work against either one.
  2. Data is directional, but direction is a property of what an effect touches, not of what a command is called. cwp bricks import with no environment argument is a local write. cwp content pull writes a uid upward to adopt a post. A design keyed on command names gets both wrong.
  3. The external tools lie. cloudron sync pull exits 0 having moved nothing (B-003). sync push is 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.
  4. 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.md names this as the recurring shape of the entire project.
  5. 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-run less truthful.
  6. 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 --adhoc that refuses protected environments outright and writes the payload to .cwp/adhoc/<timestamp>-<ability>.json so 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 status answers 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.

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

Surveyscwp 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 nativecloudron backup create syntheticmysqldump + tar of wp-content into a retained path
transferDryRun false truersync --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 covers exec but not transfer. It would be an ssh variant, 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:

  1. bricks.enabled defaults to false, and pull.ts gates every Bricks step behind it.
  2. The content transfer already has a builder switch. ContentContext carries bricks: boolean, documented as: “False on a project that is not a Bricks site, where the post row and its meta are still worth transferring and bricks/get-page-elements is not registered to ask. The artifact records bricks: null for those.” The non-builder path is designed, not accidental.
  3. The transport is not Bricks. wp eval-file with a sentinel-framed shim over the WordPress Abilities API is generic infrastructure; abilities.ts is 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 is post_content. It is trivial, and its triviality is the proof the interface is not Bricks-shaped: if none needs 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_assets and code signatures on import, the postTypes global setting, the readiness chain of SPEC §9.2.
  • elementor — the honest stress test, and the reason to believe the interface generalises. Elementor stores _elementor_data as JSON in postmeta and has no abilities API at all, so readPage/writePage go through wp post meta get/update instead 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

  1. One item per file. A 4 000-line design-system dump has no reviewable diff; forty files of a hundred lines each do.
  2. 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”.
  3. 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.
  4. 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.
  5. URLs as placeholders. Already implemented (toStoredForm/toSiteForm); generalised so that the same page pulled from dev and from prod produces byte-identical files.
  6. A pull that changes nothing writes nothing. Enforced by Workspace.write returning unchanged (§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.
  • --adhoc is 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 import with 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 becomes direction: "up" plus a runtime early-out — today’s code with more types.
  • cwp content pull performs 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 completepush --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.

  1. Reads batch freely. Independent, unordered, no partial-failure semantics. Most of the latency is here. Ship this alone first.
  2. 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 null stays distinct from false.
  3. 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.
  4. Never batch across a destructive boundary, and bound by payload bytes. F-033’s import --delete wants a sequential, observable loop. And nobody has measured the stdin limit for wp 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 Provider implementation plus its capability declaration. The conformance suite tells you what is missing. Nothing in ops/ changes.
  • A new page builder. One Builder implementation. The conformance suite runs it against the same content and design-system tests as Bricks.
  • A new doctor check. One file declaring needs and scope, returning Finding[].
  • 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.ts where 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.

  1. Kernel — executor, journal + renderers, workspace, findings, facts. ~600 lines, fully tested.
  2. Domain — settings resolver, schema, paths, plan and effect types, policy, the invariant catalogue, canonical serialisation. Pure, so fast to write and test.
  3. Providers, both at once. cloudron and local together, against the shared conformance suite. Building the second one now is what stops the interface calcifying around the first.
  4. Runtime + manifest + cwp doctor end to end. A survey: exercises the check registry, parallel reads, findings, both renderers, and provider capability reporting. Cannot damage anything.
  5. The downward pathpull, db, scrub, fetch. Snapshot policy, the mail-guard assertion, the unknown effect kind.
  6. The upward pathpush/deploy, plugins apply. The refuse/consent suite 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.
  7. Builders: none and bricks. none first — it is trivial and it proves the seam. Then Bricks, migrating the ability transport into tools/abilities/ on the way.
  8. 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.
  9. ssh provider. By now the interface has four consumers and the capability model is exercised; this is where backup: "synthetic" and transferDryRun: true prove it. Its generated DDEV provider config must be executed in a test, not reviewed — B-015’s lesson.
  10. Conduits and surveyswp, shell, logs, status, projects, config, open.
  11. init — last, because it scaffolds artefacts whose shape everything else has just fixed, and it now has to ask which provider and which builder.
  12. 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.