Skip to content

04 — States and flows ​

How TMS v2 moves its records through their lives: one state diagram and one transition table per record, then five end-to-end sequences. Read this when you need to know "what status can this record be in, and what moves it".

Built or designed. Every state, transition and step is tagged:

  • Built — in the code of bigc-tms-core-v2, branch main-v2-prototype, commit 3d70475a (worktree /Users/thanosorn/Works/Software/tms/wt-v2-prototype-core).
  • Designed — only in the design documents (discovery module designs under /Users/thanosorn/Works/Software/tms/agent-task/.work/tms-v2-discovery/modules/<module>/design.md, and the system-wide design.md beside them). Not in code yet.

In diagrams, built states are green and designed states are grey with a dashed border; transition labels end in (B) or (D).

At a glance ​

recordbuilt in code todaydesignedbiggest gap
Delivery order (DO)6 status names; only PENDING is ever set by code; any other value can be written by admin PUT or by the Kafka payload, unchecked8 BRD v2 names, a guarded transition per edgenames differ, and code has no transition guard
Planpending → planned, stays planned on every re-run or edit; generated exists as a name, nothing sets it7 states incl. optimising, failed, generating, cancelledgenerate is a stub; optimise runs inside the HTTP request, not as a job
Task (trip)entity with task_no only; no status field6 statesnot built
Stopstops exist only inside plan.tasks[].stops[] with a stop type; no stop status5 statesnot built
Duty recordnot built7 statesnot built
COD on a DOis_cod and cod_amount fields onlycod_status UNPAID → PAID → RECEIVED + one handover per tasknot built
Job (jobs engine row)PENDING, RUNNING, DEAD; finished rows are deletedsamesmall: schedule retry count, DEAD rows never expire
Kafka inbound messageclaim row processing / done / failed + dead-letter row pending / resolvedintake_message PARKED / DEAD / REPLAYING / REPLAYED with replaydesign's park-and-replay not built
Attachmentpending → scanning → ready / failedsamenone found

Old planning notes in this folder (task-v2-plan.md, delivery-order-v2-plan.md, July–August) use the older names PENDING / PLANNED / IN_TRANSIT / FAILED / RE_SCHEDULED and a Trip with status PLANNED. The module designs replaced those names with the BRD v2 names below; treat the old notes as history.


1. Delivery order (DO) ​

One DO is one shipment to one customer address. Source: code src/common/enums/delivery_order.enum.ts, src/delivery_order/delivery_order.service.ts; design: system-wide design.md "DO status — the one shared table", intake/design.md "DO states as intake sees them", execution/design.md "State machines".

1a. As built ​

The enum has PENDING, PLANNED, IN_TRANSIT, DELIVERED, FAILED, CANCELLED, but the schema stores status as a free string. Code sets only PENDING (on create when no status is given). The planner reads PENDING DOs as candidates and never changes DO status.

1b. As designed ​

A redelivery after a failed attempt is the same DO back in UNASSIGNED with attempt_count > 0. An OMS redeliver of a CANCELLED or FAILED_DELIVERY order is a new DO (next attempt_no). An ops hold is a record on the DO, not a status; the DO status HOLD means "the driver recorded a failed attempt".

1c. Code name vs design name ​

code name (built)nearest design namenote
PENDINGUNASSIGNED (no approved_at, or ready to plan)design also folds v1 RE_SCHEDULED into UNASSIGNED with attempt_count > 0
PLANNEDASSIGNEDdesign: on a route not yet departed, with or without a task
—DISPATCHEDno code name
IN_TRANSITOUT_FOR_DELIVERY
—HOLDno code name
DELIVEREDDELIVERED
FAILEDFAILED_DELIVERY
CANCELLEDCANCELLED

1d. Transition table ​

