# status-agent

A small, boring program that moves today's Distru sales orders from READY_TO_SHIP to DELIVERING, with the guardrails you would want before letting anything touch orders on its own. From lesson 07-04 of the No Bullshit AI Course.

It is deliberately not clever. The allowlist, the cap and the gate live in one block at the top of `agent.py`, so the person who signs off on it can read the whole policy in a minute.

## Run it

```bash
# 1. Rehearse offline: four fake orders, every decision printed. No key, no network.
python3 agent.py --dry-run --fixture sample-orders.json

# 2. Read live, write nothing.
export DISTRU_API_TOKEN=...          # Distru -> Settings -> Integrations -> Distru API
python3 agent.py --dry-run

# 3. Live. Run it by hand for a week before you put it on a schedule.
python3 agent.py
```

Python 3.8+, standard library only. Optional: `pip install typesafe-sdk` and `STATUS_AGENT_GATE=typesafe` to add a one-question decision-model check before each write (lesson 06-05).

## The guardrails

| guardrail | where | what it does |
|---|---|---|
| allowlist of transitions | `ALLOWED_TRANSITIONS` | only `READY_TO_SHIP -> DELIVERING`. Anything else is skipped and logged. |
| find-before-write | `reread()` | re-reads each order by id right before writing. The list can lag about a second, and a rep may have moved it. |
| idempotent | `status-agent-ledger.jsonl` | an order already written today is skipped, so running it twice writes once. |
| stopping condition | `MAX_WRITES_PER_RUN` | 25 writes, then it stops and says so. Also stops on the first unexpected HTTP error. |
| pre-write gate | `gate()` | field checks (customer set, lines fulfilled); optionally one Noul question. |
| dry run | `--dry-run` | every decision, no POST. |
| log | the ledger | one JSON line per order per run: action, from, to, reason, time. |

## What the dry run printed (fixture)

```text
# status-agent DRY RUN for 2026-09-17
# allowlist: {"READY_TO_SHIP": ["DELIVERING"]}  max writes: 25  gate: deterministic
# GET https://app.distru.com/public/v1/orders?delivery_datetime=2026-09-17T07%3A00%3A00.000000Z%2C2026-09-18T06%3A59%3A59.999999Z&statuses%5B%5D=READY_TO_SHIP
#   Authorization: Bearer <DISTRU_API_TOKEN>
# fixture: 4 orders from sample-orders.json (no request sent)
{"order_id": "…101", "order_number": "SO-1041", "from": "READY_TO_SHIP", "to": "DELIVERING", "action": "wrote", "reason": "fields look complete", "request": "POST https://app.distru.com/public/v1/orders {\"id\": \"…101\", \"status\": \"DELIVERING\"}", ...}
{"order_id": "…102", "order_number": "SO-1042", "from": "PROCESSING", "to": "DELIVERING", "action": "skipped", "reason": "PROCESSING -> DELIVERING not in allowlist", ...}
{"order_id": "…103", "order_number": "SO-1043", "from": "READY_TO_SHIP", "to": "DELIVERING", "action": "skipped", "reason": "no customer on the order; DELIVERING needs one", ...}
{"order_id": "…104", "order_number": "SO-1044", "from": "DELIVERING", "to": "DELIVERING", "action": "skipped", "reason": "DELIVERING -> DELIVERING not in allowlist", ...}
# summary 2026-09-17: wrote=1 skipped=3 failed=0 stopped=0  (dry run: nothing was sent)
```

## Distru facts this relies on (verified 2026-09-17)

- `GET /public/v1/orders` accepts `delivery_datetime=<after>,<before>` (inclusive ISO 8601) and `statuses[]=...`; response is `{"data": [...], "next_page": ...}`.
- `POST /public/v1/orders` with `{"id": "...", "status": "DELIVERING"}` is a sparse update: every field you omit keeps its value.
- Statuses: PENDING, PROCESSING, READY_TO_SHIP, DELIVERING, DELIVERED, COMPLETED, CANCELED. Moving to DELIVERING requires every line fulfilled, a customer set, and a compliance transfer if any item is package-tracked. Distru returns a 400 with a `pointer` when that is not true; the agent logs it as `failed` and continues.
- Only PDF endpoints are rate limited. Writes that sync to Metrc are asynchronous: a 200 means Distru accepted it, not that the state system has it yet.

Set `TIMEZONE_OFFSET_HOURS` to your warehouse. "Today" is your calendar day, converted to UTC for the filter.

## What it will not do

Pick which orders to touch. Retry a 400. Move anything backwards. Run without a human having run it by hand first. If you want mass edits to other statuses, add one line to `ALLOWED_TRANSITIONS`, run `--dry-run`, read the ledger, then decide.

CC0 1.0. Version 1.0.0.
