# Safety contract

> Modes, tenant rules, refusals, confirm hashes, typed tokens, production rules and readback, as metro enforces them.

Source: https://metrocli.com/docs/agents/safety-contract/ · Docs index: https://metrocli.com/llms.txt

metro can change invoices, credits, contracts and customer access. This page is the contract between metro, your agent and you. Most of it is enforced in code. The rest is behavior every agent must follow.

## The rules

1. **Read-only by default.** After `metro init`, metro can look, preview and validate. Writes need `metro mode write`, and only the human decides to raise it.
2. **Sandbox first.** A change is proven on sandbox before production is discussed.
3. **Always name the tenant.** Pass `--sandbox` or `--prod`. metro never picks production on its own.
4. **Preview, confirm, apply, read back.** Every write follows the same ladder.
5. **Production needs the human's explicit typed yes in chat for that specific change.** A yes for one change never covers the next. A green preview is not authorization, and neither is a key being present.
6. **Never print keys.** Don't `cat` or echo `~/.metro/.env`. metro shows fingerprints like `sha256:80f0714120b8`, never values.

## Modes

The mode is a ceiling, saved in `~/.metro/mode.json`.

| Mode | Live reads | Live writes | Set with |
|---|---|---|---|
| `local-only` | blocked | blocked | `metro mode set local-only` |
| `read-only` (default) | allowed | blocked | `metro mode set read-only` or `metro mode reset` |
| `read-write` | allowed | allowed, with confirmation tokens | `metro mode write` or `metro mode set read-write` |

`metro mode` shows the current mode. A dry run never needs write mode. An `--apply` in read-only mode is refused with `MTR-MODE-WRITE-BLOCKED` and the next command to run.

## The ladder for every write

**Preflight.** Reads only. Writes a plan file with the exact request bodies and prints a `payloadHash`: the `sha256:` of the canonical plan, which covers the tenant and the key fingerprint.

```bash
metro contract expire preflight --ingest-alias=acme-corp --cutover-mode=next-hour --sandbox --out=expire.json
```

**Review.** The agent shows you the plain-English change and the hash, then waits for your yes.

**Dry run.** `apply` with `--confirm-hash` and without `--apply` prints the requests and writes nothing.

```bash
metro contract expire apply --file expire.json --sandbox --confirm-hash sha256:...
```

**Apply.** Add `--apply` and the typed token for the tenant.

```bash
metro contract expire apply --file expire.json --sandbox --confirm-hash sha256:... \
  --apply --confirm-live-apply=I_UNDERSTAND_THIS_WILL_WRITE_LIVE_METRONOME_SANDBOX
```

**Read back.** metro reads each object back and prints expected vs actual. Any mismatch exits `3`. Every apply writes a log to `~/.metro/logs/<op>/`, or to `METRO_LOG_DIR`.

## Typed tokens

| Where | Flag |
|---|---|
| Sandbox write | `--confirm-live-apply=I_UNDERSTAND_THIS_WILL_WRITE_LIVE_METRONOME_SANDBOX` |
| Production write | `--prod --confirm-prod-apply=I_UNDERSTAND_BILLING_SIDE_EFFECTS` |
| Guarded read (`metro api read`) | `--confirm-live-read I_UNDERSTAND_THIS_WILL_READ_LIVE_METRONOME` |
| Guarded sandbox write (`metro api apply`) | `--confirm-live-apply I_UNDERSTAND_THIS_WILL_WRITE_LIVE_METRONOME_SANDBOX`, plus an apply gate and an approval record |

The guarded `metro api apply` chain is sandbox-only. Production apply through it is blocked.

## Refusals

A refusal exits `2`. Stop, quote it to the human, and don't work around it.

| Code | When |
|---|---|
| `MTR-TENANT-001` | No `--sandbox` or `--prod`. metro never infers the tenant. |
| `MTR-TENANT-003` | Both flags passed, or apply's tenant differs from the plan's. A sandbox plan can never be applied to production. |
| `MTR-TENANT-004` | The key fingerprint changed between preflight and apply. |
| `MTR-TENANT-KEY-001` | The tenant's key isn't set. |
| `MTR-TENANT-KEY-002` | `--prod`, and the production key equals the sandbox key. |
| `MTR-TENANT-KEY-003` | `--sandbox`, and the sandbox key equals the production key. |
| `MTR-PLAN-001` | The file isn't a plan for this command. |
| `MTR-PLAN-002` | The plan was edited after preflight. |
| `MTR-HASH-001`, `MTR-HASH-002` | `--confirm-hash` is missing, or doesn't match the plan. |
| `MTR-MODE-WRITE-BLOCKED` | `--apply` while the mode is `read-only` or `local-only`. |
| `MTR-LIVE-APPLY-001` | A sandbox apply without the sandbox token. |
| `MTR-PROD-APPLY-BLOCKED` | A production apply without the production token. |

Two non-refusal stops matter too. `MTR-PRECHECK` (exit `1`) means live state changed since preflight; run preflight again. `MTR-APPLY-STEP-FAILED` (exit `1`) names the step that failed and the steps that completed before it.

## Production rules

Before any production write, write down:

1. The customer's identifiers: Metronome customer ID, contract ID, billing-provider customer ID, and any external ID you matched on.
2. The tenant, and which key metro will use.
3. The current state, from read-only evidence.
4. The desired state, in business terms.
5. The exact endpoints and payload shapes.
6. The expected invoices, balance changes, billing-provider behavior and access effects.
7. The dry-run result, and where its file was written.
8. A canary: one customer or one object first.
9. The readback you'll run.
10. Rollback or remediation.

## Readback

The samples below are illustrative, in the exact format metro prints. Readback is the proof. A `200` from Metronome is not: some contract edits return `200` and silently don't apply.

```text
Applied (sandbox).
Read-back (expected vs actual):
  ok   contract.id: expected "00000000-0000-4000-8000-000000000002" actual "00000000-0000-4000-8000-000000000002"
  ok   contract.ending_before: expected "2026-12-01T19:00:00.000Z" actual "2026-12-01T19:00:00.000Z"
Log: ~/.metro/logs/contract-expire/apply-2026-11-03T19-21-34-560Z.json
```

When it fails, metro says so and exits `3`:

```text
Applied, but read-back FAILED (sandbox).
Read-back (expected vs actual):
  FAIL quantity at 2027-02-01T00:00:00.000Z: expected 15 actual 12
```

Don't re-run the same plan. Use the log and a fresh read, for example `metro ops who <customer-id> --sandbox`, to find where expected and actual diverge. Fix the cause, then preflight again.

## Retries

Writes are retried only on HTTP 429. A timed-out create is never sent twice. Every write metro builds carries a `uniqueness_key` where Metronome accepts one, so a repeat returns `409` instead of a duplicate. Customer and rate-card creates have no uniqueness key, so metro uses the ingest aliases and the card name as the anchor and checks them again just before writing.