from → totriggerwhoguardstatuscode path or design section
— → PENDINGKafka bigc.lastmiles.do-post message, no DO with that delivery_nosystem (Kafka consumer)delivery_no and message_id present; one transaction writes DO + activity row + outbound jobBuiltdelivery_order.service.ts upsertFromKafka → createDeliveryOrder
— → PENDINGPOST /v1/delivery_orderadminnone beyond DTO validation; delivery_no generated DO + 8 digits if missingBuiltdelivery_order.controller.ts, createDeliveryOrder
any → anyPUT /v1/delivery_order/:delivery_no with statusadminnone — any string is writtenBuiltupdateByDeliveryNo
any → anyKafka message for an existing delivery_no carrying statussystemnone on status; a replay of the same message_id is a no-op (unique activity event_key)BuiltupsertFromKafka → updateByDeliveryNo
— → UNASSIGNEDOMS message, admin form, importintakerouting table rows 1–3 (dedupe, park behind earlier message, create)Designedintake design.md "How a message is routed"
UNASSIGNED → CANCELLEDOMS cancel or admin cancelintake / opsOMS cancel refused once attempt_count > 0; admin cancel needs a reason thenDesignedintake "DO states as intake sees them"
UNASSIGNED → ASSIGNEDgenerate of the chosen plan variant, add-to-routeplanningconditional claim: order still has no plan id and is still eligibleDesignedplanning "How an order is claimed and released"
ASSIGNED → UNASSIGNEDorder removed from plan, plan cancelled, ops hold, end of plan dateplanningreleased in the same write that removes it from its routeDesignedplanning "How an order is claimed and released"
ASSIGNED → DISPATCHEDdepart (driver, staff PIN)executionone transaction across task, DOs, DO items; at least one unit loadedDesignedexecution "State machines", row on depart
ASSIGNED → ASSIGNED (off task)depart with nothing loaded, or task cancelledexecutionstays on its route, no attempt countedDesignedexecution "State machines"
DISPATCHED → OUT_FOR_DELIVERYdriver opens first stopexecutionevery DO of the task without an open ops hold moves at onceDesignedexecution "State machines"
OUT_FOR_DELIVERY → DELIVEREDconfirm delivereddriverstop is ARRIVED; idempotency key; COD written in the same transactionDesignedexecution "State machines"; cod-payment "Lifecycle"
OUT_FOR_DELIVERY → HOLDreport failed attemptdriverreason from reason master; attempt_count + 1Designedexecution "Failed attempts, holds and reschedule"
HOLD → OUT_FOR_DELIVERYretry while still at that stopdrivercount unchangedDesignedsame
DISPATCHED / OUT_FOR_DELIVERY / HOLD → UNASSIGNEDclose-out "redeliver"store staff (unload PIN) or opsnot on-demand; counted attempts below max_delivery_attempts (default 3); goods scanned backDesignedexecution "Failed attempts…"; close-out row
DISPATCHED / OUT_FOR_DELIVERY / HOLD → FAILED_DELIVERYclose-out "fail"store staff or opsreason required; forced when goods not returnedDesignedsame
ASSIGNED → CANCELLEDupstream cancel before the task leftintake / opstask not departedDesignedexecution "State machines"
DISPATCHED / OUT_FOR_DELIVERY / HOLD → CANCELLEDupstream cancel after the task leftexecution at close-outstays on the task until close-outDesignedexecution "State machines"
CANCELLED / FAILED_DELIVERY → new DOOMS sends a newer message for the orderintakenewest attempt is finalDesignedintake routing row 8
DELIVERED → (no change)any OMS messageintakerecorded as DELIVERED_SKIPPEDDesignedintake routing row 9

Status corrections (for example "revert delivered") are designed as named reverse edges, each with a reason and an audit row; a free status set from admin is rejected by design. Code today has no such edges and no guard.


2. Plan ​

One plan is one routing variant for one store and one date. A store and date may have several variants; only one may be generated. Source: code src/common/enums/plan.enum.ts, src/plan/plan.service.ts, plan_optimize.service.ts, plan_operation.service.ts; design: planning design.md "Plan states".

generated is green because the name exists in code and blocks re-plan, but no code path sets it.

from → totriggerwhoguardstatuscode path or design section
— → pendingPOST /v1/planadmin, store operatorstore and plan template exist; every template vehicle type existsBuiltplan.service.ts createPlan
pending → plannedPOST /v1/plan/:code/re_plan (Google Route Optimization runs inside the request)admin, store operatorplan not generated; has shifts; save only if optimize.revision unchanged, else 409Builtplan_optimize.service.ts rePlan → _execute → _save
planned → plannedre_plan again (reset: pins and hand removals dropped)admin, store operatorsame as aboveBuiltsame
planned → plannedmove_stop, assign_stop, unassign_stop (re-optimise only the touched routes)admin, store operatorplan must be planned; revision check as aboveBuiltplan_operation.service.ts → recalculate
planned → planned (no change)Google call fails—failure written to plan_history and activity log; plan untouchedBuilt_execute catch → recordFailure
planned → generatedPOST /v1/plan/:code/generate_taskadmin, store operator—Not built: returns "Task generated successfully" and changes nothingplan.service.ts generateTask (TODO)
pending / planned / failed → optimisingOptimise pressed; background job holds a leaseops adminstate and revision still matchDesignedplanning "Plan states"; "Prebuild a store's day" v2 steps 1–2
optimising → planned / failedjob finishes / errors or lease lapsessystemearlier routes kept on failureDesignedplanning "Plan states"
planned → generating → generatedGenerate pressed; job turns each open route into one taskops adminchosen-variant flag under a unique index on store + date; per-route order claimDesignedplanning "How an order is claimed and released"
generating → plannederror or lease lapsessystemroutes that got a task keep it; a retry resumesDesignedplanning "Plan states"
generated → plannedmanual route added, or execution cancels a route's taskops admin / execution—Designedplanning "Plan states"
pending / planned / failed → cancelledcancelops adminno route has a taskDesignedplanning "Plan states"

