Primitive spec

Elliot

The logistics primitive. Elliot moves goods from A to B across modes — parcel carriers, your own last-mile fleet, and recurring B2B freight routes — behind one Shipment model and one mode-agnostic carrier-driver contract. Adding a carrier or a mode is a new driver class, not a new system.

Status: Live. All three flavors are wired with real carriers: production-grade parcel drivers (EasyPost, Shippo, direct UPS / FedEx), on-demand couriers (DoorDash Drive, Uber Direct), and a freight LTL/FTL broker driver with a full CarrierTender lifecycle. Signature-verified tracking webhooks ingest carrier status and stream it to Palver, a Hober order.closed auto-creates a draft shipment, and an X12 EDI layer connects freight carriers that run on EDI rather than REST. A delivery can also gate dispatch on payment through the Daneel needs ledger (payment-to-dispatch). Still phase-2: real geocoding / route optimization, ZPL labels, customs forms, and DoorDash/Uber webhook tracking.

What it owns

Elliot owns the movement: the shipment, its packages, the rate quotes, the bought label, and — for owned vehicles — the route stops and their progress. One Shipment aggregate spans all three modes; a single carrier-driver contract (quote / book / cancel) covers a parcel carrier, your own van, and a freight lane alike.

Elliot does not own the order that triggers a shipment (that is Hober), the recipient's identity (Terminus), the vehicle as a bookable resource (Trantor), the shipping charge on the ledger (Mallow), or the tracking notification (Speaker). It carries opaque references to them and stays import-free.

Concepts

