Safety Model
The gates between an intent and a charge: confirmations, dry-run, parallel batch authorization, owner actions, and test-label mode.
Shipping tools can charge carrier accounts and permanently change data. The CLI encodes that risk so a script or model cannot spend money by accident. This article explains the safety classes, what --yes covers, how parallel batch approval is different, and how dry-run behaves.
In this article, we will review safety classes, authorize direct commands and postsale run, separate parent authorization from batch authorization, and preview high-risk work with dry-run (including what still writes).
Before We Begin
We recommend that you read How the CLI works first. Practice money paths with test-label mode when you can.
Safety classes
Every operation has a class:
| Class | Meaning | Non-interactive requirement |
|---|---|---|
| read | Safe to call. Most auto-retry on blips | None |
| idempotent-write | Changes state | Usually none |
| destructive | Permanent delete or irreversible close | --yes |
| money-moving | Carrier charge or void path | --yes |
Note
A few read operations are not auto-retried because the backend may write (for example some shipments-by-order lookups can validate and save an address). They are still safe to call. Only automatic retry is suppressed.
What counts as money-moving
Exactly these tools:
create_shipmentpurchase_labelsvoid_shipment
CLI cousins include orders create-shipment / shipments create, labels purchase, and labels void.
What counts as destructive
Deletes for carriers, origin addresses, filters, templates, shipping rules, automation, and shipments, plus create_end_of_day_manifest (end-of-day close is irreversible without carrier support).
The CLI also has postsale orders bulk-delete, a direct destructive command with no agent tool equivalent. It requires --yes and deletes multiple orders at once; deleted orders can be restored individually with orders recover.
Three authorization scopes
This is the concept that saves the most surprises:
Direct command → --yes on that command
postsale run (parent tools) → --yes or --authorize or interactive prompt
parallel_map swarm batch → separate BATCH approval (not covered by --yes)Direct commands
postsale labels purchase --shipment-id <shipment_uuid> --yes
postsale labels void --shipment-id <shipment_uuid> --yes
postsale shipments delete shp_1 --yesWithout a TTY and without --yes, expect exit 64 and confirmation_required.
The parent run
| Mechanism | Effect |
|---|---|
--yes | All parent money-moving and destructive tools |
--authorize purchase_labels,create_shipment | Only the tools you list |
POSTSALE_AUTHORIZED_TOOLS | Same idea via environment |
| Interactive terminal | Prompts per gated tool |
Least-privilege examples:
# Ship only (create + purchase)
postsale run --authorize create_shipment,purchase_labels \
"ship order ord_... cheapest"
# Void only
postsale run --authorize void_shipment "void shipment shp_..."
# Destructive config (separate from money)
postsale run --authorize delete_filter "delete filter ..."parallel batch approval
When parallel_map is about to fan out money work, or when the template text looks money-related, you must approve the batch before any subagent starts. Parent --yes does not count.
- Interactive: answer the batch prompt
- Non-interactive: only with
POSTSALE_SWARM_AUTOAUTH=1in a pre-authorized environment (the CLI emits an audit warning) - Otherwise:
swarm_authorization_required
The keyword scan is intentionally sensitive (ship, label, shipment, purchase, and similar). For read-only batches, use neutral wording. Details: [Parallel batches](../features/parallel batch.md).
Destructive tools do not fan out at all: subagents have no delete or manifest-close tools, and the executor refuses them as a second layer. Destructive work always happens in the parent loop under the normal gate.
Dry-run: high risk simulated, not everything frozen
postsale run --dry-run "Purchase a PDF label for shipment <shipment_id>"Simulated (no carrier charge / no destructive API call): money-moving tools, destructive tools, and order status writes (set_order_status, bulk_set_order_status).
Still real: creating or updating orders, updating shipments, tags, bulk field edits, and filter create/update.
Inspect simulated_effects and confirmed_effects on the envelope. Full detail: Dry-run.
Good to Know
The environment badge
Before any money-moving command runs at an interactive terminal, the CLI prints a one-line environment badge to stderr: a loud red [PRODUCTION]. It appears on both direct commands (labels purchase, labels void, shipments create, manifest close) and postsale run's money-moving and parallel batch-batch prompts.
It is a human cue only — it is suppressed on non-TTY stderr, so agent, pipe, and JSON output in unattended runs is unaffected. It is there so a real charge is never a surprise; if you meant to rehearse, stop and use Test Without Spending. When test-label mode is on (below), the badge carries a [test-label mode] suffix wherever it is printed, including on labels void and manifest close, where the mode changes nothing: the suffix says the switch is set in this shell, not that the command is covered.
Test-label mode
export POSTSALE_TEST_LABEL_MODE=1 makes two promises for every process that inherits the variable, and only two:
- Every shipment body the CLI authors is stamped
test_label: true. Thecreate_shipmenttool,shipments create, andorders create-shipmentadd the flag and disclose it in_cli_meta.applied_defaults. An explicittest_label: falseis refused withtest_label_mode_conflict, never rewritten. Two body shapes are refused because the backend builds them from your shipping rules and a rules-built shipment cannot carry the flag: an empty order-side create (test_label_mode_requires_body, pass carrier, service and packages explicitly) and the{ order, shipment }wrapper onshipments create(test_label_mode_unsupported_body). - A label purchase reads every shipment first and refuses the whole batch unless each is a test shipment (
test_label_required, listing the offending ids; nothing is bought). This read is the guarantee: the backend prices a label from the shipment's flag at purchase time and has no refusal of its own for a real shipment. Onpostsale runthe check happens before the confirmation prompt, so a batch the CLI can already see is not a test batch is refused without asking you to approve it. The one exception is when that pre-prompt read itself fails (a timeout, a server error): then the normal prompt runs and the purchase step re-reads, refuses, or surfaces the read error, but it never buys without the prompt having been shown. The purchase result carries_cli_meta.test_label_verifiedwith the ids that passed.
What it does not do: it does not touch updates (every shipment read returns the flag explicitly, so a round-tripped update body of a real shipment says test_label: false, and forcing it either way would be wrong; a cleared flag is simply refused at purchase). It does not restrict labels void / void_shipment (no spend; the carrier is still called in its test environment). It does not change end-of-day manifests: the backend's end-of-day endpoint already leaves test-label shipments out of a manifest, refuses a manifest made only of them ("Test labels cannot be added to an end-of-day document."), and its eligibility query excludes them, so the mode has nothing to add there. It weakens no gate: money confirmation, --yes, batch authorization and owner-action gates apply exactly as without it, so it is not authorization and does not make an unattended agent safe to leave alone. It is scoped to processes that inherit the variable: another terminal, a cron job, or the web app are not covered. The pre-purchase read is the same call get_shipment makes, including its side effect: an unprocessed US shipment that was never address-validated is validated and the result stored.
Under --dry-run the purchase is still simulated, but the pre-purchase reads still happen live (dry-run never suppresses reads), so the preview shows the refusal a real run would produce; the simulated envelope carries test_label_verified and says in its note that shipments were read.
For a step-by-step rehearsal, including a refusal on purpose, see Test Without Spending. Any non-empty value turns the mode on, including 0 and false — a typo must fail towards a refusal, never towards real postage. Unset the variable to turn it off. postsale doctor reports it under checks.environment.test_label_mode, postsale run prints a one-line notice at start, and confirmed effects in traces carry a response-derived test_label so a trace can answer "was that real postage?" without trusting the CLI's own claim.
Trust-boundary variables
| Variable | Risk |
|---|---|
POSTSALE_CLI_V0_GUARD=off | Disables the operation allowlist (testing only) |
POSTSALE_AUTHORIZED_TOOLS | Pre-authorizes named tools for run |
POSTSALE_SWARM_AUTOAUTH=1 | Bypasses parallel batch prompts non-interactively |
Do not enable these casually on shared machines or untrusted agent hosts.
Gated abort shape
When run stops on a gate, data includes gate (direct, swarm_batch, or swarm_size) and next with remedies that actually apply. Do not invent a bypass that next does not list.
Bulk prompts
Some bulk commands prompt because the scope is large, even when the class is not money-moving.
Additional Reading
- Dry-run
- [Parallel batches](../features/parallel batch.md)
- Shipping, labels, and rates
- Batches and Unattended Runs
- Safety classes reference
On this page
Related