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

treecontent/, bricks/, settings/, widgets/, roles.yml, inventory.yml, tracked paths

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.

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.

  1. DirectionRefuseexit 5

    An effect that writes to a remote without the guard ledger recording that the remote-write set passed for that target.

  2. Protected environmentRefuseexit 5

    Any upward write to an environment whose mode is protected, unless you pass --force.

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

  4. Escaping or empty trackedRefuseexit 5

    A path that resolves outside the tracked list, and a tracked list with nothing in it.

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

  6. ConfirmationConsentexit 6

    Proceeding without an answer. Where the blast radius is named items, the prompt lists them: the text comes from the plan.

  7. Restore pointConsent

    Nothing. Here cwp takes the remote backup and the local snapshot, once every refusal above has passed.

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

The 18 safety invariants, the rule each states, and the test that asserts it.
InvariantWhat it saysAsserted by
PROTECTED_NEEDS_FORCESPEC §2an environment whose `mode` is `protected` refuses every upward write without --forcepolicy: a protected environment refuses an upward write without --force
REFUSAL_SURVIVES_DRY_RUNSPEC §2 / §19.1the refusal runs under --dry-run too; the consent does notpolicy: a protected environment refuses under --dry-run as well
BACKUP_BEFORE_UPWARDSPEC §2no remote write proceeds without a restore point, or an explicit waiverpolicy: an upward write needs a restore point or an explicit waiver
BACKUP_CAPABILITY_REFUSES§9.2a provider that cannot back up refuses the upward write rather than skipping itpolicy: a provider that cannot back up refuses rather than skipping
UPWARD_HAS_A_SOURCEP2 / F-043an upward effect with no repo source is refused, unless it declares itself unreproduciblepolicy: an upward effect with no repo source is refused
WRITE_SCOPE_IS_PER_EFFECT§13 / §19.3direction lives on the effect, never on the commandpolicy: 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'spolicy: a fetch never overwrites a tracked path
NEVER_RAW_SQLCLAUDE.md rule 6URL replacement goes through wp search-replace, never raw SQLpolicy: a URL rewrite must use search-replace, never raw SQL
PULL_REFUSES_DIRTY_TREES-0003 / ADR-002 / F-092a downward crossing refuses where it would overwrite uncommitted work in a path it writes — `--force` overridesops/crossing: a pull refuses a dirty tree in a path it writes
PULL_REFUSES_CONFLICTS-0003 / ADR-002 / F-092a downward crossing refuses any item that changed on both sides, by name — `--force` overridesops/crossing: a pull refuses an item that changed on both sides
PUSH_REFUSES_NON_FAST_FORWARDS-0004 / ADR-002 / F-092 / B-087an 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 skippedops/crossing: a push refuses when the environment moved since base
SNAPSHOT_ABORTS_PULLS-0003 / `docs/commands/pull.md` / §19.5a failed pre-pull snapshot aborts the pull — the one step of seven that is not best-effortops/pull: a failed pre-pull snapshot aborts the pull
MAIL_GUARD_ALWAYSCLAUDE.md rule 5 / §19.4every pull that imports a database installs the mail guard, on every terminating path, even with scrubbing disabledops/pull: the mail guard is installed on every terminating path
SOURCE_IS_NEVER_WRITTENF-088 / CLAUDE.md rule 2an environment whose `mode` is `source` is refused every remote write, and no flag opens itops/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 offis refused rather than read, in the direction the mode exists for
CROSSING_DECLARES_SUB_WRITESCLAUDE.md rules 2 and 4 / ADR-005a composite crossing carries its sub-plans' effects, so the ladder sees every remote write the run will makeops/crossing: a pull that adopts refuses a protected environment
REFUSAL_HAS_NO_EFFECTSSPEC §2a refused push runs no hook, takes no backup and transfers nothingops/push: a refused push has no side effects at all
VERIFY_BEFORE_NEXTSPEC §2 / §19.2each transfer is verified as it lands; a failed digest stops the foldops/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.