❯ 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.
Data flows down. Code flows up.
Nothing moves sideways by accident — and the rule that makes that 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 upward is guarded on that one thing and nothing else.
- local
- dev
- prodprotected
- databasedev → localno guard — nothing upstream changes
- uploadsdev → localno guard — nothing upstream changes
Data comes down. Nothing is guarded, because nothing upstream changes.
- page contentdev → localno guard — nothing upstream changes
- media referencesdev → localno guard — nothing upstream changes
- uid adoptionlocal → devguarded: confirm, restore point, source in the repo
The interesting one. Its name says down, and one of its effects goes up — so that one effect is guarded and the others are not.
- tracked pathslocal → devguarded: confirm, restore point, source in the repo
- post-deploy stepslocal → devguarded: confirm, restore point, source in the repo
Code goes up, through the gate. dev is not protected, so the gate opens once you have confirmed and a restore point exists.
- tracked pathslocal → prodrefused — prod is protected
prod is protected. The gate is shut, and it stays shut under --dry-run as well.
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 marked protected, unless --force is given.
- 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 is refused outright on a protected environment.
- 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 that cannot be honoured refuses instead of degrading.
- ConfirmationConsentexit 6
Proceeding without an answer. Where the blast radius is named items the prompt enumerates them, because the text is generated from the plan.
- Restore pointConsent
Nothing — this is where the remote backup and the local snapshot are taken, once every refusal above has passed.
- Verification, per effectApplyexit 1
The rest of the fold. Each transfer is verified 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.
12 rules. 12 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 just lost its cover.
| Invariant | What it says | Asserted by |
|---|---|---|
| PROTECTED_NEEDS_FORCESPEC §5.4 | a protected environment refuses every upward write without --force | policy: a protected environment refuses an upward write without --force |
| REFUSAL_SURVIVES_DRY_RUNSPEC §5.4 / §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 §5.4 | 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 | 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_OVERWRITTENSPEC §5.8 | 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 |
| SNAPSHOT_ABORTS_PULLSPEC §5.3 / §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 installs the mail guard, on every terminating path, even with scrubbing disabled | ops/pull: the mail guard is installed on every terminating path |
| REFUSAL_HAS_NO_EFFECTSSPEC §5.4 | 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 §5.4 / §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 it has never been built — so there is no command that 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 logs 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.