Skip to content

Safety Model

The gates between an intent and a charge: confirmations, dry-run, parallel batch authorization, owner actions, and test-label mode.

Updated September 3, 20265 min readPostsale CLI

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:

ClassMeaningNon-interactive requirement
readSafe to call. Most auto-retry on blipsNone
idempotent-writeChanges stateUsually none
destructivePermanent delete or irreversible close--yes
money-movingCarrier 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_shipment
  • purchase_labels
  • void_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:

text
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

bash
postsale labels purchase --shipment-id <shipment_uuid> --yes
postsale labels void --shipment-id <shipment_uuid> --yes
postsale shipments delete shp_1 --yes

Without a TTY and without --yes, expect exit 64 and confirmation_required.

The parent run

MechanismEffect
--yesAll parent money-moving and destructive tools
--authorize purchase_labels,create_shipmentOnly the tools you list
POSTSALE_AUTHORIZED_TOOLSSame idea via environment
Interactive terminalPrompts per gated tool

Least-privilege examples:

bash
# 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=1 in 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

bash
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:

  1. Every shipment body the CLI authors is stamped test_label: true. The create_shipment tool, shipments create, and orders create-shipment add the flag and disclose it in _cli_meta.applied_defaults. An explicit test_label: false is refused with test_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 on shipments create (test_label_mode_unsupported_body).
  2. 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. On postsale run the 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_verified with 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

VariableRisk
POSTSALE_CLI_V0_GUARD=offDisables the operation allowlist (testing only)
POSTSALE_AUTHORIZED_TOOLSPre-authorizes named tools for run
POSTSALE_SWARM_AUTOAUTH=1Bypasses 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