Commands vs detected facts¶
This is the most important conceptual distinction in the Databús run lifecycle. Every lifecycle event is either a command — an intentional act by a human or system actor — or a detected fact — something the platform inferred from evidence. Getting this distinction right matters for how you reason about run state, who is responsible for it, and how you debug unexpected transitions.
Commands¶
A command is an explicit, synchronous request from an operator, dispatcher, or the REST API. It arrives via HTTP and is processed immediately on the Django request thread by RunLifecycleService.
Command events:
| Event | Trigger | Actor |
|---|---|---|
validate_run |
POST /api/create-run/ |
System (automatic on creation) |
initialize_run |
POST /api/create-run/ |
System (automatic on creation) |
run_confirmed_by_operator |
POST /api/runs/<run_id>/update/ with RUN_CONFIRMED |
Operator or dispatcher |
cancel_run |
POST /api/runs/<run_id>/update/ with CANCEL_RUN |
Operator, dispatcher, or system |
run_interrupted |
POST /api/runs/<run_id>/update/ with RUN_INTERRUPTED |
Operator, dispatcher, or system |
run_short_turned |
POST /api/runs/<run_id>/update/ with RUN_SHORT_TURNED |
Dispatcher or system |
See Interfaces → REST API for the full request/response shapes of these endpoints.
Commands are validated by guards synchronously. If a guard fails, the HTTP request returns an error and no state change occurs.
Detected facts¶
A detected fact is an event inferred by the detection layer after observing telemetry. Detected facts are asynchronous — they are produced by detect_from_telemetry or detect_from_scan inside Celery tasks, not on the HTTP thread.
Detected-fact events:
| Event | Detector | Trigger |
|---|---|---|
run_tracking_started |
RunTrackingStartedDetector |
Confirmed run + any valid telemetry ping |
run_started |
RunStartedDetector |
Tracking run + position.speed > 0.5 m/s |
run_tracking_restored |
RunTrackingRestoredDetector |
No Signal run + any valid telemetry ping |
run_completed |
RunCompletedDetector |
In Progress run + server-computed STOPPED_AT at a stop |
run_tracking_lost |
RunTrackingLostDetector |
In Progress + staleness > 60 s (periodic scan) |
run_tracking_expired |
RunTrackingExpiredDetector |
No Signal + staleness > 600 s (periodic scan) |
Detected facts fire via the same run_lifecycle_event Celery task as commands, but with actor_role = "system" in the payload.
The run_completed rename (commit 54e23f3)¶
The event was previously named complete_run — a verb phrase that reads like a command. It was renamed to run_completed — a past-tense fact phrase — because completion is detected, not commanded.
The old name implied that an operator explicitly tells the system "the run is done." The current design detects completion automatically: RunCompletedDetector fires run_completed when the server-computed vehicle_stop_status reports current_status == "STOPPED_AT" at a stop, and the is_at_terminal_stop guard then confirms that stop is the terminal stop of the trip.
Manual completion still uses the same event
If an operator manually marks a run as completed via the REST API, the same run_completed event is fired — but this time sourced from the HTTP thread rather than from the detection layer. The event is a fact in both cases; the mechanism of discovery differs.
Why this matters¶
The distinction shapes where you look when debugging:
- A run that never reached
Trackingdespite the vehicle moving: check the command path — didRUN_CONFIRMED_BY_OPERATORarrive? Did the REST API call succeed? - A run stuck in
Trackingdespite pings arriving: check the detection path — isRunStartedDetectorfiring? Is speed being parsed correctly from the MQTT payload? - A run stuck in
In Progresspast the terminal stop: checkRunCompletedDetector— is the server-computedvehicle_stop_statusactually reportingSTOPPED_AT? Isis_at_terminal_stoppassing?
The run_completed detection path after progression moved server-side (commit ae36cc8)¶
An earlier version computed vehicle_stop_status at the edge and sent it as a progression MQTT leaf. When progression moved server-side (the edge now only sends position and occupancy), RunCompletedDetector stopped firing because the progression leaf was no longer arriving from MQTT.
Commit ae36cc8 restored detection by re-feeding the server-computed vehicle_stop_status back into detect_from_telemetry with leaf="progression" inside the process_position_update Celery task. The full flow is:
MQTT position ping
→ HSET vehicle:<id>:position
→ process_position_update.delay(run_id, vehicle_id)
→ produce_stop_status() # server-side map-matching
→ detect_from_telemetry(run_id, vehicle_id, "progression", computed_stop_status)
→ RunCompletedDetector.detect() if state == "In Progress" and STOPPED_AT
→ run_lifecycle_event.delay("run_completed", {..., "stop_id": ...})
→ RunLifecycleService.process_event()
→ is_at_terminal_stop guard
→ transition to Completed
See detection.md for the detection layer internals and ../data-flow/server-processing.md for the full process_position_update flow.