# ADR-119 Rule-Driven Courier Auto-Selection Mirroring Shiprocket Allocation

## Status

Proposed, 2026-07-27 (amended 2026-07-28 — replay gate re-run on live rates; booking kill-switch
added ahead of any real auto-book go-live).

## Status History

```yaml
status_history:
  - date: 2026-07-27
    status: Proposed
    changed_by: hkl
    reason: Initial proposal — the booking rules engine for DispatchWithAutomation.
    changed_via: adr-kit (360lm) — authored from DL CLI via SSH
  - date: 2026-07-28
    status: Proposed (amended)
    changed_by: hkl
    reason: |
      Real, funded YEMO Shiprocket API credentials landed (KYC-cleared, active account).
      Owner authorized READS-ONLY go-live: rates/track/wallet flip to live Shiprocket API
      calls, booking stays explicitly off. Two things resulted:

      (1) Booking kill-switch added — SHIPROCKET_LIVE_BOOKING_ENABLED (default false,
      independent of MOCK_MODE) is checked inside _shiprocket_book itself, so having live
      creds present can never by itself enable real bookings; a second, explicit flag must
      also be set. Verified live: /book refused with booking_disabled:true even with a
      VALID Bearer JWT and live creds present. This is the mandatory pre-go-live rail this
      ADR's replay-gate paragraph implicitly assumed but never named as a distinct control —
      naming it now closes that gap before any real go-live decision.

      (2) The replay-test harness (mock-mode baseline: 29/68, 43%, documented as a "ceiling
      not a verdict" since mock rates never include DTDC/Xpressbees) was re-run against LIVE
      Shiprocket serviceability for the same 68 historical lanes (replay_test_live.py). Result:
      29/68 (43%) — same headline number, but now a genuine measurement (DTDC/Xpressbees ARE
      real candidates this time; 0 lanes had no live serviceability). This is NOT a
      historical-accuracy score — Shiprocket's serviceable-courier set and rates change over
      time, so it measures "does live rate-shopping + the engine produce sane picks on these
      lanes today", not "would it have rebooked identically". With zero `booking_rules`
      configured (still the case), the engine free-selects by rate/ETD only — the 43% match
      reflects that, not a rules-engine defect. **Gate verdict: NOT cleared for auto-book** —
      real serviceability now measurable, but no policy rules exist yet to encode "match
      historical carrier choice" as a goal, and go-live requires an explicit separate owner
      decision per the kill-switch above regardless of this number.
    changed_via: adr-kit (360lm) — VCC build session, dev-only reads-live rollout
```

## Context

