# ADR-117 Courier Dispatch via a Dedicated External-Aggregator Proxy Service

## Status

Proposed, 2026-07-27 (amended 2026-07-28 — real YEMO Shiprocket credentials wired, reads-only).

## Status History

```yaml
status_history:
  - date: 2026-07-27
    status: Proposed
    changed_by: hkl
    reason: Initial proposal — architecture for the DispatchWithAutomation module (MDD_dispatch_automation.md).
    changed_via: adr-kit (360lm) — authored from DL CLI via SSH
  - date: 2026-07-28
    status: Proposed (amended)
    changed_by: hkl
    reason: |
      Real, active, KYC-cleared, funded YEMO Shiprocket credentials provided (root-only
      staging file, deleted immediately after wiring). SHIPROCKET_EMAIL/PASSWORD now set in
      /root/360lm-web/.env -> shiprocket-proxy's environment (docker-compose.yml, unchanged
      variable names/mechanism — MOCK_MODE flips to False purely by their presence, as
      originally designed). Verified live: auth login returns a real token; wallet balance
      (₹4859.30, new /wallet endpoint + sr_wallet_balance()); courier/serviceability returns
      real courier data (not the 3-item mock list); courier/track/awb returns real tracking
      data for a real historical AWB. One real bug found+fixed during this verification:
      sr_track originally read `tracking_data.shipment_status`, assuming it was status text —
      it is actually a NUMERIC code; the real status string lives at
      `tracking_data.shipment_track[0].current_status`. Fixed before this was ever exposed to
      the rules engine. Booking stays OFF regardless of these live creds — see ADR-119's
      2026-07-28 amendment for the kill-switch that enforces this independently of MOCK_MODE.
    changed_via: adr-kit (360lm) — VCC build session, dev-only reads-live rollout
```

## Context

The `dispatch` module (evolving into "DispatchWithAutomation") must call Shiprocket's REST API
(rate/serviceability, order create, AWB assign, tracking, NDR), run courier-portal browser
automation, AI-map messy consignee spreadsheets, and generate Excel + PPTX artifacts. PostgREST
(ADR-014) cannot make outbound HTTP calls, run a browser, or call an AI model. The 360lm house
pattern for exactly this is a **dedicated sidecar proxy** (ADR-059 tour-ai, ADR-108 site-capture,
and the module's own existing `dispatch-ai`). A proven Python toolchain already implements this
end-to-end (the TIMEX campaign, 7 days in production on DL — see `…/shiprocket-timex/HANDOFF.md`),
carrying hard-won logic that is expensive to re-derive: the serviceability-list-vs-assign-AWB
disagreement, silent AWB reversion, multi-format courier date parsing, and the
`"DELIVERED"⊂"UNDELIVERED"` status-substring bug. Shiprocket credentials must never live in the DB
or client (ADR-105/106 posture).

## Decision

Stand up **`shiprocket-proxy`** — a dedicated Python container behind Traefik — as the module's
sole outbound-integration layer. It holds all booking-point credentials (Docker secret / env,
keyed per booking-point — ADR-122), orchestrates every Shiprocket API call, hosts all browser
automation (ADR-118) + AI field-mapping (ADR-062) + Excel/PPTX generation, and **normalizes all
external data before it reaches Postgres**. The PWA never calls Shiprocket directly; it talks only
to `shiprocket-proxy` (fail-closed ADR-105 Bearer-JWT) and reads state via PostgREST. The proxy
writes normalized rows into `dispatch.*` (service role); PostgREST computes everything derived.
Governing rule: **"Python touches the outside world and normalizes it; Postgres never sees a raw
courier string."**

**Decision Maker:** hkl

## Implementation Notes

- **Container:** `shiprocket-proxy` (Python), Traefik route `/shiprocket`, added to `/root/360lm-web/docker-compose.yml` (DOCKER_SHARED + TRAEFIK zones → locked change window at build).
- **Creds:** Docker secret per booking-point (ADR-122); never in `dispatch.*` or client JS.
- **Reuse:** port the TIMEX modules verbatim where possible (booking, tracking, `parse_loose_date`, the UNDELIVERED fix) — do NOT reimplement in PLpgSQL.
- **Write path:** proxy → `dispatch.shipment_events` (append-only, ADR-120) with normalized `status_normalized`/`edd`; PWA reads via PostgREST (`Accept-Profile: dispatch`, ADR-074).
- **Boundary guard:** proxy emits events only; all analytics/derived values are SQL (ADR-120).
- **Find sites:** `grep -rn "shiprocket-proxy" /root/360lm-web/docker-compose.yml`

## Alternatives Considered

- **Reimplement the logic natively in PLpgSQL/PostgREST.** Rejected: PostgREST cannot run HTTP/browser/AI, and porting the empirically-tuned booking/parsing logic re-opens closed bugs (serviceability mismatch, AWB revert, date ranges, UNDELIVERED substring).
- **Call Shiprocket from the PWA's client JavaScript.** Rejected: exposes credentials, hits CORS, and provides no server-side orchestration/retry/normalization.
- **Route through a single shared multi-purpose proxy.** Rejected: credential isolation and failure blast-radius; ADR-059 already establishes dedicated per-domain sidecars.

## Consequences

**Positive:**
- Preserves 7-days-proven logic; credentials isolated server-side; matches the established sidecar pattern; keeps the DB clean (normalized-only).

**Negative / Trade-offs:**
- A new Python service to maintain; the module spans two stacks (Python + PostgREST/JS).

**Risks and mitigations:**
- *Boundary creep* (proxy drifting into analytics) → enforce "proxy writes events, SQL computes derived" (ADR-120).
- *Shared-VPS memory pressure* from browser automation → concurrency caps + TTL reaping (ADR-118).

## Related Decisions

- ADR-059 (dedicated sidecar precedent), ADR-010 (cross-schema via proxy), ADR-014 (PostgREST), ADR-105 (proxy JWT), ADR-106 (grant-gated cross-schema), ADR-108 (isolated service), ADR-062 (AI pipeline). Depended on by ADR-118, 119, 120, 122.

## References

- `/var/www/360lm/docs/MDD_dispatch_automation.md` §5.
- `C:\Users\Lenovo\Documents\shiprocket-timex\HANDOFF.md` (proven TIMEX toolchain + gotchas).
- Fable architecture review, DL session 2026-07-27.