Shipment
The universal movement aggregate. Carries a carrierType (EASYPOST, SHIPPO, UPS, FEDEX, DOORDASH_DRIVE, UBER_DIRECT, FREIGHT_BROKER, OWN_FLEET, plus a MOCK test driver), from/to address snapshots, an idempotencyKey (replay-safe quoting), and opaque refs to a Hober order and a Terminus recipient. Status follows the mode: parcel runs DRAFT → RATED → PURCHASED; own-fleet runs ASSIGNED → EN_ROUTE_PICKUP → PICKED_UP → EN_ROUTE_DROPOFF → DELIVERED; webhooks drive IN_TRANSIT / DELIVERED / EXCEPTION.
Parcel, Rate & Label
A Shipment holds one-or-more Parcels (weight + dimensions + declared value). A quote returns immutable Rate lines (carrier, service, amountCents, est. delivery days); buying the chosen rate produces a Label — the permanent record of the purchase with a trackingCode and a printable artifact (PDF / PNG / ZPL).
Carrier-driver seam
Every mode maps to one contract: CarrierDriverInterface with quote / book / cancel. Parcel-only concepts (label URL, format, tracking code) are nullable fields on the result, not method names — so an own-fleet driver simply returns null for them. Drivers auto-register by tag and resolve by carrierType. Eight ship today, each making real calls to its carrier API: EasyPost and Shippo (multi-carrier parcel), direct UPS and FedEx, DoorDash Drive and Uber Direct (on-demand last mile), a generic FreightBroker, plus OwnFleet and a Mock for tests. A new carrier is a new driver class, never a core change.
Carrier configuration
CarrierConfiguration is a tenant's account with an upstream carrier — type, active flag, a priority for default routing, and a settings blob whose credentials are encrypted at the Doctrine boundary. The Shipment denormalizes carrierType so a deleted config never orphans history.
Own-fleet: drivers, stops & zones
For the OWN_FLEET mode, a Driver is an operational projection over a Terminus Person and a Trantor vehicle resource. Booking creates pickup/dropoff Stops sequenced by a RoutePlanner (a naive ManualRoutePlanner today; matrix / 2-opt deferred), and the driver advances each stop. A quote is gated on serviceability: Zone polygons (SERVICE_AREA / SURCHARGE_BAND / EXCLUSION) are evaluated by a pure-PHP ray-cast ZoneResolver — no PostGIS.
Freight: templates, runs, loads & tendering
Recurring B2B distribution. A RouteTemplate carries an RFC 5545 rrule and ordered stops; a generator materializes one RouteRun per occurrence (idempotent on tenant + template + service date). Shipments consolidate onto a Load with advisory capacity tracking across weight, volume, and pallets. For LTL/FTL, a CarrierTender shops every active broker config for a shipment, then awards and tenders one rate under a lock — PENDING → TENDERED → ACCEPTED (the broker's PRO/BOL lands on accept), with REJECTED and CANCELLED branches.
Freight EDI (X12)
Carriers that run on EDI rather than REST map onto the same tender lifecycle through a CarrierType::EDI driver — the driver registry made it additive, no core rework. A pure-PHP X12 codec (ISA/GS/ST…SE/GE/IEA, version 00401) encodes a 204 Load Tender and 997 Functional Ack and decodes inbound 990 (tender response), 214 (status), and 997. Tendering is async: awardAndTender emits a 204 and parks the tender in TENDERED until an inbound 990 resolves it (accept → ACCEPTED + shipment PURCHASED). Inbound documents land at a shared-secret-gated endpoint; the transport seam (EdiTransportInterface) defaults to a durable spool, with AS2 / SFTP a drop-in.
Tracking & live status
Carriers call back to a per-config webhook (POST /elliot/v1/webhooks/{carrierConfigurationId}); the driver verifies the carrier's HMAC signature, parses the events, and a forward-only gate advances the Shipment status. Each change writes an audit event in the same transaction, so it streams to Palver's per-shipment and tenant channels for a live tracking board.
From a Hober order
When a Hober order closes, an outbox subscriber auto-creates a draft shipment — ship-to from the order's delivery address, one parcel per billable line using the Hardin variant's ship weight and dimensions. If the tenant has a default carrier configured and the data is complete, the shipment lands RATED with live rates; otherwise it waits as a DRAFT for an operator to finish. Idempotent on the order id.
Place & geocoding
A Place is a reusable geocoded point (address + lat/lng + geocodeStatus). A GeocoderInterface resolves addresses to coordinates; the default NoOpGeocoder leaves points MANUAL until a real provider (Google / Mapbox / HERE) is wired. Places are the shared geography that own-fleet zones and Seldon field-service both read.

API surface

All endpoints are versioned under /elliot/v1/, return RFC 7807 problem details on error, and read tenantId from the bearer token. State-flipping paths (buy label, dispatch, stop transitions, run advance) take pessimistic locks, mirroring Payments' refund discipline.

Elliot endpoints in the Foundation API reference OpenAPI 3.1 schema for Elliot with request/response shapes, parameters, and a try-it client.

Quick reference

MethodPathPurpose
POST / GET/elliot/v1/shipmentsCreate a shipment and quote carriers (returns rates), or list shipments.
POST/elliot/v1/shipments/{id}/labelsBuy a label against the selected rate (parcel).
POST/elliot/v1/shipments/{id}/dispatchBook an own-fleet movement — creates pickup/dropoff stops, flips to ASSIGNED.
PATCH/elliot/v1/shipments/{id}/stops/{stopId}Advance a stop and roll its status up onto the shipment.
POST / GET/elliot/v1/driversManage own-fleet drivers (status, assigned vehicle).
POST / GET/elliot/v1/carrier-configurationsManage tenant carrier accounts (encrypted credentials).
POST / GET/elliot/v1/zonesManage service-area / surcharge / exclusion polygons.
POST / GET/elliot/v1/route-templatesDefine recurring freight routes (RRULE + ordered stops).
GET / PATCH/elliot/v1/route-runs/{id}Read a materialized route run; advance its status under a lock.
POST/elliot/v1/loads/{id}/shipmentsConsolidate a shipment onto a load; returns an advisory capacity report.
POST/elliot/v1/freight/shipmentsCreate a freight shipment and rate-shop every active broker.
POST / GET/elliot/v1/shipments/{id}/tendersAward + tender a freight rate to its broker, or list a shipment's tenders.
POST/elliot/v1/edi/inboundInbound X12 freight documents (990 / 214 / 997) — shared-secret gated, partner + tenant resolved from the ISA ids.
POST/elliot/v1/webhooks/{carrierConfigurationId}Carrier tracking ingress — HMAC-verified, advances shipment status, streams to Palver.

Example: quote, then buy a label

Create a parcel shipment; the carrier driver returns rate options:

POST /elliot/v1/shipments
Content-Type: application/json
Authorization: Bearer <token>

{
  "carrierType": "MOCK",
  "fromAddress": { "name": "Main Store", "street1": "1 Market St", "city": "Austin", "region": "TX", "postalCode": "78701", "countryCode": "US" },
  "toAddress":   { "name": "A. Customer", "street1": "500 Oak St", "city": "Dallas", "region": "TX", "postalCode": "75201", "countryCode": "US" },
  "parcels": [ { "weightGrams": 1200, "lengthMm": 300, "widthMm": 200, "heightMm": 100 } ]
}
→ 201 Created  shp_01JB…  status: RATED
{
  "rates": [
    { "id": "rt_01JB…", "carrier": "USPS", "service": "Priority", "amountCents": 899,  "estDeliveryDays": 2 },
    { "id": "rt_01JC…", "carrier": "UPS",  "service": "Ground",   "amountCents": 1149, "estDeliveryDays": 3 }
  ]
}

Buy the chosen rate to get a tracking code and a printable label:

POST /elliot/v1/shipments/shp_01JB…/labels
Content-Type: application/json
Authorization: Bearer <token>

{ "rateId": "rt_01JB…", "labelFormat": "PDF" }
→ 201 Created  status: PURCHASED
{
  "label": { "trackingCode": "9400…", "labelUrl": "https://…/label.pdf", "amountCents": 899 }
}

How it fits with the rest

flowchart LR
  Ho[Hober order] -. deferred .-> El(Elliot)
  Ho[Hober order.closed] -- auto-shipment --> El(Elliot)
  El -- quote / book / cancel --> Drv[Carrier driver]
  Drv --> Ext[EasyPost / Shippo / UPS / FedEx]
  Drv --> OnD[DoorDash / Uber Direct]
  Drv --> Fr[Freight broker]
  Drv --> Own[Own-fleet]
  Carrier[Carrier webhooks] -- status --> El
  El -- live status --> Pv[Palver]
  El -. vehicle ref .-> T[Trantor]
  El -. recipient .-> Te[Terminus]
  El -- Place + ZoneResolver --> S[Seldon field-service]
            

Elliot follows the platform's opaque-reference contract in every direction. A Hober order close now auto-creates a draft Shipment; Trantor owns the vehicle as a resource; Terminus owns the recipient; and carrier tracking webhooks stream status to Palver for a live board. Seldon field-service reads Elliot's Place and ZoneResolver to gate appointments to a service area — and, in a later phase, to turn route drive-times into the travel buffer it reserves around a technician's job.