❯ Safety
Refusals you cannot configure away.
A stated direction, a refusal that runs even in a dry run, a restore point taken before the write and a verification taken after it. None of it is a setting.
The tree is the centre. Every site is a spoke.
Direction is measured from your repository, never from your machine. pull reads into the tree, push writes out of it, and the environment argument names which site. So cwp push with nothing after it is not a contradiction. The three sites differ in distance, not in kind.
The rule that makes “nothing moves sideways by accident” true is finer than it looks. Direction is a property of each effect, never of the command that produced it, so a command called pull that writes one thing outward is guarded on that one thing and nothing else. Every upward write passes through the tree: there is no arc from one spoke to another going out.
Everything through the hub is in and out. The environment argument names which site, never which way.
localthe DDEV container — a database and a filesystem, like the others
- page contenttree → localno refusal — the local site is not a remote, so the upward ladder is never asked
- menustree → localno refusal — the local site is not a remote, so the upward ladder is never asked
deva remote install, not marked protected
nothing moves here in this command
prodprotectedmarked protected in cwp.yml
nothing moves here in this command
No environment named, so the target is the local site. It is a write out of the tree into the nearest spoke — not a contradiction, and not a movement of zero distance.
Everything through the hub is in and out. The environment argument names which site, never which way.
localthe DDEV container — a database and a filesystem, like the others
nothing moves here in this command
deva remote install, not marked protected
- page contentdev → treeno guard — the write lands in the repository, where it can be read as a diff
- media referencesdev → treeno guard — the write lands in the repository, where it can be read as a diff
- uid adoptiontree → devguarded: refusal, confirmation, restore point, and a committed source for the write
prodprotectedmarked protected in cwp.yml
nothing moves here in this command
The interesting one. Its name says into the tree, and one of its effects goes the other way: the uid marker is written into the site it was read from, so that one effect is guarded and the others are not.
Everything through the hub is in and out. The environment argument names which site, never which way.
localthe DDEV container — a database and a filesystem, like the others
nothing moves here in this command
deva remote install, not marked protected
- tracked pathstree → devguarded: refusal, confirmation, restore point, and a committed source for the write
- post-deploy stepstree → devguarded: refusal, confirmation, restore point, and a committed source for the write
prodprotectedmarked protected in cwp.yml
nothing moves here in this command
Code leaves the tree for a site. There is no local target for it — the docroot lives inside the repository, so for code the local site is the tree, which is the reason a project has a dev at all.
Everything through the hub is in and out. The environment argument names which site, never which way.
localthe DDEV container — a database and a filesystem, like the others
nothing moves here in this command
deva remote install, not marked protected
nothing moves here in this command
prodprotectedmarked protected in cwp.yml
- tracked pathstree → prodrefused — prod is protected, and --dry-run does not soften it
The same arc, one spoke further out. prod is protected, so the gate is shut — and it stays shut under --dry-run, because a refusal that a dry run softens is not a refusal.
Everything through the hub is in and out. The environment argument names which site, never which way.
localthe DDEV container — a database and a filesystem, like the others
receives database and uploads from dev, on an edge that never passes through the tree
deva remote install, not marked protected
- databasedev → localno guard — bulk site state moving toward this machine, and it never enters the tree
- uploadsdev → localno guard — bulk site state moving toward this machine, and it never enters the tree
prodprotectedmarked protected in cwp.yml
nothing moves here in this command
The edge that skips the hub: site to site, never through the tree. It has one arrowhead and it points away from every site that matters, which is invariant 1 drawn instead of stated.
The ladder
Every upward write passes the same rungs in the same order, and the order is the guarantee. Refuse comes before Consent, so “a refused push has no side effects” is a property of the sequence rather than of anyone remembering it. No hook runs, no backup is taken, nothing is transferred.
- DirectionRefuseexit 5
An effect that writes to a remote without the guard ledger recording that the remote-write set passed for that target.
- Protected environmentRefuseexit 5
Any upward write to an environment whose mode is protected, unless you pass --force.
- No source in the repositoryRefuseexit 5
An upward effect that cannot name the committed file it came from. --from names one. --adhoc records the payload instead, and a protected environment refuses it outright.
- Escaping or empty trackedRefuseexit 5
A path that resolves outside the tracked list, and a tracked list with nothing in it.
- A host that cannot back upRefuseexit 5
The write itself, rather than the backup step. A capability the host cannot honour refuses instead of degrading.
- ConfirmationConsentexit 6
Proceeding without an answer. Where the blast radius is named items, the prompt lists them: the text comes from the plan.
- Restore pointConsent
Nothing. Here cwp takes the remote backup and the local snapshot, once every refusal above has passed.
- Verification, per effectApplyexit 1
The rest of the fold. cwp verifies each transfer by digest as it lands, and a failure stops the run before the next path moves.
Everything marked Refuse runs under --dry-run as well. Everything marked Consent does not: there is nothing to consent to, and nothing has happened yet that would need a restore point.
18 rules. 18 tests.
Anyone can publish a list of safety rules. The third column is the part that matters: each rule names the test that asserts it, and a test in the catalogue asserts that every one of those tests exists and passes. Delete the enforcement and the build says which rule lost its cover.
| Invariant | What it says | Asserted by |
|---|---|---|
| PROTECTED_NEEDS_FORCESPEC §2 | an environment whose `mode` is `protected` refuses every upward write without --force | policy: a protected environment refuses an upward write without --force |
| REFUSAL_SURVIVES_DRY_RUNSPEC §2 / §19.1 | the refusal runs under --dry-run too; the consent does not | policy: a protected environment refuses under --dry-run as well |
| BACKUP_BEFORE_UPWARDSPEC §2 | no remote write proceeds without a restore point, or an explicit waiver | policy: an upward write needs a restore point or an explicit waiver |
| BACKUP_CAPABILITY_REFUSES§9.2 | a provider that cannot back up refuses the upward write rather than skipping it | policy: a provider that cannot back up refuses rather than skipping |
| UPWARD_HAS_A_SOURCEP2 / F-043 | an upward effect with no repo source is refused, unless it declares itself unreproducible | policy: an upward effect with no repo source is refused |
| WRITE_SCOPE_IS_PER_EFFECT§13 / §19.3 | direction lives on the effect, never on the command | policy: direction is read from the effect, not the command |
| TRACKED_NEVER_OVERWRITTENS-0006 / `docs/commands/fetch.md` | a downward fetch never overwrites a path listed in `tracked` — that is your source, not the remote's | policy: a fetch never overwrites a tracked path |
| NEVER_RAW_SQLCLAUDE.md rule 6 | URL replacement goes through wp search-replace, never raw SQL | policy: a URL rewrite must use search-replace, never raw SQL |
| PULL_REFUSES_DIRTY_TREES-0003 / ADR-002 / F-092 | a downward crossing refuses where it would overwrite uncommitted work in a path it writes — `--force` overrides | ops/crossing: a pull refuses a dirty tree in a path it writes |
| PULL_REFUSES_CONFLICTS-0003 / ADR-002 / F-092 | a downward crossing refuses any item that changed on both sides, by name — `--force` overrides | ops/crossing: a pull refuses an item that changed on both sides |
| PUSH_REFUSES_NON_FAST_FORWARDS-0004 / ADR-002 / F-092 / B-087 | an upward crossing refuses when an artifact the survey could read has moved since `base` — roles, settings, widgets and inventory; `--force` overrides, and an artifact that cannot be read is named rather than skipped | ops/crossing: a push refuses when the environment moved since base |
| SNAPSHOT_ABORTS_PULLS-0003 / `docs/commands/pull.md` / §19.5 | a failed pre-pull snapshot aborts the pull — the one step of seven that is not best-effort | ops/pull: a failed pre-pull snapshot aborts the pull |
| MAIL_GUARD_ALWAYSCLAUDE.md rule 5 / §19.4 | every pull that imports a database installs the mail guard, on every terminating path, even with scrubbing disabled | ops/pull: the mail guard is installed on every terminating path |
| SOURCE_IS_NEVER_WRITTENF-088 / CLAUDE.md rule 2 | an environment whose `mode` is `source` is refused every remote write, and no flag opens it | ops/guard: refuses any remote write to an environment whose mode is source |
| SOURCE_RELEASES_BEFORE_IT_CHANGESS-0002 / S-0007 / F-088 | `mode: source` on an environment that is still adopted is refused in both directions until `--release` takes the marker off | is refused rather than read, in the direction the mode exists for |
| CROSSING_DECLARES_SUB_WRITESCLAUDE.md rules 2 and 4 / ADR-005 | a composite crossing carries its sub-plans' effects, so the ladder sees every remote write the run will make | ops/crossing: a pull that adopts refuses a protected environment |
| REFUSAL_HAS_NO_EFFECTSSPEC §2 | a refused push runs no hook, takes no backup and transfers nothing | ops/push: a refused push has no side effects at all |
| VERIFY_BEFORE_NEXTSPEC §2 / §19.2 | each transfer is verified as it lands; a failed digest stops the fold | ops/push: a failed digest stops the fold before the next transfer |
The build fails if any named test disappears. These are not documentation of intent. They are the tests, indexed by the rule they defend.
Exit codes
Stable across releases, and the reason a script or an agent can tell “this was refused” from “this went wrong”.
- 0
- success
- 1
- generic error
- 2
- configuration error
- 3
- missing dependency
- 4
- remote error
- 5
- refused by a safety guard
- 6
- aborted by user
cwp wp and the other conduits forward WP-CLI's own exit code verbatim, even where it collides with this table. A pass-through that rewrites exit codes is not a pass-through.
What is deliberately missing
cwp db push does not exist. It was designed, then deferred, and never built. No command moves a database upward, with or without a flag. If that changes, it will appear on the roadmap before it appears in the tool.
cwp wp, cwp shell and cwp tail are unguarded by design. cwp cannot classify WP-CLI subcommands, and a reflexive --force on wp plugin list is worse than no guard at all: it teaches you to pass the flag without reading. They announce their target instead.