Bulk campaigns book dozens/hundreds of shipments; manual per-shipment courier choice does not
scale. The module books *through* Shiprocket, which already runs its own allocation model
(Courier Recommendation Engine modes Rating/Price/Delivery-perf/Pickup-perf; Courier Rules on 10
conditions incl. payment mode, weight, zone, state, PIN, city; a Courier Priority list). A DL
research sweep (2026-07-27) surveyed Shiprocket + ShipStation/Sendcloud/Cahoot/EasyPost/ClickPost/
Pickrr + OSS (Karrio, Purplship) and concluded: mirror Shiprocket's model (nothing lost by booking
through it) and use Pickrr's rule shape `{Rule Name, conditions, selection mode, blacklist,
priority}`. 360DLM adds policy rules (e.g. "Zone-D out-of-state → Blue Dart", "far zones →
Air-only"). Booking is financially real but **recoverable** (panel reassign or cancel), so the
project owner chose rule-driven auto-book rather than a per-batch human gate — provided the rules
are validated first.

## Decision

Implement a **rule-driven courier auto-selection engine** in `shiprocket-proxy` that mirrors
Shiprocket's allocation model. v1 rule dimensions (all must-have): (1) **Zone A–E** derived from
pickup+delivery PIN; (2) **carrier priority list + restrict/blacklist** per matched condition
(first serviceable wins); (3) **zone→mode→carrier** policy mapping as explicit, editable rules;
(4) **weight-slab**; (5) **serviceability gate** (drop couriers not covering the destination PIN);
(6) **selection-mode tie-break** (fastest / cheapest / best-rated, reusing Shiprocket's own
rate/ETD/rating fields — not recomputed). Booking is **auto**, with panel reassign/cancel as the
recovery net. **Before auto-book is trusted, a replay-test harness is mandatory**: run the rules
against real past campaigns (TIMEX etc.) and confirm they reproduce what was actually booked;
divergences are reviewed before go-live. Rules live in `dispatch.booking_rules` (Pickrr shape),
editable by the Admin role.

**Decision Maker:** hkl

## Implementation Notes

- **Config:** `dispatch.booking_rules` rows `{name, conditions_jsonb, selection_mode, blacklist, priority}`; zone derived via a PIN→zone helper (ADR-004 maps/geocode where needed).
- **Engine:** in `shiprocket-proxy`; consumes Shiprocket serviceability/rate response fields (weight, dims, rate, ETD, rating) as scoring inputs; keeps the TIMEX serviceability-vs-assign + silent-AWB-revert handling (ADR-117).
- **Replay harness:** offline mode replaying archived campaign data → diff report (picked vs actual); a build gate, not a runtime path.
- **Later (not v1):** cheapest-within-SLA; carrier scorecard / performance-by-lane from accumulated `shipment_events`.
- **Booking kill-switch (2026-07-28):** `SHIPROCKET_LIVE_BOOKING_ENABLED` env var, default unset/false; checked inside `_shiprocket_book` before any `sr_book()` call. Independent of `MOCK_MODE` by design — adding real creds and allowing real bookings are two separately-flipped, separately-auditable switches. `docker-compose.yml`'s `shiprocket-proxy` service passes it through as `${SHIPROCKET_LIVE_BOOKING_ENABLED:-false}`.
- **Live replay gate:** `replay_test_live.py` (mirrors `replay_test.py` but calls `sr_get_rates` per lane instead of `mock_rates`) — the script to re-run whenever live creds exist and this ADR's gate needs re-checking. Requires `/opt/timex-dashboard-data.json` present in the container (`docker cp /var/www/timex-dashboard/data.json shiprocket-proxy:/opt/timex-dashboard-data.json`).
- **Find sites:** `grep -rn "booking_rules\|select_courier\|SHIPROCKET_LIVE_BOOKING_ENABLED" /opt/shiprocket-proxy`

## Alternatives Considered

- **Per-batch human confirmation gate (no auto-book) in v1.** Rejected by owner: booking is recoverable (reassign/cancel), and the goal is scale; the replay harness substitutes for the gate as the safety mechanism.
- **Build a bespoke rules DSL from scratch.** Rejected: reinvents Shiprocket's proven condition set; mirror it (+ Pickrr shape) instead.
- **Always pick cheapest (or always fastest) with no rules.** Rejected: ignores policy (carrier-only, zone→mode) and the serviceability reality that the cheapest option is often not bookable on a lane.

## Consequences

**Positive:**
- Scales to bulk; encodes policy explicitly; reuses Shiprocket's own scoring fields; recoverable by design.

**Negative / Trade-offs:**
- Auto-book commits real AWBs without a per-batch human look — mitigated, not eliminated, by recoverability + replay validation.

**Risks and mitigations:**
- *Unproven rules mis-booking at scale* → replay-test harness is a hard pre-go-live gate.
- *Serviceability list ≠ assignable courier / silent AWB revert* → keep the TIMEX retry/verify logic; re-verify AWB presence after each run (ADR-117).

## Related Decisions

- ADR-117 (hosts the engine), ADR-118 (feeds outcomes into tracking), ADR-122 (booking-points the engine selects within), ADR-004 (zone/geocode).

## References

- `/var/www/360lm/docs/MDD_dispatch_automation.md` §8.
- DL courier-rule research report (Shiprocket CORE/Courier Rules, zones A–E, Pickrr shape, scorecards), 2026-07-27.
- `…/shiprocket-timex/HANDOFF.md` (fastest-first + serviceability gotchas).