Every built plan save writes a plan_history row and an activity_logs row in the same transaction (actions RE_PLAN, RECALCULATE, MOVE_STOP, ASSIGN_STOP, UNASSIGN_STOP; CREATE and RESET are declared but never written).


3. Task (one driver's trip) ​

Design only. In code, src/task/task.schema.ts holds just task_no (TK + 8 digits) and TaskService.createTask has no caller. Design: execution design.md "State machines".

from → totriggerwhoguardstatusdesign section
— → PENDINGgenerate of a plan routeplanning jobone task per route, in the route's transactionDesignedplanning "Prebuild a store's day" step 7
PENDING → ACCEPTEDacceptdrivercompare-and-swap on status; two concurrent accepts → exactly one winsDesignedexecution lifecycle, accept row
ACCEPTED → DISPATCHEDdepart (staff PIN + photo)driverstore check-in within store radius (default 300 m); one transaction for task, DOs, DO items, outboxDesignedexecution lifecycle, depart row
DISPATCHED → IN_TRANSITopen first stopdriver—Designedexecution "State machines"
IN_TRANSIT / DISPATCHED → COMPLETEDclose-outstore staff (unload PIN), or ops for an overdue taskevery undelivered DO decided; every stop finalDesignedexecution close-out row; overdue-list row
PENDING / ACCEPTED → CANCELLEDops cancelops adminnot left the store; route task link cleared, plan back to plannedDesignedexecution "State machines"
PENDING / ACCEPTED → CANCELLEDscheduled job, 24 h after task date (task_start_grace_hours)systemstill not started; DOs stay ASSIGNED on their routeDesigned (needs customer confirm)execution never-started row

A task is "held" while its driver's duty record is HELD; that is not a task status. A task that left the store cannot be cancelled; it ends only through close-out.


4. Stop (one customer address in a task) ​

Design only. In code a stop exists only inside a plan route (plan.tasks[].stops[]) with a type DEPARTURE, DELIVERY or RETURN and a pin flag; it has no status. Design: execution design.md "State machines".

from → totriggerwhoguardstatusdesign section
PENDING → DELIVERINGopen_stopdrivertask DISPATCHED or IN_TRANSITDesignedexecution lifecycle, open-stop row
PENDING / DELIVERING → ARRIVEDarrivedriverserver stores position and distance; beyond 2,000 m only flags out_of_locationDesignedexecution arrival row
DELIVERING → PENDINGdriver opens a different stopdriver—Designedexecution "State machines"
ARRIVED → DEPARTEDnext stop opened, return check-in, or close-outdriver / close-outevery DO at the stop is settled (DELIVERED, HOLD, CANCELLED, or held/cancelled upstream)Designedexecution "State machines"
PENDING / DELIVERING → CANCELLEDstop moved or emptied, task cancelled, or not reached by close-outops / close-out—Designedexecution "State machines"

Each DO at a stop also gets a visit record stop_do (one per task × DO, one open visit per DO by unique index); its close command (redeliver, fail, reassign, …) stamps the DO values used. Design only.


5. Duty record (one driver, one date) ​

Design only; nothing in code. Shared by planning (time windows) and workforce (state). Design: workforce design.md "Duty states"; system-wide design.md "Shared duty record".

from → totriggerwhoguardstatusdesign section
— → SCHEDULEDwindow attached (plan template slot or confirmed booking)systemdriver ACTIVE, not on approved leave; windows may not overlapDesignedworkforce "Duty states"
SCHEDULED → ON_DUTYclock-indriverphoto + position; on-time mark setDesignedsame
ON_DUTY ⇄ ON_BREAKstart / end breakdriverstart only with no task in progressDesignedsame
ON_DUTY / ON_BREAK → HELDholdops admin, or on-demand acceptance timeoutthe driver's not-started tasks of the date are unassignedDesignedsame
HELD → ON_DUTYunholdops adminwindows untouchedDesignedsame
ON_DUTY / HELD → OFF_DUTYclock-outdriverphoto + position; no task in progressDesignedsame
ON_DUTY → OFF_DUTYbooking cancelled, no other window that dayops adminno task in progress; ended_by_cancelDesignedsame
SCHEDULED → CANCELLEDbooking cancelled or window detached, no other windowops admin / planningrecord kept for historyDesignedsame
ON_DUTY / ON_BREAK / HELD → OFF_DUTYday close jobsystem2 h after last window end; auto_closedDesignedsame
SCHEDULED → ABSENTday close jobsystemnever clocked inDesignedsame

Booking states (designed): INVALID, CONFIRMED, CANCELLED, CLOSED (by day close). Leave: PENDING → APPROVED / REJECTED / CANCELLED. Every write is one conditional update (… where state = <from>), so a repeated request has one effect.


6. COD on an order, and the cash handover ​

Design only. Code has is_cod and cod_amount on the DO and passes them through to outbound Kafka; no COD status, no handover. Design: cod-payment design.md "Lifecycle of COD on one order", "Handing cash to the store".

from → totriggerwhoguardstatusdesign section
— → UNPAIDDO created from OMS with CODintakeamount due > 0; later OMS updates may change cod_amount only while UNPAIDDesignedcod-payment "Lifecycle"
UNPAID → PAIDconfirm delivereddriverCOD photo uploaded; same idempotency key returns first result; written in the DELIVERED transaction; server copies the amount dueDesignedcod-payment "Collecting cash at the stop"
PAID → RECEIVEDhandover: one cod_handover per taskstore cashier (PIN on driver's phone)task closed out; no handover exists for the task; PIN belongs to the task's store; received between 0 and expected; a shortfall needs a reasonDesignedcod-payment "Handing cash to the store"
RECEIVED → —none; corrections are adjustment rows on the handoverstore supervisornet received stays between 0 and expectedDesignedsame

The handover record has no status of its own. Per task the store sees a derived state: open cash (a PAID order, no handover), settled (variance 0), settled short (variance < 0). An optional store setting cod.block_dispatch_unsettled stops new assignments to a driver with open cash.


7. Job (jobs engine row) and schedule slot ​

Built. One jobs row is one piece of work that must not be lost: an outbound Kafka message, or one slot of a schedule. Source: src/jobs/jobs.constants.ts, fast_lane.worker.ts, long_lane.worker.ts, jobs.controller.ts, src/schedules/schedules.service.ts. Design: platform design.md rows on schedules and the jobs engine; tms-v2-jobs-engine-code-walkthrough.md.

from → totriggerwhoguardstatuscode path
— → PENDINGsideEffect.enqueue in the caller's transactionbusiness code, schedule tickunique dedupe_key: a repeat enqueue inserts nothingBuiltside_effect.service.ts enqueueMany
PENDING → deletedhandler succeededfast lane (pod owning the partition)write guarded on the row still PENDINGBuiltfast_lane.worker.ts JobOutcome._ok
PENDING → PENDING (retry)handler threw an answered errorworkerattempts < max (default 6 runs = 1 + 5 retries); a retrying row holds later versions of its key on the same targetBuiltJobOutcome._answered, dispatchOnce
PENDING → PENDING (unreachable)TargetUnreachable (broker down, refused)workerattempts not counted; target paused 30 s, then one probeBuiltJobOutcome._unreachable, target_pause.ts
PENDING → DEADmax attempts, or invalid record (dead_now), or outcome unknown on an at-most-once target, or partition taken over while the row was in flightworker / heartbeatat-most-once is the default for every topicBuiltJobOutcome._answered, FastLaneWorker._closeTakenOver
PENDING → RUNNINGlong-lane reserve (schedules)long lanebatch of ≤ 10; exclusive targets: one RUNNING row per code across pods (unique partial index on running_code)Builtlong_lane.worker.ts reserve
RUNNING → PENDINGanswered failure with retries left, or lease (5 min, heartbeat 1 min) expired and another pod reclaimslong laneclaim token guards every writeBuiltlong_lane.worker.ts
DEAD → PENDINGPOST /v1/jobs/:id/retryadminmust be DEAD, else 409Builtjobs.controller.ts retry
DEAD → deletedPOST /v1/jobs/:id/skipadminmust be DEAD, else 409Builtjobs.controller.ts skip
DEAD → deleted after 30 daysTTLsystem—Designed (assumed, not built)jobs retention decision; no TTL index in jobs.schema.ts

A DEAD row does not hold later rows of its key. Schedule slots run on the long lane: the tick (every 15 s) enqueues one row per due slot with dedupe_key <code>:<slot>, so two pods ticking at once still make one row.


8. Supporting lifecycles ​

8a. Inbound Kafka message ​

statemeaningstatuscode or design
kafka_consumed.processinga pod claimed this message_id (or topic:partition:offset)Builtkafka.consumer.ts _claim
kafka_consumed.donehandler succeeded; a redelivery is skippedBuiltconsumeBatch
kafka_consumed.failedhandler returned a permanent reason; the message is copied to kafka_consume_dlq with status pending; a later delivery of the same id may claim it againBuiltconsumeBatch
kafka_consume_dlq.resolveda later delivery of the same id succeededBuiltconsumeBatch
processing older than 5 mintreated as a crashed claim, taken againBuiltCONSUME_LEASE_MS
intake_message PARKED → REPLAYING → REPLAYED, or DEADa message that could not be applied (missing store or channel, waiting behind an earlier message) is parked and replayed in order when the cause is fixedDesignedintake "How a message is routed", "Data model"

Code has no replay: a dead-lettered message is applied again only if Kafka delivers the same message_id again.

8b. Attachment ​

Code src/attachment/attachment.service.ts, attachment.sweep.processor.ts; matches platform design.md "Storage and attachments". The malware scanner is a stub that always answers clean.


9. End-to-end sequences ​

9.1 Order arrives from OMS → DO (Built, designed additions marked) ​

9.2 Plan → optimise → generate tasks (optimise Built in-request; generate Designed) ​

9.3 Driver run → delivered or failed → HOLD → close-out (Designed) ​

9.4 COD collection and cash handover (Designed) ​

9.5 DO status change → OMS and staffapp hear about it (Built) ​


10. Where code and design disagree ​

#recordcode (Built)designeffect
1DOnames PENDING, PLANNED, IN_TRANSIT, DELIVERED, FAILED, CANCELLEDBRD v2 names UNASSIGNED, ASSIGNED, DISPATCHED, OUT_FOR_DELIVERY, HOLD, DELIVERED, FAILED_DELIVERY, CANCELLEDthe enum must be renamed before execution is built; DISPATCHED and HOLD have no code name
2DOstatus is a free string; admin PUT and the Kafka payload write any valueevery edge guarded (… where status = expected); free status set rejected; corrections only as named reverse edgesany status jump is possible today
3DOa Kafka message for a known delivery_no always updates it in place, including CANCELLED or DELIVERED DOsrouting table: stale skip, cancel refused after a field attempt, redeliver of a final DO as a new DO, DELIVERED_SKIPPEDv1 overwrite behaviour kept in code
4DOplan optimise reads PENDING DOs and never changes DO status or claims themclaim (plan id, ASSIGNED) at generate onlyconsistent with design for optimise; the claim itself is not built
5Planoptimise runs inside the HTTP request (Google timeout 120 s); states pending, planned, generatedoptimise and generate are background jobs; adds optimising, failed, generating, cancelleda slow Google call holds the request; no failed state (failure leaves the plan as it was)
6Plangenerate_task is a stub; nothing ever sets generatedgenerate creates tasks, claims DOs, plan → generatedplans can be re-planned forever
7Planwrites both a plan_history row and an activity_logs row per save; CREATE / RESET actions declared, never writtenthe designs differ: planning keeps plan_history; platform moves plan rows into activity_logs onlytwo history stores for plans until this is settled
8Task / stoptask has only task_no; stop has no status6 task and 5 stop statesnot built
9Duty, CODnot builtfull state machinesnot built
10Kafka inbounddedupe claim + dead-letter row, no replayintake_message PARKED / DEAD / REPLAYING / REPLAYED with ordered replaya failed order message waits for Kafka to redeliver it
11Outbound emitemits do_update_oms / do_update_staffapp on every create and on every update that changes status, routed by channel code (OMS: DONJAI, CHOKCHAI, HLH; staffapp: SPO, OD, HLH)emit only where v1 emits, mapped per v2 edge from PROD kafka_logsthe v1-measured emit list is not yet applied
12Jobschedule retry_count 3 is used as the max number of runs (3 runs)"a failed run retries up to 3 times" (4 runs)one fewer retry than designed
13JobDEAD rows never expireTTL 30 days after closed_at (assumed decision)DEAD rows with full payload grow without bound