Architecture walkthrough · doormile_backend

Doormile Parcel Flow

How a parcel actually moves through Doormile — from a sales rep's GPS-stamped doorstep survey to a consignee reading six digits aloud. And where that pipeline, built for on-demand courier, meets a business run as milk rounds.

01

The parcel's path, end to end

Seven stages, two route branches, one convergence. Every arrow below is a real state transition in the Go backend — the labels are the endpoints that cause them and the statuses they write.

01 · ACQUISITION Field rep on site surveylat / surveylong DoormileClient POST /crm/clients converts Tenant billable account TenantPricing + TenantLocation sites 02 · ORIGINATION Customer App CRM Console Express Console bulk Excel upload PickupBooking status: Pending_Pickup · priced vs TenantPricing Preferredpickupfrom / to the time-window field — unused as a round 03 · DISPATCH Redis GEOSEARCH milers:locations · 10 km candidates Claude Sonnet scoring distance · load · rating 30-day on-time record pgvector RAG memory AgentDecision reasoning logged BookingAssignment Miler_Assigned 5 s timeout → greedy nearest miler 04 · FIRST MILE Accept /assignments/:id/accept Reached doorstep arrivedat + arrival GPS Pickup complete COD collected · Idempotency-Key 05 · CUSTODY HANDOFF Consignment created Collected_By_Miler · tracking number issued 6-digit delivery OTP to the receiver only — stripped from rider & console APIs 06 · ROUTE FORK first 3 pincode digits match? YES · hyperlocal NO · hub & spoke /consignments/:id/start-delivery Out_for_Delivery inbound scan → Inwarded_at_Hub tripsheet loaded → In_Transit destination hub arrive → assign-miler OTP verified → DeliveryProof stored signature · photo · geolocation → Delivered
Figure 1 — the lifecycleThe booking is a promise to collect; the consignment is a parcel in custody. pickup-complete is the pivot between them, and the only place the OTP is minted. The teal branch never touches a hub.
  1. 01Acquisition. A rep registers a business on site; the survey coordinates are the proof they stood there. The lead converts to a Tenant with its own rate card and depot sites.
  2. 02Origination. Three doors, one object. Bookingsource is the only thing that distinguishes them.
  3. 03Dispatch. Nearby available riders from Redis, scored by Claude with reasoning logged, degrading to greedy-nearest after five seconds.
  4. 04First mile. Accept, reach, collect — each an idempotent state transition, because riders work on flaky mobile networks.
  5. 05Custody handoff. Booking becomes Consignment. A six-digit code goes to the receiver and to nobody else.
  6. 06Route fork. Matching 3-digit pincode prefixes skip the hub entirely. Everything else rides a tripsheet.
  7. 07Close. The consignee reads six digits aloud. Nothing else marks a parcel delivered.
02

Where the model and the code disagree

A milk round has five properties, and all five are anti-on-demand: a fixed beat, a rider who owns it, standing orders that recur by default, a promised slot, and density economics. Stage 03 above is built on the opposite premise.

TODAY · ASSIGNED JOB Each parcel scored on its own. The rider set is re-drawn from scratch every single time. P1 P2 P3 P4 GEOSEARCH + scoring R1 R2 R3 R4 No Route, Beat or Round entity exists in doormile_backend — only Tripsheet, which is a hub-to-hub manifest, not a delivery beat. stops per rider: unstable, day to day MILK ROUND · OWNED BEAT One rider, one ordered sequence, unchanged tomorrow. Familiarity is the cost saving. Rider A owns beat 7 1 2 3 4 repeats tomorrow rider_substitutions absent rider → sub rider, per tenant, per date jupiter stops per rider: stable, learned, dense
Figure 2 — the seamThe right-hand pattern only needs a substitution table because a round has an owner. Doormile has no owner and no round; backend_jupiter has both. Two lineages, one repo.
The tell

rider_substitutions — absent rider, substitute rider, per tenant, per date, with a scheduled → active → completed lifecycle. On-demand dispatch has no concept of an absent rider; you simply assign someone else. That table can only exist if a specific person owns a specific round.

Milk-round propertyIn Doormile todayWhere it lives
Promised time slotPresentPreferredpickupfrom / Preferredpickupto on PickupBooking
Recurring account shapePresentCRM captures shipping_frequency, parcel_volume, active_contracts
Depot / dairy originPresentTenant → TenantLocation, per-site reporting on Tenantlocationid
Dense local dropPresent3-digit pincode prefix match skips the hub entirely
Day's order book in one dropPresentPOST /admin/expressbooking/bulk
Round ownershipAbsentonly rider_substitutions, and only in backend_jupiter
Beat with ordered stopsAbsentno Route / Beat / Round model — Tripsheet is linehaul only
Standing ordersAbsentthe only "subscription" in the backend is NATS message subscriptions
Waves as delivery roundsAbsentfrontend display filter in utils/batchBucket.js — see below
03

Why a wave isn't a round

Morning / Afternoon / Evening look like rounds. They aren't. They bucket on orderdate — when the order was placed — so a wave describes arrival, not departure. The dispatch folder's own CLAUDE.md records that the promised-delivery field was tried here and deliberately rejected.

Morning · 0–9 Afternoon · 9–16 Evening · 16–24 12 AM 9 AM 4 PM 12 AM bucket on orderdate current placed 12:22 → Afternoon bucket on expecteddeliverytime jupiter · rejected here promised 16:30 → Evening
Figure 3 — the same parcel, two wavesOnly the lower one describes when the parcel goes out. Bucketing on arrival is correct for a courier queue and wrong for a round — which is why the windows inherited from jupiter had to be widened to cover all 24 hours once the field changed.

There is a second constraint recorded in that file worth carrying forward: the filter field and the bucket field must be the same field. An earlier attempt to admit rows on updatedat while bucketing on createdat put six orders in the Evening batch at 10:41 in the morning on a day with no orders at all.

04

What a native milk round would need

Four additions, roughly in dependency order. None of them fight the existing pipeline — the consignment, OTP and proof-of-delivery machinery downstream of stage 05 stays exactly as it is.

  1. 01A Beat entity — an ordered stop sequence owned by a tenant and a hub, with a slot window. This is the object the whole model is missing.
  2. 02Rider-to-beat assignment that persists — across days, not per parcel. rider_substitutions in jupiter is already the covering mechanism; it just needs a beat to point at.
  3. 03A standing-order generator — materialises tomorrow's bookings from a recurrence rule against a TenantLocation, so the day's round exists before anyone books it.
  4. 04Bucketing moved to the slot field — at which point the original clustered windows become correct again, and a wave finally means a departure.
What the AI layer becomes

Dispatch stops choosing a rider per parcel and starts doing two different jobs: sequencing a beat's stops before it goes out, and handling exceptions — the overflow parcel, the absent rider, the address that doesn't fit today's round. That is a smaller, sharper problem than scoring every candidate on every booking, and it preserves the density that makes the model pay.