BatteryFlow / Mobitra platform
Trip and Route Management
Everything the feature is: the API surface, how the pieces are wired,
what happens per GPS packet, how it gets deployed, and what a reviewer should
push on. Current as of 27 August 2026, written against the code on branch
UAT in route-trip-mgmt and route-trip-execution.
The one-minute version
A route is a template. Someone draws a corridor on a map and hangs checkpoints, no-go zones and stops off it. Approving the route freezes a route version: a snapshot plus geometry that never changes again.
A trip is one vehicle running one frozen version of that template. While the trip runs, every telemetry packet for that vehicle is measured against the trip's geometry, and that produces alerts: left the corridor, entered a restricted zone, missed a checkpoint, arrived at a stop, running late.
Two shapes, deliberately kept apart:
- Discrete zones (stops, checkpoints, no-go) are closed shapes with enter and exit edges. Point in polygon, debounced.
- The corridor is a polyline with a width. Continuous distance to line on every packet. It is not a geofence and it never becomes one.
That split decides where code lives. Corridor, ETA, delay and
CHECKPOINT_MISSED derivation live in trip execution. Zone membership
is shared maths held in step by a golden corpus.
Two containers. route-trip-mgmt serves HTTP on 8100 and owns the
database schema. route-trip-execution is a pure Kafka consumer with
no HTTP surface at all. They share Postgres and Redis, they never call each
other, and one Redis key is the pivot between them.
How to review this
If you have an hour, this is the order that gets you to the risky parts fastest.
- Read What the platform does and Wiring. Ten minutes. The rest only makes sense once the route-template-versus-trip distinction and the one-writer Redis key are in your head.
- Go straight to Things that look wrong and are not.
Four decisions in there look like sloppiness and are load-bearing: the duplicated
spatial primitive, the pinned quirk corpus, the negated stop-notification
predicate, and the inert
TRIP_CODE_ROOT. If you are going to disagree with something, disagree with these first, because the rest of the design leans on them. - Read the lifecycle with the route and dispatch endpoints open beside it. The guardrails are all state machine rules: PATCH cannot approve, structural edits demote to DRAFT, dispatch never mints a version, one live trip per vehicle. Each one is a real bug that was closed.
- Then per-packet evaluation. This is where correctness actually lives. Pay attention to the write-once snapshot, the dedup and cooldown model, and the debounce counters.
- Finish on the defect ledger and what this pass found. Six items are still open and named, including one that has no owner yet.
- The geofencing stream and trip execution both decide zone membership and they disagree six ways. Merging them was deliberately not done. Is that the right call for this release? See the pinned decisions.
- A dropped
TRIP_CANCELLEDleaves a zombie trip alerting forever, because nothing re-reads trip status per packet. Pre-existing, still open. NO_GO_EXITis published three times and delivered zero times. The fix lives in a repo we cannot push to.
What the platform does
The objects
| Object | What it is | Table |
|---|---|---|
| Operational stop | A reusable place in a catalogue: a depot, a customer site, a swap station. Has coordinates, optionally a geofence. | operational_stop |
| Route | The template. Corridor geometry, a width in metres, ordered stops, checkpoints, no-go zones, free-form restrictions. | route, route_stop, route_zone |
| Route version | A frozen publish of a route: the full snapshot plus geometry, stamped with who approved it and when. Immutable. | route_version |
| Trip | One vehicle, one driver, one frozen route version, one run. Carries the checklist and the lifecycle status. | trip |
| Trip zone | The trip's own copy of each stop and checkpoint, with runtime progress: planned versus actual arrival, hit time, status. | trip_zone |
| Alert | Something that happened and needs attention. Deduped while open, re-notified on a cooldown, optionally promoted to a case. | alert |
Trip statuses
The status column is the whole state machine. Nothing else gates the lifecycle.
| Status | Set by | Means |
|---|---|---|
DRAFT | create | Created, not scheduled. Assignable. |
PLANNED | create (default) | Ready to be assigned. |
SCHEDULED | create | Has a scheduled start. Assignable. |
ASSIGNED | POST /trips/:id/assign | Vehicle and driver attached. The only status dispatch accepts. |
DISPATCHED | POST /trips/:id/dispatch | Monitoring is armed. Redis hot state exists. Execution owns the trip from here. |
EN_ROUTE | trip-execution, first packet | The vehicle has actually moved. |
COMPLETED | trip-execution | Reached the end. Hot state deleted. Terminal. |
CANCELLED | POST /trips/:id/cancel | Stopped by a human at any non-terminal status. Terminal. |
Once a trip is DISPATCHED or later it is
execution owned: the API refuses to let a human write
trip_zone.status and answers 409 EXECUTION_OWNED. Only
the evaluator moves those rows.
Route statuses
| Status | Means |
|---|---|
DRAFT | Editable. Cannot carry a new trip. |
APPROVED | Has a frozen current_version_id. Trips can be created against it. Still editable, but any structural edit demotes it back to DRAFT. |
ARCHIVED | Read only. Every write returns 409 ROUTE_ARCHIVED. |
Services and repos
| Repo | Owns | Runtime | Push access | State |
|---|---|---|---|---|
mobitra/route-trip-mgmt |
Routes, versions, trips, dispatch, the schema | Express, HTTP :8100 |
Developer | pushed branch UAT |
mobitra/route-trip-execution |
Per-packet evaluation, alert rows | Kafka consumer, no HTTP | Developer | pushed branch UAT |
mobitra/geofencing |
Geofence stream, zone alert publishing | Kafka consumer + small HTTP | none (404) | 43 commits local only |
mobitra/web-portal |
Trips console, map, Route Builder, alert detail | React (CRA) | Developer | review branches pushed |
mobitra/notification-service |
Alert delivery over FCM | Kafka consumer | none (404) | 9 commits local only |
spring-custm-os |
Geofence CRUD, identity, JWT issuance | Java, HTTP | not needed | unchanged |
local-dev |
Rig, seeds, telemetry feeders | scripts | no remote | laptop only |
mobitra_developer2 (user id 21950467) cannot see
mobitra/geofencing or mobitra/notification-service.
Both answer 404 and are invisible to search, so 43 and 9 commits sit on a
laptop. Both need Developer. As a stopgap all three services' code is
readable on web-portal under
review/2026-08-26-trip-platform-*.
This blocks the notification-service alert-delivery fixes. It does not block the rollout of the two new services.
Division of labour
| Concern | Owner | Why there |
|---|---|---|
| Route and trip CRUD, approval, dispatch | route-trip-mgmt | It owns the schema and the state machine. |
| Corridor deviation, ETA, delay, missed checkpoints | route-trip-execution | Continuous geometry on a polyline. Nothing else has the frozen snapshot in memory. |
| Checkpoint hit, no-go entry, stop arrive and depart notifications | geofencing stream | It already does enter and exit edge detection for every geofence on the platform. |
| Stop arrive and depart notifications for stops with no geofence | route-trip-execution | The stream structurally cannot see them. See the negated predicate. |
| Delivery to a phone | notification-service | Owns FCM tokens and per-user preferences. |
| Geofence row creation | spring-custm-os | The Route Builder posts zones through the normal onboard endpoint with the caller's bearer token, so a trip zone is born the same way every other geofence is. |
Wiring and data flow
The Redis pivot
fleetgo:trip:active:{IMEI} is the one piece of shared mutable
state in the whole feature, and the rule around it is short:
| Role | Who | What for |
|---|---|---|
| Only writer | route-trip-execution | Loads it at dispatch, updates it on every packet, deletes it at complete or cancel. |
| Reader | route-trip-mgmt | The live views. Position, ETA, corridor state, progress, without touching the telemetry warehouse. |
| Reader | geofencing stream | To learn which geofence ids belong to this vehicle's live trip. |
One writer, two readers, no second cache to keep coherent. That coupling survived the repo split on purpose.
Position on the trip page comes from this Redis blob, not from the telemetry warehouse. So the trip pages work whenever the trip stack is up. They do not require status-info, the Kafka Connect sinks or Influx to be running. A Redis flush costs at most one packet, because the evaluator rebuilds hot state from Postgres on a miss.
What replaced geofence.trip_meta
Trip context used to be stamped as a JSONB column onto shared
geofence rows at dispatch. That meant route-trip-mgmt wrote to a
legacy table it does not own, and two live trips could not share a
geofence: the second dispatch overwrote the first.
The column is gone. The geofencing stream now reads the vehicle's own
active-trip blob from Redis and admits zones by id
(trip_zone_lookup.js). That is strictly narrower than the old
account-wide scan, and one geofence can now serve two trips at once.
route-trip-mgmt's migrations touch zero legacy tables.
Data model
One shared Postgres database, usermanagement. Thirteen tables are
owned by these migrations. Everything else the service reads is a narrow
read-only mirror declared in schema.prisma without relations, so
Prisma never tries to migrate it.
Owned
operational_stop, route, route_stop,
route_zone, route_version, trip_template,
trip, trip_assignment, trip_zone,
alert, audit_event, outbox,
idempotency_key.
Read-only mirrors, never migrated
geofence, device, users,
asset_details, asset_assignment,
ref_data, upload_tasks.
Every table named in a CREATE, ALTER or
DROP across all five migrations was intersected against the 44
tables live in UAT today.
tables the migrations touch : 27
tables live in UAT : 44
intersection : 0
Zero. The 14 that later migrations drop are dropped in the same series that created them, so a fresh apply lands on 13 tables.
_prisma_migrations. notification-service writes to it too. Two
consequences: one FAILED row from either service makes Prisma refuse everything
with P3009 and blame whichever ran last, and after the trip
migrations land notification-service's prisma migrate status will
report migrations in the database that are not in its folder. That is a warning,
not a failure, and migrate deploy still works. Tell whoever owns
that service before they see it.
The template and version relationship
This is the part most worth understanding, because four separate guardrails exist only to protect it.
route (template, editable)
|
|-- route_stop ordered, points at operational_stop
|-- route_zone role = 'checkpoint' | 'no_go', points at geofence_id
|-- geometry GeoJSON LineString, from OSRM or straight line
|-- corridor_width_m
|
+-- approve --> route_version { version, snapshot, geometry, approved_by }
^
| pinned at trip creation
trip.route_version_id
A trip pins route_version_id at creation and rides that frozen
snapshot for its whole life. Editing the route afterwards never retunes a trip
already on the road. Corridor width in particular is read from
snapshot.corridor_width_m, not live, so a mid-trip PATCH cannot
silently change what a running vehicle is measured against.
Key columns
| Column | Type | Notes |
|---|---|---|
route.current_version_id | int, nullable | The published version. Null on a route that has never been approved. An APPROVED route with a null here is refused at trip creation. |
route.corridor_width_m | real, default 100 | Metres either side of the line. Snapshotted at approval. |
route_zone.no_go_type | varchar(10) | HARD or SOFT. Runtime severity only, see below. |
route_zone.geofence_id | int, nullable | Scalar FK with no constraint. geofence is not trip-owned. |
trip.vehicle_imei | varchar | Denormalised from device at assign. Everything downstream keys on IMEI. |
trip.checklist | jsonb | {status, items[], completed_by, completed_dt}. Must be PASSED before dispatch. |
trip_assignment.kind | varchar(20) | vehicle or driver. One table, partial unique on (trip_id, kind) WHERE status='ACTIVE'. |
alert.dedup_key | varchar(255) | Partial unique on (account_id, alert_type, dedup_key) WHERE status='OPEN', created as raw SQL in migration 20250811010000. |
alert.metadata | jsonb | Carries snapshot, the vehicle vitals at the moment of the breach. Write once. |
alert.case_status | varchar(30), nullable | Non-null promotes the alert into the Exceptions view. Same row, two lenses. |
uq_alert_open_dedup is raw SQL in migration
20250811010000_alert_open_dedup_unique. Prisma cannot express
partial indexes, so it is invisible in schema.prisma and shows up as
drift. Do not "fix" the drift by dropping it. The whole dedup model rests on it:
upsertAlert does an insert-first with
ON CONFLICT ... DO NOTHING precisely so two packets for the same
trip cannot both decide there is no open alert.
Timestamps: the 5h30m bug
Worth reading even though it is closed, because it explains a migration that carries a deploy gate.
The trip tables originally stored timestamp WITHOUT time zone, a
wall clock. That only works while every writer agrees out of band which clock it
means, and two did not. route-trip-mgmt writes through Prisma, which uses UTC.
Execution wrote through node-postgres, which used local. On an IST host the same
column held both conventions 5 hours 30 minutes apart, so
planned_arrival read back five and a half hours in the past and
every dispatched trip raised TRIP_DELAYED within one packet,
reporting about 330 minutes. 330 minutes is the offset.
Fixed by 20260826000000_trip_timestamps_timestamptz: 33 columns
across 11 tables, converted with USING col AT TIME ZONE 'UTC' so the
conversion is pinned rather than depending on the session zone.
That migration rewrites those tables under an ACCESS EXCLUSIVE lock.
Free on empty tables, needs a maintenance window on populated ones. Applying it
while the stack was running killed every live process with
0A000 cached plan must not change result type. Nothing was lost,
because the consumer halts on failure and Kafka redelivered, but
take the stack down first. Verified on PostgreSQL 16;
UAT runs 12.22, so read the job output rather than assuming.
Rows written before it keep their old values.
API conventions and auth
One service serves the whole API: route-trip-mgmt on port 8100.
Everything sits under /api/v1 except /health.
route-trip-execution has no HTTP surface at all, so there is
nothing to call on it and it needs no gateway route.
Base URLs
| Environment | Base | Notes |
|---|---|---|
| Local rig | http://localhost:8100 | Header auth stub is allowed here and nowhere else. |
| UAT, in cluster | http://route-trip-mgmt-container:8100 | Kong reaches it by container name on mobitra-uat-bridge-net. Port 8100 is deliberately not published on the host. |
| UAT, from a browser | https://proxy.testmbtrsas.com/fleet-trip | Kong route with strip_path=true, so /fleet-trip/api/v1/trips arrives as /api/v1/trips. |
Authentication
Two modes, selected by TRIP_AUTH_MODE, and the service fails at
boot rather than on the first request if the pair is invalid.
jwt, the only mode allowed anywhere shared
Validates the portal token issued by spring-custm-os. HS256, signed with the
raw bytes of app.usermanagement.secretkey, so
TRIP_JWT_SECRET must be the identical plain string, not a base64
decode of it.
Authorization: Bearer <portal token>
Identity is then resolved from the database against the token subject.
Account, role id, role type and role name all come from there. Cached for
TRIP_IDENTITY_TTL_SEC seconds, which is also the lag on a role
change taking effect.
header, local rig only
The original stub. Refused outright when NODE_ENV=production.
X-Account-Id: 11
X-User-Id: bf-local
X-Role-Type: SuperAdmin (optional)
Both X-Account-Id and X-User-Id are required. The
old stub defaulted the user to the literal system, which made every
unattributed write look like a platform action in the audit log.
Under JWT auth, X-Account-Id is a request to operate on
another tenant. It is honoured only for roles allowed to cross accounts, and for
an Administrator only within their own account subtree. The response context
carries homeAccountId, crossAccount and
applyRoleScope so audit rows record which it was.
The service verifies the token itself even when Kong's jwt plugin has already
validated upstream, because the plugin has been found disabled on a live route
before. If Kong forwards X-Consumer-Username and it does not match
the token subject, the request is refused with CONSUMER_MISMATCH.
That is a misconfigured route, not a request to serve.
Permissions
Tiers are the platform's existing role.type values, keyed
lowercase because the casing in the platform's own data is inconsistent. An
unknown tier is treated as the least privileged known tier, so a typo in
role.type cannot grant everything.
| Tier | trip: create / assign / dispatch / cancel / checklist | route: create / edit | route: approve | alert & exception | config |
|---|---|---|---|---|---|
superadmin | yes | yes | yes | yes | yes |
mobitraadmin | yes | yes | yes | yes | yes |
administrator | yes | yes | yes | yes | yes |
other | yes | yes | no | yes | no |
mobitrasupport | no | no | no | yes | no |
external | no | no | no | no | no |
other is the catch-all tier most operational users sit in, so it
gets the day to day dispatch permissions. It does not get route approval and it
does not get config. Approving a route is the control that says "this corridor
is safe to run", and it should not be the same person's routine action.
Before this existed, every endpoint was reachable by any caller. Dispatch, cancel, approval and no-go-zone edits were gated only by trip state, never by who was asking. A driver could cancel a trip. Anyone could strip every no-go zone off a route with one PUT.
Idempotency
Four endpoints require an Idempotency-Key header and reject the
call with 400 IDEMPOTENCY_REQUIRED without one:
POST /api/v1/tripsPOST /api/v1/trips/:id/dispatchPOST /api/v1/trips/:id/driver-eventsPOST /api/v1/driver/sync
The key is claimed with an INSERT first, so concurrent retries race on the unique constraint instead of both reading "no cached response yet" and both proceeding. A read-then-write here produced duplicate trips and double dispatches. Cached responses live 24 hours. A failed attempt releases the claim so a retry actually retries.
| Situation | Result |
|---|---|
| New key | Request runs. Response body cached on success. |
| Same key, same body, original finished | 200 with the cached body, handler not re-run. |
| Same key, same body, original still running | 409 IDEMPOTENCY_IN_PROGRESS |
| Same key, different body | 409 IDEMPOTENCY_BODY_MISMATCH |
| Same key, different account | 409 IDEMPOTENCY_CONFLICT |
| Original failed | Claim released, retry runs normally. |
Shared shapes
- List endpoints return
{ items: [...] }. Trip event feeds return{ events: [...] }. The driver router returns{ trips: [...] }. - Errors return
{ error: "<message>", code: "<CODE>" }. - Every read is scoped to the caller's effective account. There is no endpoint that returns another tenant's rows without the cross-account path above.
- Coordinates in request bodies are latitude first for bare pairs. GeoJSON objects are accepted and follow GeoJSON order (longitude first). This is deliberate and both are handled by one normaliser.
- List caps: trips 100, alerts and exceptions 200, devices 200, events 100 default and 200 maximum.
Endpoint index
Click any row to jump. Cards below expand.
| Method | Path | Permission | Purpose |
|---|---|---|---|
| GET | /health | none | Liveness plus the active auth mode |
| POST | /api/v1/stops | authenticated | Create a catalogue stop |
| GET | /api/v1/stops | authenticated | List stops |
| GET | /api/v1/stops/:id | authenticated | One stop |
| PATCH | /api/v1/stops/:id | authenticated | Edit or deactivate a stop |
| POST | /api/v1/routes | route:create | Create a route shell |
| GET | /api/v1/routes | authenticated | List routes with publish state |
| GET | /api/v1/routes/:id | authenticated | Full route with zones and stops |
| PATCH | /api/v1/routes/:id | route:edit | Edit metadata, archive, demote |
| PUT | /api/v1/routes/:id/waypoints | authenticated | Replace the shape points |
| PUT | /api/v1/routes/:id/stops | authenticated | Replace the ordered stop list |
| PUT | /api/v1/routes/:id/checkpoints | route:edit | Replace checkpoints |
| PUT | /api/v1/routes/:id/no-go-zones | route:edit | Replace no-go zones |
| PUT | /api/v1/routes/:id/restrictions | route:edit | Replace the restrictions blob |
| PUT | /api/v1/routes/:id/geometry | authenticated | Set geometry directly |
| POST | /api/v1/routes/:id/plan | authenticated | Route through OSRM, store geometry |
| POST | /api/v1/routes/:id/approve | route:approve | Freeze a version, publish |
| POST | /api/v1/trips/templates | authenticated | Create a trip template |
| GET | /api/v1/trips/templates | authenticated | List active templates |
| POST | /api/v1/trips | trip:create | Create a trip on an approved route |
| GET | /api/v1/trips | authenticated | List trips for the board |
| GET | /api/v1/trips/live | authenticated | Live state for every active trip |
| GET | /api/v1/trips/:id | authenticated | Full trip detail |
| GET | /api/v1/trips/:id/live | authenticated | One trip's live position and progress |
| GET | /api/v1/trips/:id/events | authenticated | Per-trip audit timeline |
| PATCH | /api/v1/trips/:id/stops/:stopId | authenticated | Set planned times before dispatch |
| PATCH | /api/v1/trips/:id/checkpoints/:cpId | authenticated | Set checkpoint status before dispatch |
| POST | /api/v1/trips/:id/driver-events | driver or dispatcher | Driver app action on a trip |
| POST | /api/v1/trips/:id/assign | trip:assign | Attach vehicle and driver |
| POST | /api/v1/trips/:id/reassign | trip:assign | Swap vehicle or driver, including mid-trip |
| POST | /api/v1/trips/:id/checklist | trip:checklist | Pass or fail the pre-trip checklist |
| POST | /api/v1/trips/:id/dispatch | trip:dispatch | Arm monitoring, emit TRIP_DISPATCHED |
| POST | /api/v1/trips/:id/cancel | trip:cancel | Stop a trip and free the vehicle |
| GET | /api/v1/alerts | authenticated | Alert feed for the console |
| GET | /api/v1/alerts/:id | authenticated | One alert with vitals and elapsed time |
| POST | /api/v1/alerts/:id/acknowledge | authenticated | Acknowledge |
| POST | /api/v1/alerts/:id/resolve | authenticated | Resolve |
| GET | /api/v1/exceptions | authenticated | Alerts promoted to cases |
| POST | /api/v1/exceptions | authenticated | Promote an alert, or raise one by hand |
| GET | /api/v1/exceptions/:id | authenticated | One case |
| PATCH | /api/v1/exceptions/:id | authenticated | Own, triage, close |
| GET | /api/v1/devices | authenticated | Dispatchable vehicles with their driver |
| GET | /api/v1/events | authenticated | Account-wide trip event feed |
| GET | /api/v1/driver/trips | authenticated | The caller's own trips |
| POST | /api/v1/driver/sync | authenticated | Batch upload of offline driver events |
Stops
The reusable catalogue. A stop is a place, not a step in a journey. Routes
reference stops through route_stop with a sequence, so the same
depot can appear on fifty routes.
POST/api/v1/stops authenticatedCreate a catalogue stop
Body
{
"name": "Electronic City Depot", // required
"stop_type": "DEPOT", // validated against ref_data type 'stop_type'
"latitude": 12.8452,
"longitude": 77.6602,
"address": "Hosur Road, Bengaluru",
"geofence_id": 4412 // optional, and usually null
}
Returns
201 with the created row.
Errors
400 VALIDATION_ERROR missing name.
400 INVALID_REF_DATA unknown stop_type.
geofence_id defaults to null, and that is
the common case. A coordinate-only stop is invisible to the geofencing stream,
which is why trip execution notifies for exactly those.
See the negated predicate.
GET/api/v1/stops authenticatedList stops, default status ACTIVE
Query
?status=ACTIVE (default) or any other status value.
Returns
{ "items": [ { "id": 12, "name": "...", "latitude": ..., ... } ] }
Ordered by name.
GET/api/v1/stops/:id authenticatedOne stop
Errors
404 NOT_FOUND if it does not exist in the caller's account.
PATCH/api/v1/stops/:id authenticatedEdit fields, or retire with status
Body
Any subset of name, geofence_id,
stop_type, address, latitude,
longitude, status. Undefined fields are left alone.
Editing a stop's coordinates changes them for every route that references it, including approved ones. It does not demote those routes and it does not touch trips already running, because they ride the frozen snapshot. But the next approval will pick up the new position.
Routes
The zone-attachment endpoints are all full replace PUTs, not partial merges. Send the complete list every time. That is what makes the Route Builder able to treat the map as the source of truth.
Every structural edit on an APPROVED route sets it back to
DRAFT: waypoints, stops, checkpoints, no-go zones, restrictions,
geometry, plan, and a corridor width or trip type change through PATCH.
current_version_id is deliberately left alone, so trips already
riding that version keep running unchanged. New trips are refused with
409 ROUTE_NOT_APPROVED until someone approves again.
POST/api/v1/routes route:createCreate the route shell
Body
{
"name": "E-City to Whitefield", // required
"description": "morning line haul",
"trip_type": "LH", // validated against ref_data 'trip_type', default LH
"corridor_width_m": 120 // default 100
}
Returns
201 with the row. Status is DRAFT, geometry is null.
GET/api/v1/routes authenticatedList with publish state
Query
?status=APPROVED filters. Ordered by last update, newest first.
Returns
{ "items": [ {
"id": 101, "name": "...", "status": "APPROVED",
"corridor_width_m": 120,
"published_version": { "id": 62, "version": 3, "approved_by": "...", "approved_dt": "..." },
"working_copy_dirty": false
} ] }
current_version_id is a row id.
Showing it in the UI produced "v62" for what was actually version 3. Use
published_version.version for anything a human reads.
working_copy_dirty is the flag that says the template has been
edited since the last approval.
GET/api/v1/routes/:id authenticatedFull detail with zones and stops
Returns
The route plus route_stops (with the joined
operational_stop), zones split by role,
waypoints, geometry, restrictions,
published_version and working_copy_dirty.
Errors
404 NOT_FOUND.
PATCH/api/v1/routes/:id route:editMetadata, archive. Cannot approve.
Body
{ "name": "...", "description": "...", "trip_type": "LH",
"corridor_width_m": 150, "status": "DRAFT" | "ARCHIVED" }
Errors
409 APPROVAL_REQUIRED | Sending status: "APPROVED". Use the approve endpoint. |
400 VALIDATION_ERROR | Any status other than DRAFT or ARCHIVED. |
409 ROUTE_ARCHIVED | The route is archived. All writes are refused. |
404 NOT_FOUND |
Approval is its own control. It carries the
route:approve permission, the separation of duty check, and the
route_version freeze. Writing APPROVED through PATCH
skipped all three and left current_version_id null: a route that
looks published with no template for a trip to pin.
Changing corridor_width_m or trip_type also demotes an approved route.
PUT/api/v1/routes/:id/waypoints authenticatedFull replace of the shape points
Body
{ "waypoints": [
{ "sequence": 1, "latitude": 12.8452, "longitude": 77.6602 },
{ "sequence": 2, "latitude": 12.9100, "longitude": 77.6400 }
] }
These are the points fed to the planner. Two or more are needed before
/plan will work. Demotes an approved route.
PUT/api/v1/routes/:id/stops authenticatedFull replace of the ordered stop list
Body
{ "stops": [
{ "operational_stop_id": 12, "sequence": 1, "dwell_time_min": 15 },
{ "operational_stop_id": 19, "sequence": 2 }
] }
(route_id, sequence) is unique, so sequences must not repeat.
Trip creation expands this list into trip_zone rows with role
stop. Demotes an approved route.
PUT/api/v1/routes/:id/checkpoints route:editFull replace of the checkpoint series
Body
{ "checkpoints": [
{ "sequence": 1, "geofence_id": 5501, "name": "A - Hosur Road toll", "required": true },
{ "sequence": 2, "geofence_id": 5502, "name": "B - Silk Board", "required": true }
] }
Each checkpoint points at a real geofence row. Execution reads the checkpoint geometry from that row, so a checkpoint without a usable geofence monitors nothing.
Errors
400 GEOFENCE_INVALID if a referenced geofence does not exist, belongs
to another account, or has neither three or more vertices nor a centre and radius.
Replacing the checkpoint list nulls
trip_zone.route_zone_id on trips that referenced the old rows.
That is what makes an approved route editable at all, and it is why dispatch
re-checks: a trip whose checkpoints were detached by a later edit is refused
with 409 ROUTE_ZONE_INVALID rather than dispatched blind.
PUT/api/v1/routes/:id/no-go-zones route:editFull replace of the restricted zones
Body
{ "zones": [
{ "geofence_id": 5610, "no_go_type": "HARD", "name": "School zone" },
{ "geofence_id": 5611, "no_go_type": "SOFT", "name": "Market lane" }
] }
HARD raises CRITICAL,
SOFT raises WARN. Same detection, same edge trigger,
same cooldown. Routing avoids neither. The planner does not know these
zones exist. A soft zone is a warning, not a silent skip. The vocabulary is
CRITICAL | WARN | INFO, never WARNING.
Errors
400 GEOFENCE_INVALID, same checks as checkpoints.
PUT/api/v1/routes/:id/restrictions route:editFree-form restrictions blob
Body
{ "restrictions": { "max_speed_kmh": 60, "night_driving": false } }
Stored as jsonb and carried into the version snapshot. Nothing in execution reads it today. It is captured so an approver signs off on it and so a later rule engine has somewhere to look. Demotes an approved route.
PUT/api/v1/routes/:id/geometry authenticatedSet geometry directly, no planner
Body
{
"geometry": { "type": "LineString", "coordinates": [[77.66,12.84],[77.64,12.91]] },
"distance_m": 12000,
"duration_s": 1800
}
GeoJSON order here: longitude first. At least two coordinates.
osrm_meta is cleared, so a route whose geometry was set by hand
cannot later claim it was road-snapped.
Errors
400 VALIDATION_ERROR, 409 ROUTE_ARCHIVED, 404 NOT_FOUND.
Used by the portal as the fallback when the planner is unavailable, and by the corridor fixture path in the acceptance harness.
POST/api/v1/routes/:id/plan authenticatedRoute the line through OSRM and store it
Takes the stored waypoints if there are two or more, otherwise the route's stops in sequence, and asks the routing engine for a road-snapped line. Stores geometry, distance, duration and the raw engine response.
Returns
{
"route_id": 101,
"geometry": { "type": "LineString", "coordinates": [...] },
"distance_m": 24188.4,
"duration_s": 2735,
"planned_via_osrm": true
}
Errors
400 VALIDATION_ERROR fewer than two waypoints or stops, or a stop
with no coordinates. 404 NOT_FOUND.
False means the straight-line fallback ran: the engine was down, or the coordinates were outside the loaded region's coverage. The approver's confirm step shows this, because signing off on a straight line drawn through buildings is a different decision from signing off on a road route.
An OSRM serving a city-scoped dataset (UAT runs a Lucknow-only graph) answers
out-of-coverage coordinates with code: "Ok". It snaps every waypoint
to the nearest edge it has, however far away, and returns a zero-length route
between them. Measured: Bengaluru coordinates against the Lucknow graph give
{"code":"Ok","distance":0}.
The guard is the invariant that a road route can never
be shorter than the straight line between its endpoints. If
route.distance < 0.9 * haversine(first, last) the answer is
rejected as out of coverage and the soft fallback fires. Without it, that
degenerate geometry gets stored and the corridor evaluator then judges real
vehicles against it.
Engine selection
ROUTING_ENGINE | OSRM_URL | Behaviour |
|---|---|---|
| unset | unset | Straight line, haversine distance, about 30 km/h. Never errors. |
| unset | set | Try OSRM, fall back to straight line on failure. This is what UAT ships. |
osrm | set | OSRM failures are hard errors. |
straightline | anything | Skip OSRM entirely. |
POST/api/v1/routes/:id/approve route:approveFreeze a version and publish
The publish control. In one transaction it writes a new
route_version with the next version number and the full snapshot,
then points route.current_version_id at it and sets the route
APPROVED.
Returns
201 with the created route_version row.
Preconditions, in the order they are checked
404 NOT_FOUND | No such route in this account. |
409 ROUTE_NOT_PLANNED | No geometry. Plan it first. |
409 ROUTE_NO_ENDPOINTS | Fewer than two stops and fewer than two waypoints. A corridor with no start and end cannot be dispatched against and execution has nothing to arrive at, so the trip could never complete. |
403 SELF_APPROVAL_FORBIDDEN | The caller created the route. Separation of duty. |
403 FORBIDDEN | The caller's tier lacks route:approve. |
Setting it to true disables the
separation of duty check. It exists because the local rig and the acceptance
scripts create and approve as the same user, and a hard rule would break every
one of them. It must stay unset in any shared environment. The UAT compose
file deliberately does not set it.
snapshotRoute strips
versions, status and current_version_id
before freezing. Leaving them in meant every version embedded every previous
version, so snapshots grew multiplicatively.
Trips
POST/api/v1/trips/templates authenticatedCreate a trip template
Body
{ "name": "Morning E-City run", "route_id": 101,
"checklist": [ { "id": "vehicle_inspection", "label": "...", "required": true } ],
"config": {} }
A template is a saved default: a route plus a checklist plus config. Creating a trip from one inherits the route and the checklist items.
Errors
400 VALIDATION_ERROR missing name, or unknown route_id.
GET/api/v1/trips/templates authenticatedActive templates, by name
Returns
{ "items": [ ... ] }Only status = ACTIVE.
POST/api/v1/trips trip:createCreate a trip on an approved route Idempotency-Key
Headers
Idempotency-Key: trip-create-<uuid> // required
Body
{
"route_id": 101, // or template_id, whose route_id is used
"template_id": 4,
"trip_type": "LH", // defaults to the route's
"trip_number": "TRP-...", // auto-generated when omitted
"scheduled_start": "2026-08-28T04:30:00Z",
"scheduled_end": "2026-08-28T09:00:00Z",
"external_ref": { "erp_id": "SO-88123" },
"status": "PLANNED" // DRAFT | PLANNED | SCHEDULED only
}
What it does, in one transaction
- Pins
route_version_idfrom the route'scurrent_version_id. - Expands every
route_stopinto atrip_zonewith rolestop, statusPENDING. - Expands every checkpoint
route_zoneinto atrip_zonewith rolecheckpoint. - Seeds the checklist, from the template if there is one, otherwise the default items.
- Writes an
audit_eventand anoutboxrow forTRIP_CREATED.
Returns
201 with the full trip including its zones.
Errors
409 ROUTE_NOT_APPROVED | Route status is not APPROVED, or it is APPROVED with a null current_version_id. The second case is a row written around the approve endpoint by the old PATCH path: there is no template to pin, so there is nothing the trip could safely run. |
400 INVALID_STATUS | Asked for a status later than SCHEDULED. A trip born DISPATCHED never emits the dispatch event, so execution would never know about it. |
400 VALIDATION_ERROR | No route, unknown route or template, or a non-integer id. A client sending "96" instead of 96 used to reach Prisma and come back as an opaque 500. |
400 IDEMPOTENCY_REQUIRED | No key header. |
GET/api/v1/trips authenticatedBoard list, newest first, capped at 100
Query
?status=DISPATCHED
Returns
Each row carries its route (id, name, trip_type) and its zones with the joined operational stop, so the dispatcher console can render a Route column without a follow-up request per trip.
GET/api/v1/trips/live authenticatedEvery active trip's live state in one call
Backs both the fleet map and the ETA column. One Postgres query for the
DISPATCHED and EN_ROUTE trips, then one Redis
MGET for all their IMEIs. One round trip regardless of fleet size.
Returns
{ "items": [ {
"trip_id": 512, "trip_number": "TRP-...", "status": "EN_ROUTE",
"imei": "10000005000006", "driver_user_id": "drv-7",
"route_name": "E-City to Whitefield", "corridor_width_m": 120,
"monitored": true,
"position": { "lat": 12.91, "lng": 77.64, "recorded_at": "2026-08-27T09:12:03Z" },
"inside_corridor": true,
"speed_kmh": 38.4, // smoothed EMA, not a single noisy fix
"progress_m": 8120, "route_distance_m": 24188,
"stops_done": 1, "stops_total": 2,
"checkpoints_hit": 2, "checkpoints_total": 3,
"polyline": [[12.84,77.66], ...],
"eta": { "eta_dt": "...", "planned_arrival_dt": "...", "delay_seconds": 420 }
} ] }
/liveis declared before/:id. Express matches in order, andlivewould otherwise be captured as a trip id and failNumber().stops_donecountsARRIVEDas done, not onlyDEPARTED. Arriving at the final stop is what completes the trip, so that stop never reaches DEPARTED, and counting only departures left every finished trip one short of its own total.
monitored: false means there is no Redis blob for that IMEI: the
trip is dispatched in Postgres but execution has not loaded it, or has lost it.
That is the field to check first when a trip looks dead on the map.
GET/api/v1/trips/:id authenticatedFull trip detail
Trip, route, pinned route version, zones with their stops and checkpoints,
assignments, checklist. 404 NOT_FOUND outside the caller's account.
GET/api/v1/trips/:id/live authenticatedOne trip: where it is and how far through
Same Redis source as the fleet call, plus vehicle identity read directly from
device and asset_details, and average speed. The
snapshot is keyed by IMEI, so the handler guards against the blob belonging to a
newer trip on the same vehicle.
The trip page polls this every 3000 ms. There is no websocket and no SSE anywhere in the portal.
GET/api/v1/trips/:id/events authenticatedPer-trip audit timeline
Returns
{ "events": [ { "id": "8812", "action": "TRIP_DISPATCHED", "actor_id": "...", "payload": {...}, "created_dt": "..." } ] }
Reads audit_event scoped to this trip. id is a
BigInt serialised as a string, because JSON.stringify cannot handle
BigInt.
PATCH/api/v1/trips/:id/stops/:stopId authenticatedPlanned times, and status before dispatch
Body
{ "status": "PENDING", "planned_arrival": "2026-08-28T05:10:00Z", "planned_departure": "..." }
Errors
409 EXECUTION_OWNED when status is sent and the trip
is DISPATCHED or later. Planned times can still be set; only status
is locked. 404 NOT_FOUND for the trip or the stop row.
PATCH/api/v1/trips/:id/checkpoints/:checkpointId authenticatedCheckpoint status before dispatch
Body
{ "status": "PENDING" }
Same 409 EXECUTION_OWNED rule. Once monitoring is armed, only the
evaluator moves checkpoint status.
POST/api/v1/trips/:id/driver-events driver or dispatcherAn action taken by the person in the vehicle Idempotency-Key
Body
{ "eventType": "START_TRIP", "clientTimestamp": "2026-08-28T04:32:11Z", "payload": {} }
Event types
| Type | Effect | Allowed when |
|---|---|---|
START_TRIP | Marks the trip started from the driver side. | Only DISPATCHED. Allowing ASSIGNED skipped the dispatch gate entirely. |
STOP_ARRIVE | Manual arrival at a stop. | Driver-writable statuses. |
STOP_DEPART | Manual departure. | Driver-writable statuses. |
CHECKLIST_COMPLETE | Marks the checklist done from the app. | Any status. This is the one exception to the status gate. |
SOS | Raises an alert. | Driver-writable statuses. |
EXPENSE | Records an expense against the trip. | Driver-writable statuses. |
Errors
409 INVALID_STATE when the trip status does not allow the event.
404 NOT_FOUND. 403 when the caller is neither the
assigned driver nor a dispatcher tier.
Dispatch
These are mounted on the same /api/v1/trips prefix. They are the
lifecycle transitions, and each one has a guard that came from a real failure.
POST/api/v1/trips/:id/assign trip:assignAttach a vehicle and a driver
Body
{ "device_id": "MT013-0007", "driver_user_id": "drv-7" } // both required
What it does
Validates the pair, denormalises the device's IMEI onto
trip.vehicle_imei, sets status ASSIGNED, writes ACTIVE
rows into trip_assignment for both kinds, and emits
TRIP_ASSIGNED through the outbox.
Validation
| Check | Kind |
|---|---|
| Device exists in this account | blocking |
| Device is active | blocking |
| Asset row exists for the device | blocking |
An ACTIVE asset_assignment links this driver to this device | soft, returned as a warning |
Returns
{ "trip": { ... }, "validation": { "device_valid": true, "blocks": [], "warnings": [ ... ] } }
Errors
400 ASSIGNMENT_BLOCKED with the reasons joined.
409 INVALID_STATUS unless the trip is DRAFT, PLANNED, SCHEDULED or
ASSIGNED. SCHEDULED being assignable matters: a trip created SCHEDULED used to
hit a dead end here and could never be assigned.
POST/api/v1/trips/:id/reassign trip:assignSwap vehicle or driver, including mid-trip
Body
{ "device_id": "MT013-0011", "driver_user_id": "drv-9", "reason": "breakdown at Silk Board" }
At least one of the two. Unlike assign, this works at any non-terminal status including in flight, which is the point: swap a broken-down vehicle without cancelling the trip.
What it does
- Marks the old
trip_assignmentrowREPLACEDand writes a new ACTIVE one. - Keeps the denormalised trip columns in sync.
- Emits
TRIP_REASSIGNEDcarryingold_imei,imeiandvehicle_changed, so execution can move its IMEI-keyed hot state.
Errors
409 INVALID_STATUS on COMPLETED or CANCELLED.
409 NOT_ASSIGNED if the trip has no device or driver to swap from.
400 NO_CHANGE if neither actually changes.
400 ASSIGNMENT_BLOCKED.
A driver-only reassignment carries no IMEI change, so execution does nothing with it.
POST/api/v1/trips/:id/checklist trip:checklistPass or fail the pre-trip checklist
Body
{ "status": "PASSED", // PASSED | FAILED | PENDING, defaults to PASSED
"items": [ { "id": "vehicle_inspection", "checked": true },
{ "id": "documents", "checked": true } ] }
Passing requires every required item to be checked. completed_by
and completed_dt are stamped on PASSED and cleared otherwise.
Errors
400 VALIDATION_ERROR unknown status, or a required item left
unchecked. 404 NOT_FOUND for the trip or a missing checklist.
POST/api/v1/trips/:id/dispatch trip:dispatchArm monitoring Idempotency-Key
The gate everything else protects. On success the trip becomes
DISPATCHED, the outbox carries TRIP_DISPATCHED to
trip.events, and execution loads
fleetgo:trip:active:{IMEI}.
Returns
{ "trip": { ... }, "route_version_id": 62, "zones_armed": 5, "event": "TRIP_DISPATCHED" }
zones_armed is how many zones this trip will actually watch.
Preconditions, in order
| already DISPATCHED | 200 with "Already dispatched". Not an error. |
409 INVALID_STATUS | Not ASSIGNED. |
409 NOT_ASSIGNED | No device or no driver. |
409 DISPATCH_BLOCKED | Re-validation failed. The assignment can go stale between assign and dispatch: device decommissioned, driver unassigned. |
409 CHECKLIST_REQUIRED | Checklist is not PASSED. |
409 NO_ROUTE | Trip has no route. |
409 ROUTE_NOT_APPROVED | No frozen version to run. Dispatch never mints one. |
409 ROUTE_ZONE_INVALID | A no-go zone with no geofence, or a checkpoint with no geofence or detached by a later route edit. |
409 VEHICLE_BUSY | Another DISPATCHED or EN_ROUTE trip already holds this IMEI. |
Execution keys hot state by IMEI. Dispatching a second trip onto the same vehicle silently overwrote the first and left it dispatched but never monitored. The check names the offending trip in the message so the dispatcher knows what to cancel.
A zone with no geofence evaluates nothing in flight. That is a silent monitoring gap, not an error anywhere. New attachments are rejected upstream, so this check exists for rows that predate that rule and for checkpoints a later route edit detached. Failing loudly here beats dispatching a trip that cannot be monitored as planned.
POST/api/v1/trips/:id/cancel trip:cancelStop a trip and free the vehicle
Body
{ "reason": "customer cancelled" }
Works at any non-terminal status, including in flight. Sets
CANCELLED, writes the audit row, and emits
TRIP_CANCELLED with the IMEI in the payload.
Errors
409 INVALID_STATUS on COMPLETED or CANCELLED only.
Execution clears
fleetgo:trip:active:{IMEI} from this payload. Without it the hot
state survives the cancel and the vehicle keeps alerting against a dead trip.
Blocking cancellation on DISPATCHED and EN_ROUTE, which an earlier version did,
left an in-flight trip with no way out: the vehicle stayed occupied forever and
execution's cancel handler was unreachable dead code.
Alerts
GET/api/v1/alerts authenticatedFeed for the console, newest first, capped at 200
Query
?trip_id=512
?status=OPEN,ACKNOWLEDGED // comma list
The console used to ask for OPEN only, so
acknowledging an alert deleted it from the view permanently. The comma list is
there so one request can cover both.
GET/api/v1/alerts/:id authenticatedOne alert with vitals and elapsed time
Returns
{
"id": 9912, "alert_type": "ROUTE_DEVIATION", "severity": "WARN", "status": "OPEN",
"message": "Vehicle off route (266m from corridor)",
"metadata": {
"distance_m": 266, "lat": 12.9312, "lng": 77.6221,
"snapshot": { "soc": 61, "speed": 42.1, "odometer": 88213,
"ignition": true, "gps_fix": true, "heading": 118 }
},
"trip": { "id": 512, "trip_number": "...", "vehicle_imei": "...", "driver_user_id": "..." },
"elapsed_s": 1840
}
The alert row's only foreign keys are account and trip, so the detail view
joins the trip to say which vehicle and when. elapsed_s is measured
from actual_start, falling back to scheduled_start, and
is null rather than zero when neither is known.
The corridor evaluator upserts on every
off-route packet. A flat metadata overwrite meant the stored deviation position
was where the vehicle came back, not where it left. On one drive the persisted
distances read 124, 125 and 133 m while the actual breaches were 266, 253 and
320 m. snapshot is now frozen at first write; the rest of metadata
keeps rolling.
POST/api/v1/alerts/:id/acknowledge authenticatedSomeone has seen it
Sets ACKNOWLEDGED and stamps who and when. Empty body.
Errors
409 ALERT_RESOLVED if it is already resolved. 404 NOT_FOUND.
Acknowledging a live
ROUTE_DEVIATION does not stop it. The dedup key is still open, so
the next off-route packet re-notifies after the cooldown. Known, still open.
POST/api/v1/alerts/:id/resolve authenticatedClose it
Sets RESOLVED with who and when. This also frees the dedup key,
so a fresh breach of the same kind opens a new alert rather than reusing the
old row.
Exceptions and cases
An exception is an alert row with a
non-null case_status. There is no separate table. Promoting an
alert to a case sets case_status = OPEN and an owner. Creating an
exception with no alert_id inserts a fresh alert of type
EXCEPTION.
GET/api/v1/exceptions authenticatedAlerts that were promoted to cases
Query
?trip_id=, ?status=OPEN filters on case_status. Capped at 200.
POST/api/v1/exceptions authenticatedPromote an alert, or raise a case by hand
Body
{ "alert_id": 9912, // promote this alert
"trip_id": 512, // or raise a standalone case on a trip
"owner_user_id": "ops-3", // defaults to the caller
"severity": "MEDIUM",
"root_cause": "driver took the service road",
"sla_due_dt": "2026-08-28T18:00:00Z",
"notes": "..." }
Returns
201 with the case shape.
Errors
400 VALIDATION_ERROR for an unknown trip or alert.
GET/api/v1/exceptions/:id authenticatedOne case
404 NOT_FOUND if the row exists but has a null case_status, because then it is an alert and not a case.
PATCH/api/v1/exceptions/:id authenticatedOwn it, triage it, close it
Body
{ "owner_user_id": "ops-3", "severity": "HIGH", "status": "CLOSED",
"root_cause": "...", "notes": "..." }
status writes case_status, and CLOSED
also stamps closed_dt.
Driver
Mounted at /api/v1/driver. Built for the driver app, which is
offline-first, so the sync endpoint accepts a batch.
GET/api/v1/driver/trips authenticatedThe caller's own trips
Query
?status=DISPATCHED
The driver is taken from the token subject, not from a parameter, so this endpoint cannot be pointed at somebody else's trips.
Returns
{ "trips": [ ... ] }
POST/api/v1/driver/sync authenticatedBatch upload of events collected offline Idempotency-Key
Body
{ "events": [
{ "eventType": "START_TRIP", "tripId": 512, "clientTimestamp": "..." },
{ "eventType": "STOP_ARRIVE", "tripId": 512, "clientTimestamp": "...", "payload": { "tripStopId": 88 } }
] }
Returns
{ "results": [ { "ok": true, ... }, { "ok": false, "error": "..." } ],
"synced": 1, "failed": 1 }
Per-event results, so one bad event does not reject the batch. The whole call is still idempotent on the key.
Devices, events, health
GET/api/v1/devices authenticatedDispatchable vehicles with their active driver
Read-only view for the vehicle picker. device is spring-owned and
this service mirrors only the columns it needs. Dispatchable means it has both a
device_id and an imei: telemetry keys on IMEI, dispatch
validation keys on device_id.
Returns
{ "items": [ { "device_id": "MT013-0007", "imei": "10000005000006",
"vehicle_number": "KA53AB0191", "driver_name": "...",
"driver_user_id": "drv-7" } ] }
driver_user_id is enriched from the active
asset_assignment, so picking a vehicle auto-fills the driver instead
of making the operator hunt user ids. Capped at 200, ordered by vehicle number.
GET/api/v1/events authenticatedAccount-wide trip event feed
Query
?limit=100, clamped to 1..200.
The same audit_event rows as the per-trip timeline, across the
whole account, newest first. Backs the dashboard Trip Events tab on a 10 second
poll, so it is deliberately read-only and hard-capped.
GET/health no authLiveness plus the active auth mode
{ "status": "ok", "service": "trip-core", "phase": "2-dispatch-api", "auth_mode": "jwt" }
"header" in a shared environment means the
env did not apply. That is a security regression, not a warning. The CD job
greps this string and rolls the image tag back if it is not
"jwt".
No secret is exposed. There is no readiness probe separate from this, and no health endpoint at all on route-trip-execution: liveness there is the container staying up and its consumer group staying Stable.
Error codes
Every error is { "error": "<message>", "code": "<CODE>" }.
The message is written for a human and is safe to show in the UI.
| Code | HTTP | Meaning and what to do |
|---|---|---|
UNAUTHORIZED | 401 | Missing, malformed or expired token; or a valid signature whose subject is not an active platform user. Deliberately does not say which. |
CONSUMER_MISMATCH | 401 | Kong's forwarded consumer does not match the token subject. Misconfigured gateway route. |
INVALID_ACCOUNT | 400 | X-Account-Id is not a positive integer. |
FORBIDDEN | 403 | The caller's role tier lacks the permission. The message names the tier and the permission. |
SELF_APPROVAL_FORBIDDEN | 403 | A route must be approved by someone other than its author. |
NOT_FOUND | 404 | No such row in the caller's effective account. Cross-tenant rows read as absent, never as forbidden. |
VALIDATION_ERROR | 400 | Bad or missing body field. The message names it. |
INVALID_REF_DATA | 400 | A trip_type or stop_type that is not in ref_data. |
GEOFENCE_INVALID | 400 | A zone references a geofence that does not exist, belongs to another account, or has no usable shape. |
APPROVAL_REQUIRED | 409 | Tried to set status: APPROVED through PATCH. |
ROUTE_ARCHIVED | 409 | Writing to an archived route. |
ROUTE_NOT_PLANNED | 409 | Approving a route with no geometry. |
ROUTE_NO_ENDPOINTS | 409 | Approving a route with no start and end. |
ROUTE_NOT_APPROVED | 409 | Creating or dispatching a trip on a route that is not published. Refetch the route: it was probably demoted by an edit. |
ROUTE_ZONE_INVALID | 409 | A zone on this trip cannot be monitored. Fix the route, re-approve, create a new trip. |
INVALID_STATUS | 400 / 409 | The transition is not legal from the current status. |
INVALID_STATE | 409 | A driver event that the trip's status does not allow. |
EXECUTION_OWNED | 409 | Human tried to write a trip_zone status after dispatch. |
ASSIGNMENT_BLOCKED | 400 | Hard validation failure on device or driver. |
DISPATCH_BLOCKED | 409 | Re-validation at dispatch failed. Something went stale since assign. |
CHECKLIST_REQUIRED | 409 | Checklist is not PASSED. |
NOT_ASSIGNED | 409 | No device or driver on the trip. |
NO_ROUTE | 409 | Trip has no route. |
NO_CHANGE | 400 | A reassignment that changes nothing. |
VEHICLE_BUSY | 409 | Another live trip holds this IMEI. The message names it. |
ALERT_RESOLVED | 409 | Acknowledging an already resolved alert. |
IDEMPOTENCY_REQUIRED | 400 | Missing Idempotency-Key. |
IDEMPOTENCY_IN_PROGRESS | 409 | The first request with this key is still running. Do not start a second. |
IDEMPOTENCY_BODY_MISMATCH | 409 | Key reused with a different body. |
IDEMPOTENCY_CONFLICT | 409 | Key belongs to another account. |
Lifecycle, end to end
One pass from an empty map to a completed trip, naming the call, the gate and the side effects at each step.
- Draw the zones. In the Route Builder, each circle or polygon becomes a
real
geofencerow, created through spring-custm-os with the caller's bearer token. Trip zones are born the same way every other geofence is, not inserted directly and unauthenticated as they once were.Spring answers business failures with 404Not 400, not 422. The message is in the body, so error handling branches on the message and not the status code. Anyone touching that path needs to know this before they "fix" it.
- Create the route and attach everything.
POST /routes, then the full-replace PUTs for waypoints, stops, checkpoints and no-go zones. Status isDRAFTthroughout. - Plan.
POST /routes/:id/planasks OSRM for a road-snapped line and stores geometry, distance and duration. Checkplanned_via_osrm. The builder now previews the routed line before anything can be published. - Approve.
POST /routes/:id/approvefreezes aroute_versionand setscurrent_version_id. A different person from the author, unless the local self-approval flag is on. This is the moment the corridor becomes something a vehicle can be judged against. - Create the trip.
POST /tripswith an idempotency key. It pins the version and expands stops and checkpoints intotrip_zonerows. StatusPLANNED. - Assign.
POST /trips/:id/assignattaches vehicle and driver, denormalises the IMEI, records assignment history. StatusASSIGNED. - Checklist.
POST /trips/:id/checklistwithstatus: PASSEDand every required item checked. - Dispatch.
POST /trips/:id/dispatch. Re-validates the assignment, checks the checklist, checks the zones are monitorable, checks no other live trip holds this IMEI. Then, in one transaction: statusDISPATCHED, an audit row, and anoutboxrow forTRIP_DISPATCHED. - The outbox publisher picks it up. A 1 to 2 second poll reads unpublished
rows, wraps each in an envelope and produces to
trip.eventspartitioned byaccount_id:trip_id, then marks the row published. Publish failures leave the row unpublished so the next poll retries. - Execution activates the trip. It consumes
TRIP_DISPATCHED, loads the trip from Postgres bound to the account the event claims, and writesfleetgo:trip:active:{IMEI}. From here every packet for that IMEI is evaluated. - Packets flow. First packet sets
EN_ROUTEand emitsTRIP_STARTED. Then corridor, no-go, checkpoints, stops and ETA, in that order, on every packet. See below. - Complete. When the trip reaches its end, execution sweeps any required
checkpoint never reached into
CHECKPOINT_MISSED, marks the tripCOMPLETED, emitsTRIP_COMPLETEDand deletes the hot state. Cancel does the same teardown from the API side at any non-terminal status.
The event envelope
Everything on trip.events has the same shape, from both
producers.
{
"schema_version": "1.0",
"event_id": "<uuid>",
"event_type": "TRIP_DISPATCHED",
"occurred_at": "2026-08-27T09:12:03.881Z",
"account_id": 11,
"trip_id": 512,
"correlation_id": "<uuid>",
"payload": { "imei": "...", "route_version_id": 62 }
}
Partition key is account_id:trip_id, so every event for one trip
lands on one partition and stays ordered.
Execution marks event_id seen in Redis
for 24 hours before handling it, and gives the marker back if the handler
throws, so a retry actually re-runs. Handlers for
TRIP_DISPATCHED throw rather than return on a missing IMEI or route
version: a dispatched trip that fails to activate is a silent monitoring outage,
and returning would consume the message with no retry and nothing on the dead
letter queue.
Per-packet evaluation
This is the whole of route-trip-execution. It consumes
location-packet-topic, looks up the IMEI, and if there is an active
trip it runs five evaluators in a fixed order.
Packet path
packet arrives (keyed by tags.IMEINumber)
|
+-- fleetgo:trip:notrip:{IMEI} set? -> drop, no Postgres hit (60s negative cache)
+-- fleetgo:trip:active:{IMEI}? -> use it
+-- miss -> rebuild from Postgres, or mark notrip
|
+-- offline gap > OFFLINE_GAP_MS? -> reset all hysteresis streaks
+-- first packet? -> status EN_ROUTE, emit TRIP_STARTED
+-- freeze vitals snapshot for this packet (once, before the evaluators)
|
+-- 1. corridor distance to the simplified polyline
+-- 2. no-go point in polygon, edge triggered
+-- 3. checkpoints point in polygon, sequence aware
+-- 4. stops point in polygon, debounced enter and exit
+-- 5. ETA and delay (skipped once completed)
|
+-- reached the end? -> sweep missed checkpoints, COMPLETED, delete hot state
ETA runs last on purpose: it reads progress along the same corridor the checks above use, and a completed trip should not forecast an arrival it has already made.
Hysteresis and debounce
Nothing fires on a single fix. Every transition needs a streak, so one bad GPS sample cannot raise an alert.
| Setting | Default | Controls |
|---|---|---|
CORRIDOR_BREACH_COUNT | 3 | Consecutive outside-corridor packets before ROUTE_DEVIATION. |
CORRIDOR_OK_COUNT | 3 | Consecutive inside packets before it resolves. |
DEBOUNCE_ENTER_COUNT | 2 | Packets inside a stop before ARRIVED. |
DEBOUNCE_EXIT_COUNT | 2 | Packets outside before DEPARTED. |
OFFLINE_GAP_MS | 900000 (15 min) | A gap longer than this resets every streak, so a vehicle coming back online does not resume a half-built breach. |
ALERT_COOLDOWN_MS | 300000 (5 min) | Minimum gap between re-notifications on an alert that is still open. |
DELAY_THRESHOLD_SEC | 600 | Seconds past planned arrival before TRIP_DELAYED. |
EVAL_POLYLINE_TOLERANCE_DEG | 0.0001 (about 11 m) | Douglas-Peucker simplification of the corridor line used for per-packet maths. Measured 2000 points down to 283, and 5.36 ms down to 0.75 ms per packet, a 7x cut. Set 0 to disable. |
NO_TRIP_CACHE_TTL_SEC | 60 | How long an IMEI is remembered as having no active trip before Postgres is asked again. |
These decide which vehicles fire
NO_GO_ENTRY and CHECKPOINT_HIT on real fences. The UAT
compose file leaves every one at its code default on purpose. Changing one
belongs in a commit of its own with a before and after diff over the golden
corpus.
Dedup and cooldown
An alert is identified by (account_id, alert_type, dedup_key)
while its status is OPEN.
| Alert | Dedup key |
|---|---|
ROUTE_DEVIATION | {tripId}:ROUTE_DEVIATION |
NO_GO_ENTRY | {tripId}:NO_GO:{zoneId} |
CHECKPOINT_MISSED | {tripId}:CHECKPOINT_MISSED:{tripCheckpointId} |
The write is insert-first with
ON CONFLICT ... DO NOTHING against the partial unique index. If the
insert lands, this is a new alert and it notifies. If it does not, an open alert
already exists, and an UPDATE decides whether the cooldown has elapsed. The row
itself reports whether this call is the one that re-notifies, and
NOW() is fixed for the statement so the comparison is exact.
A SELECT-then-INSERT here would race: two packets for the same trip could both see no open alert and each insert one.
Who notifies what
Exactly one notifier per event, and the split is structural rather than a convention.
| Event | Alert row written by | Pushed to the phone by |
|---|---|---|
ROUTE_DEVIATION, ROUTE_DEVIATION_RESOLVED | execution | execution |
TRIP_DELAYED | execution | execution |
CHECKPOINT_MISSED | execution | execution |
TRIP_STARTED, TRIP_COMPLETED | none | execution |
NO_GO_ENTRY | execution (deduped row) | geofencing stream |
CHECKPOINT_HIT | execution | geofencing stream |
STOP_ARRIVED, STOP_DEPARTED, stop has a geofence | execution | geofencing stream |
STOP_ARRIVED, STOP_DEPARTED, stop has no geofence | execution | execution |
Stop events were emitted with notify off, deferring to the geofencing stream. That stream can only see a stop that has a geofence, and 132 of 144 operational stops have none: they carry coordinates only. The alerts reached Postgres and the trip topic and then vanished with no error anywhere.
Execution now notifies for exactly the stops geofencing structurally cannot see. The predicate is the deliberate negation of the stream's admission test. Drift one way is a silent drop, the other a duplicate. See the pinned decisions.
The vitals snapshot
Every alert freezes the vehicle's state into
alert.metadata.snapshot: SOC, speed, odometer, ignition, GPS fix and
heading. Computed once per packet, before the evaluators run, so they all see the
same values. No migration was needed, because that column is already jsonb and
the packet already carried the fields. The topic ships about 38 of them and
execution declared seven.
Alert catalogue
| Type | Severity | Raised when | Clears |
|---|---|---|---|
ROUTE_DEVIATION | WARN | Distance to the corridor line exceeds half the pinned width, for CORRIDOR_BREACH_COUNT consecutive packets. | ROUTE_DEVIATION_RESOLVED after CORRIDOR_OK_COUNT inside packets. |
ROUTE_DEVIATION_RESOLVED | INFO | Back inside the corridor. | n/a, it is the clear. |
NO_GO_ENTRY | CRITICAL HARD WARN SOFT | Point enters a no-go polygon or circle. Edge triggered. | Manual resolve. NO_GO_EXIT is published but not delivered, see open defects. |
CHECKPOINT_HIT | INFO | Point enters a checkpoint zone. | n/a, it is a fact not a problem. |
CHECKPOINT_MISSED | WARN | A later checkpoint in the sequence was hit while this one was still pending, or the trip reached its end with a required checkpoint never reached. | Manual resolve. |
STOP_ARRIVED | INFO | DEBOUNCE_ENTER_COUNT packets inside the stop. | n/a |
STOP_DEPARTED | INFO | DEBOUNCE_EXIT_COUNT packets outside after arriving. | n/a |
TRIP_STARTED | INFO | First packet after dispatch. Sets EN_ROUTE. | n/a |
TRIP_DELAYED | WARN | Projected ETA is more than DELAY_THRESHOLD_SEC past planned arrival. | Manual resolve. Raised once per trip. |
TRIP_COMPLETED | INFO | Reached the end. Hot state deleted. | n/a |
EXCEPTION | as set | Raised by a human through POST /exceptions with no alert_id. | Case closed. |
The delivery shape
Trip alerts are bridged onto gps-notifications, the platform's
existing inbox and FCM path, in the shape that path already expects.
{
"accountId": 11,
"alert_time": "27-08-2026 09:12:03", // UTC, dd-MM-yyyy HH:mm:ss
"alert_time_dt": 1756285923881,
"latitude": 12.9312, "longitude": 77.6221,
"message": "Vehicle off route (266m from corridor)",
"trip_id": 512, "trip_number": "TRP-...",
"alert_type": "ROUTE_DEVIATION",
"tags": { "IMEINumber": "...", "type": "trip_alert",
"event_type": "ROUTE_DEVIATION", "trip_id": 512 },
"payload": { ... }
}
notification-service filters on tags.type == "trip_alert",
resolves users by IMEI, applies their preference keys and delivers.
Stop alerts are worded exactly like the geofencing stream's: name first, sequence in brackets. The two services each notify for the stops the other cannot see, so an inbox holding both must not read like it came from two different products.
Topics, keys and ownership
Kafka topics
| Topic | Produced by | Consumed by | State in UAT |
|---|---|---|---|
location-packet-topic | every OEM integration | geofencing, route-trip-execution, notification-service, status-info | live |
trip.events | route-trip-mgmt outbox, route-trip-execution | route-trip-execution | must be created 6 partitions |
gps-notifications | route-trip-execution, geofencing | notification-service | live |
trip.dlq | route-trip-execution | nobody yet | must be created 1 partition |
The broker has
auto.create.topics.enable=true, so the two missing topics would
appear on first produce with broker defaults. That is not a blocker, it is a
silent decision about partition count. Six on trip.events so the
consumer can scale past one instance later. One on the dead letter queue,
because ordering there is worth more than throughput.
Consumer groups
| Group | Topic | Expected state |
|---|---|---|
trip-execution-location | location-packet-topic | Stable, 1 member |
trip-execution-trip-events | trip.events | Stable, 1 member |
Separate groups, so trip lag never blocks geofence consumption. Retries are
bounded by HANDLER_MAX_ATTEMPTS (3) with a
HANDLER_RETRY_BACKOFF_MS (250 ms) backoff, and anything that
exhausts them goes to trip.dlq.
Redis keys
| Key | Writer | Readers | Lifecycle |
|---|---|---|---|
fleetgo:trip:active:{IMEI} | route-trip-execution | execution, route-trip-mgmt, geofencing | Created at dispatch, updated per packet, deleted at complete or cancel. Holds the polyline, the simplified eval polyline, stops, checkpoints, runtime streaks and ETA. |
fleetgo:trip:notrip:{IMEI} | route-trip-execution | itself | 60 second negative cache so idle vehicles skip Postgres entirely. |
fleetgo:trip:seen_events:{event_id} | route-trip-execution | itself | 24 hour idempotency marker for trip.events. Returned on handler failure. |
geofence:* | geofencing | geofencing | Zone definition cache, per-vehicle mapping markers, and the enter and exit latch. Not owned by the trip stack. |
If Redis dies: execution rebuilds lost hot state from Postgres on the next packet, and the mgmt live views degrade to "no live position" rather than erroring. Neither service exits on a Redis error.
Feeds that land on the packet topic
The trip stack is strictly downstream of ingestion and never asks which OEM a packet came from. Dual-feed reconciliation, where two sources report the same IMEI, happens at the ingestion layer and none of its keys are read here.
Fields the trip stack actually uses: Latitude,
Longitude, Speed, CurrentDate plus
CurrentTime, the GPS fix flag, and tags.IMEINumber.
Who does what in the portal
One sidebar card, Trips, at /trip-dispatch. The old Route
Management and Trip Exceptions pages are gone. Everything is a mode of the same
card.
| Surface | URL | Who uses it |
|---|---|---|
| Ops map console | /trip-dispatch | Dispatcher, control room |
| Assign Trip pairing | button on the console | Dispatcher |
| Route Builder | /trip-dispatch?mode=builder | Route author, then an approver |
| Alerts and Cases | /trip-dispatch?tab=alerts | Support, ops lead |
| Trip detail | from the console | Anyone investigating one trip |
| Trip Events tab | dashboard | Ops lead, grant gated |
Dispatcher: get a vehicle on the road
- Open Trips. Full-bleed map, corridors coloured by health: blue is fine, amber is late, red is off corridor or has alerts, grey means no fix.
- Click Assign Trip. One search box across three numbered slots: 1 Route, 2 Vehicle, 3 Driver. Picking a route draws a dashed preview and fits the camera. Picking a vehicle auto-fills the driver from the active asset assignment.
- Press dispatch. The portal runs create, assign, checklist and dispatch as four
calls in sequence. A
409 ROUTE_NOT_APPROVEDmeans somebody edited the route since it was published; the panel refetches and shows the new version. - Watch the floating panel. Fleet view has counts and a needs-attention list with in-place acknowledge and resolve. Hovering a vehicle gives an ID card. Selecting one gives per-trip ops: warnings, cancel, a link to detail, and a lazy overlay with start and end pins, checkpoint dots and shaded no-go areas.
Route author and approver
- Open the builder. Search swaps between builder and catalogue; catalogue rows load routes editable, keeping endpoints, zones, corridor and mid shape points.
- Draw. Checkpoints come out as a lettered A, B, C series with a connector and place names resolved by geocoder, written into the zone name on save. Rider radius is fixed at 50 m.
- Preview. The builder shows the routed line before anything can be published, and refuses to publish checkpoints no vehicle can reach.
- Press Publish. One button: plan through OSRM, then approve. A
403from self-approval is caught and shown as "awaiting approval" rather than an error. - A second person opens the same route and presses Approve as-is. That
button is gated on the role tiers that hold
route:approve.
Driving route 101 over telemetry showed checkpoint A sitting 63 m off the carriageway with a 50 m radius, unreachable by driving the road, so the test vehicle had to dogleg through it. One no-go zone was 7.5 km away and could never fire. Zones drawn by eye on a map are not necessarily on the road. That is what the new reachability check is for.
Support and ops lead
- Alerts and Cases merges the old exceptions page. A case is an alert row with a case status, so promoting an alert does not copy it anywhere.
- Opening an alert shows what the vehicle was doing at the moment it fired, from the frozen vitals snapshot, plus how far into the trip it was.
- The
mobitrasupporttier can see and triage. It cannot dispatch and cannot approve.
Driver
The driver app reads GET /api/v1/driver/trips, which scopes to the
token subject, and posts actions to driver-events, or a whole batch
to /driver/sync when it has been offline. Every write carries an
idempotency key, because an offline-first client retries.
No websocket, no SSE. Both trip pages poll the API every 3000 ms and the events tab every 10 s. Kafka's job ends when the compute services materialise state into Postgres and Redis; the browser only ever reads those two. Postgres holds facts, Redis holds now.
UAT rollout, in order
Written for the two standalone services. The older runbook in
geofencing/UAT_DEPLOY.md describes the hosted-inside-geofencing
shape and should not be followed for these two.
| Host | e2e-101-183 / 216.48.184.183, Ubuntu 22.04.3, 16 vCPU |
| Disk | 123 G used, 58 G free (68%), filling about 0.7 G per day |
| Memory | 21 Gi used, 7.5 Gi available, down 2 Gi in a fortnight |
| Postgres | 12.22, not on this VM. 164.52.207.108:5432, database usermanagement, 44 public tables |
| Kafka | On the VM, confluentinc/cp-kafka:7.6.2, 98 topics, auto-create on |
| Redis | On the VM, redis:6379 on the shared network, no password |
| Network | One shared mobitra-uat-bridge-net, 172.18.0.0/16 |
| Port 8100 | free, nothing listening |
0. Once, on the VM
sudo mkdir -p /root/mobitra-uat-compose-files/apins/route-trip-mgmt \
/root/mobitra-uat-compose-files/kafka/route-trip-execution
Copy each repo's deploy/uat/docker-compose.yaml into its
directory, and put the secret in a .env beside the mgmt one:
TRIP_JWT_SECRET=<byte identical to spring-custm-os app.usermanagement.secretkey>
DATABASE_URL=postgresql://...@164.52.207.108:5432/usermanagement
Leave the image tags as REPLACE_ME. The first deploy rewrites
them.
CI only rewrites an image tag in a file that already exists. It never creates one. Installing the compose file on every deploy would silently discard anything an operator changed on the box; never installing it means a human has to copy a file before CI can work at all. Install-if-absent is the only rule that is safe in both directions, and it is what the job does.
Directory placement is meaningful. mgmt goes under apins/
because it serves HTTP. Execution goes under kafka/, the same shelf
as notification and status-info, because it is a pure consumer: no ports, no Kong
route, no nginx vhost. Nothing reaches it, it reaches the bus.
1. Kafka topics
sudo docker exec kafka kafka-topics --bootstrap-server localhost:9092 \
--create --if-not-exists --topic trip.events --partitions 6 --replication-factor 1
sudo docker exec kafka kafka-topics --bootstrap-server localhost:9092 \
--create --if-not-exists --topic trip.dlq --partitions 1 --replication-factor 1
Confluent packaging drops the .sh, so the binary is
kafka-topics. The kafka-topics.sh form in the older
geofencing runbook is the Apache distribution's name and comes back "not found"
on this image.
2. Schema, before any container boots
Run the migrate_uat job in route-trip-mgmt. It prints
migrate status, applies, then prints status again, so the job log is
the record of what the database now contains. Expect 5 migrations and 13 tables
on a fresh database.
20260826000000_trip_timestamps_timestamptz
rewrites 33 columns under an ACCESS EXCLUSIVE lock and kills live processes with
0A000 cached plan must not change result type. On an empty schema
that is free, which is the case here, and it is the reason to do it now rather
than later. It was verified on PostgreSQL 16 and UAT is 12.22. The migrations use
no construct newer than 12, which was checked, and timestamptz plus
AT TIME ZONE are far older than that. Expect it to pass, and read
the job output rather than assuming it did.
3. Deploy, execution first
build_image_uat then deploy_image_uat, in
route-trip-execution first, then route-trip-mgmt. Both
manual. Both gate on health and roll the image tag back on failure.
Execution first because it has no inbound surface. If it crash-loops, nothing else has changed.
4. Kong
A=http://localhost:8001
curl -s -X PUT $A/services/mobitra-uat-trip-service \
-d name=mobitra-uat-trip-service -d url=http://route-trip-mgmt-container:8100
curl -s -X PUT $A/routes/mobitra-uat-trip-route \
-d name=mobitra-uat-trip-route -d 'paths[]=/fleet-trip' -d strip_path=true \
-d 'protocols[]=http' -d 'protocols[]=https' -d service.name=mobitra-uat-trip-service
curl -s -X POST $A/routes/mobitra-uat-trip-route/plugins -d name=cors \
-d 'config.origins[]=https://web-uat.testmbtrsas.com' \
-d 'config.methods[]=GET' -d 'config.methods[]=POST' -d 'config.methods[]=PUT' \
-d 'config.methods[]=PATCH' -d 'config.methods[]=DELETE' -d 'config.methods[]=OPTIONS' \
-d 'config.headers[]=Content-Type' -d 'config.headers[]=Authorization' \
-d 'config.headers[]=Idempotency-Key' -d 'config.headers[]=X-Account-Id' \
-d config.credentials=true -d config.max_age=3600
curl -s -X POST $A/routes/mobitra-uat-trip-route/plugins -d name=jwt \
-d config.key_claim_name=iss -d 'config.claims_to_verify[]=exp' \
-d 'config.header_names[]=authorization' -d config.run_on_preflight=true
The service mounts at /api/v1/..., so
/fleet-trip must not be forwarded. Getting it wrong gives 404s that
read like the service is down.
The Kong service points at the container by name rather than at an nginx
hostname. The sibling services all go Kong, then nginx, then container, which is
a hop that buys nothing here and would need a new vhost baked into the nginx
image. nginx needs no change at all: proxy.testmbtrsas.com already
forwards every path to Kong.
Idempotency-Key must be in the CORS allowed headers or four
endpoints break from the browser and work from curl, which is a confusing bug to
chase.
5. Portal, last
REACT_APP_TRIP_CORE_URL=https://proxy.testmbtrsas.com/fleet-trip
CRA bakes environment variables into the bundle at build time, so a portal built before the Kong route exists is a portal pointing at a 404. Build after step 4, not before.
Falling back to localhost:8100 in a
deployed build meant every trip request went to the user's own machine and
failed silently. Outside development an unset variable now yields a relative URL,
which fails visibly against the portal origin.
Rollback
| What | How |
|---|---|
| Code | The deploy jobs already roll back on a failed health gate. By hand: put the previous tag back in the compose file and docker compose up -d. Image tags are pipeline ids and stay in the registry. |
| Schema | Usually unnecessary. The tables are additive and inert while nothing calls the API. prisma/uat-rollback.sql exists if it is needed. |
| Whole feature | Remove the Kong route. The portal loses the trip card and nothing else on the platform is touched. |
CI and CD jobs
Both repos build on branch UAT only, on the mobitra-uat
runner, and every job is when: manual.
| Job | Repo | Stage | What it does |
|---|---|---|---|
build_image_uat | both | build_uat | Guards that ARF_REG, P_GCP_PROJ_ID and U_ARF_BASE are non-empty, then builds and pushes <registry>/<project>/<base>/<GCR_NAME>:$CI_PIPELINE_ID. |
migrate_uat | mgmt only | migrate_uat | Runs prisma migrate status, then deploy, then status again, as a one-shot container that exits. Never from inside the running service, so a rollout cannot race a schema change. |
deploy_image_uat | both | deploy_uat | Installs the compose file if absent, writes .env if needed, records the previous tag, rewrites the image tag, docker compose up -d, then the health gate. |
An earlier note said a deploy job could not work because the
gitlab-runner account has no sudo and is not in the docker group.
That account is correct about itself and it is not who runs the job.
/etc/systemd/system/gitlab-runner.service starts the runner with
--user root, and it is a shell executor on the UAT VM itself.
Verified against a real job that printed
Preparing the "shell" executor and
Running on e2e-101-183, then built and pushed an image.
Two consequences. A shell executor ignores
image:, so the image line in geofencing's deploy job has never
meant anything, and these two repos do not declare one. And
cd /root/mobitra-uat-compose-files/... works, because root owns
it.
The health gates
route-trip-mgmt
Waits 15 s, then calls /health from inside the container and
greps for "auth_mode":"jwt". Anything else, including
header, fails the gate and rolls the tag back.
It uses node -e rather than curl or wget,
because node:20-bookworm-slim ships neither.
route-trip-execution
Waits 30 s, then inspects container state and restart count. There is no HTTP surface to probe, so a container that is up and not restarting is the signal, and the last 40 log lines are printed either way.
On a first deploy with nothing to roll back to, the job leaves the container running so its logs can be read.
Secrets
CI variables would be the tidier home, but mobitra_developer2 is a
Developer on these projects and the variables API answers 403, so they cannot be
set from the repo side. The VM .env file is therefore the primary
source and CI variables are the override if someone with Maintainer adds them
later. Either way the secret never enters git. If neither exists the job writes a
template with FILL_ME, tells you exactly what to put in it, and
exits 1.
Image build notes
npm ci, not--omit=dev. The Prisma CLI is a devDependency andgenerateneeds it. With--omit=dev, generate would not fail loudly: it would reach the network for a newer CLI major and produce a subtly wrong client in a build reporting success.- OpenSSL is installed explicitly. The slim image does not ship it and the Prisma engine then mis-detects libssl and dies with an unparseable "Could not parse schema engine response".
- The build asserts
@prisma/clientimports anddist/index.jsexists. An un-generated client imports fine and only dies when something queries, which in a container means a green start and a service that breaks on its first real call. CMDisnode dist/index.js, notnpm start.npm startwould run migrations, and the deploy applies them out of band on purpose.
Configuration reference
route-trip-mgmt
| Variable | Default | Notes |
|---|---|---|
PORT | 8100 | Not published on the UAT host. Kong reaches the container by name. |
NODE_ENV | development | production makes header auth mode refuse to start. |
DATABASE_URL | required | Percent-encode the password. Shared usermanagement database. |
REDIS_URL | redis://localhost:6379 | Read-only use. A blip degrades the live views, it does not error them. |
KAFKA_BROKER | localhost:9092 | Outbox publisher only. |
KAFKA_TRIP_EVENTS_TOPIC | trip.events | |
OUTBOX_POLL_MS | 2000, UAT sets 1000 | How fast a dispatch reaches execution. |
TRIP_AUTH_MODE | jwt | header is the local stub and refuses NODE_ENV=production. |
TRIP_JWT_SECRET | required for jwt | Byte identical to spring-custm-os app.usermanagement.secretkey. Raw string, not a base64 decode. |
TRIP_JWT_ISSUERS | empty | Comma list of accepted iss. Empty means do not check. |
TRIP_IDENTITY_TTL_SEC | 60 | Identity cache. Also the lag on a role change taking effect. |
TRIP_CORS_ORIGINS | empty | Comma list. * is refused under jwt auth, at boot. |
TRIP_TRUST_PROXY | false | True behind nginx and Kong so Express reads X-Forwarded-*. Must stay off when the service is directly reachable, or a caller can spoof its own address. |
TRIP_ALLOW_SELF_APPROVAL | unset | local rig only Disables separation of duty on approval. Never set it anywhere shared. |
OSRM_URL | http://localhost:5000 | UAT points at http://osrm-lucknow-container:5000. |
ROUTING_ENGINE | unset | Unset with a URL set means soft fallback. See the plan endpoint. |
ROUTING_FALLBACK_SPEED_MS | 8.33 (about 30 km/h) | Duration for straight-line geometry. |
GEOFENCE_URL | n/a | spring-custm-os base for zone creation. |
route-trip-execution
| Variable | Default | Notes |
|---|---|---|
DATABASE_URL | required | The only variable with no working default. This service owns no schema and must never run a migration. |
PG_POOL_MAX | 10 in UAT | |
KAFKA_BROKER | localhost:9092 | |
REDIS_URL | redis://localhost:6379 | Sole writer of the active-trip key. |
KAFKA_TRIP_EVENTS_TOPIC | trip.events | |
KAFKA_LOCATION_PACKET_TOPIC | location-packet-topic | |
KAFKA_GPS_NOTIFICATIONS_TOPIC | gps-notifications | |
KAFKA_DEAD_LETTER_TOPIC | trip.dlq | |
CORRIDOR_WIDTH_M | 100 | Fallback only. The real width comes from the pinned snapshot. |
DEBOUNCE_ENTER_COUNT / EXIT | 2 / 2 | Stop arrive and depart. |
CORRIDOR_BREACH_COUNT / OK | 3 / 3 | Deviation raise and clear. |
OFFLINE_GAP_MS | 900000 | Streak reset after a long silence. |
ALERT_COOLDOWN_MS | 300000 | Re-notification interval on an open alert. |
DELAY_THRESHOLD_SEC | 600 | |
EVAL_POLYLINE_TOLERANCE_DEG | 0.0001 | 0 disables simplification. |
NO_TRIP_CACHE_TTL_SEC | 60 | |
EVENT_ID_TTL_SEC | 86400 | Idempotency marker lifetime. |
HANDLER_MAX_ATTEMPTS | 3 | Then the dead letter queue. |
HANDLER_RETRY_BACKOFF_MS | 250 |
The UAT compose file leaves every tuning constant commented out and at its code default, deliberately.
Verification and smoke tests
In order. Stop at the first failure.
1. The containers
docker exec route-trip-mgmt-container node -e \
"fetch('http://localhost:8100/health').then(r=>r.text()).then(console.log)"
# expect: {"status":"ok","service":"trip-core","phase":"2-dispatch-api","auth_mode":"jwt"}
docker inspect -f '{{.State.Status}} restarts={{.RestartCount}}' route-trip-execution-container
# expect: running restarts=0
2. The consumer groups
docker exec kafka kafka-consumer-groups --bootstrap-server localhost:9092 \
--describe --group trip-execution-location
docker exec kafka kafka-consumer-groups --bootstrap-server localhost:9092 \
--describe --group trip-execution-trip-events
# expect both Stable with 1 member. Consumers take 15 to 20 s to join after a restart.
3. The schema
psql "$DATABASE_URL" -c "\dt route*"
psql "$DATABASE_URL" -c "select count(*) from route; select count(*) from trip;"
psql "$DATABASE_URL" -c \
"select column_name, data_type from information_schema.columns
where table_name='trip' and column_name like '%_start%';"
# expect: timestamp with time zone, not 'timestamp without time zone'
4. Through the gateway
curl -s -o /dev/null -w '%{http_code}\n' \
https://proxy.testmbtrsas.com/fleet-trip/api/v1/routes
# expect 401 without a token. A 404 here means strip_path is wrong.
curl -s -H "Authorization: Bearer $TOKEN" \
https://proxy.testmbtrsas.com/fleet-trip/api/v1/routes | head -c 300
# expect {"items":[...]}
5. One route, end to end
The portal path is: builder, draw, Publish, then a second user presses Approve as-is, then Assign Trip and dispatch. Watch for:
planned_via_osrm: trueon the plan response, if OSRM was provisioned. False means straight-line geometry got stored.zones_armedon the dispatch response matching what you drew.- The trip appearing in
GET /api/v1/trips/livewithmonitored: truewithin a couple of seconds. False means execution has not loaded it.
6. Scripted
| Script | What it proves |
|---|---|
route-trip-mgmt/scripts/guardrails-smoke.sh | 92 checks over the registration and runtime guardrails. Needs PSQL and REDIS_CLI in env, picks a free device itself. |
route-trip-mgmt/scripts/smoke-dispatch.sh | The create, assign, checklist, dispatch chain. |
route-trip-mgmt/scripts/softzone-e2e.sh | Dispatch plus an outside and an inside packet, then asserts alert rows, the geofencing publish and the notification log. |
local-dev/scripts/drive-route.js | Drives a real route over telemetry, firing every alert type. Measures where each zone actually sits along the road line first. |
geofencing/scripts/verify.sh | Clones HEAD, installs from the lockfile, runs every suite, builds the image. Refuses a dirty tree and enforces per-suite test-count floors. |
The single most useful habit from this work. A module
that existed on disk and not in git shipped once already and produced a container
that started, reported healthy, and ran no trips at all. That is why
verify.sh clones rather than tests in place, and why it enforces
test-count floors: a step that "passes" by running fewer tests is a red build.
That rule earned itself twice.
Troubleshooting
| Symptom | Most likely cause | Check |
|---|---|---|
| Every trip request 404s from the browser but works from inside the network | Kong strip_path is false | curl $A/routes/mobitra-uat-trip-route on the Admin API |
| Four endpoints fail from the browser, work from curl | Idempotency-Key missing from the CORS allowed headers | The Kong cors plugin config, and TRIP_CORS_ORIGINS |
| Container refuses to start, no request ever served | TRIP_JWT_SECRET empty, or TRIP_CORS_ORIGINS=* with jwt auth | Container logs. Both fail at boot on purpose rather than 500ing later. |
Health says "auth_mode":"header" | The environment block did not apply | This is a security regression. The CD job already rolls back on it. |
Trip is DISPATCHED but monitored: false | Execution never loaded it | Execution logs for the TRIP_DISPATCHED handler, then trip.dlq, then redis-cli get fleetgo:trip:active:<imei> |
| Dispatch returns 409 VEHICLE_BUSY on a vehicle that finished yesterday | A trip stuck at DISPATCHED or EN_ROUTE | The message names the offending trip. Cancel it. |
| Every trip raises TRIP_DELAYED at about 330 minutes | The naive-timestamp bug. The timestamptz migration did not apply. | information_schema.columns for the trip tables |
| Migration fails with P3009 | A FAILED row in the shared _prisma_migrations, possibly from notification-service | prisma migrate status before applying. The error names whichever service ran last, not necessarily the culprit. |
| Plan succeeds but the corridor is a straight line through buildings | OSRM down, or coordinates outside the loaded region | planned_via_osrm in the response, and route.osrm_meta.engine |
| Plan returns a route of 0 m | Out-of-coverage snapping | The coverage guard should have caught it. If it did not, check the guard is still in planning/osrm.ts. |
| Stop alerts never reach a phone | The stop has no geofence and the notify predicate drifted | tripExecutionNotifiesStop against the stream's admission test |
| Acknowledging an alert makes it vanish from the console | The feed asked for OPEN only | Send ?status=OPEN,ACKNOWLEDGED |
| A cancelled trip keeps alerting | The TRIP_CANCELLED event was dropped, so the hot state survived | Delete fleetgo:trip:active:<imei> by hand. Known open defect. |
| Route Builder cannot create a zone | spring-custm-os returned 404 with a business message | Read the response body. 404 is how spring answers every business failure. |
Local development
# everything: postgres, redis, kafka, all services
cd local-native && ./run-native.sh up
# drive a real route over telemetry, firing every alert type
node local-dev/scripts/drive-route.js --route 101 --ride-sec 200
Per service
# route-trip-mgmt
npm ci && npx prisma generate
npm run dev # tsx watch, :8100
npm test # tsx --test, unit plus live tests that self-skip
# route-trip-execution
npm ci && npm run build
npm start # node dist/index.js
npm test # builds first, then node --test over dist
Local auth
TRIP_AUTH_MODE=header
curl -H 'X-Account-Id: 11' -H 'X-User-Id: bf-local' localhost:8100/api/v1/routes
Header mode defaults roleType to SuperAdmin so the scripts keep
working. Pass X-Role-Type to exercise a narrower tier.
TRIP_ALLOW_SELF_APPROVAL=true is needed locally because the rig
creates and approves as the same user.
Portal
Login bf-local, account 11, Administrator. Structure changes need
a re-login, because the sidebar structure is cached in localStorage. The login
submit chain takes about 800 ms; do not navigate away early.
- OSRM needs Docker Desktop running. Its absence is the cause of a 500 from the Publish button, which reads like an application bug and is not.
- macOS AirPlay holds port 5000, which takes down the whole compose stack on
up. Turn AirPlay Receiver off, or move the port.
Things that look wrong and are not
Five decisions that read as sloppiness and are load-bearing. If you are going to push back on the design, push back here first.
1. The spatial primitive is duplicated on purpose
geofencing/point_in_polygon.js and
route-trip-execution/src/utils/point_in_polygon.ts are the same
maths. Two services in two repositories cannot import each other, so the copies
are real.
They were proved identical before being separated: a differential over
106,751 generated cases found zero disagreements. Both repos now carry
spatial_golden.json and replay it on every build, so a change to
either that alters a single answer fails the build. "Keep in sync" used to be a
comment. It is a test now.
2. The quirk corpus is a characterisation lock, not a specification
spatial_golden.json records 2297 cases of behaviour nobody
would design on purpose:
- A falsy radius never matches, even at the exact centre.
- Polygon boundaries are half-open and direction-dependent, while circles are inclusive.
- Object-shaped coordinates return false down one branch and throw down the other.
Each one decides which vehicles fire NO_GO_ENTRY and
CHECKPOINT_HIT on real fences that exist today. Changing one needs
its own commit, a before and after diff over the corpus, and the same change in
both repos.
If the corpus goes red, change the code back. Regenerating it to make a diff go away launders a behaviour change into the baseline, which is the one thing it exists to prevent.
3. The stop-notification predicate is a deliberate negation
tripExecutionNotifiesStop returns true when a stop's
geofenceId is not a positive integer. That looks like an
inverted condition. It is the exact negation of the geofencing stream's
admission test, and that is what guarantees one notifier per stop and no double
delivery.
Change one and the other must change with it. Drift one way is a silent drop, the other a duplicate.
4. HARD and SOFT do not change routing
They are runtime severity and nothing else. HARD maps to
CRITICAL, SOFT to WARN. Same detection,
same edge trigger, same cooldown. The planner does not know no-go zones exist and
does not route around either kind. If someone expects avoidance, that is a
feature request, not a bug.
5. TRIP_CODE_ROOT is inert
Still set in geofencing's Dockerfile and compose examples. Nothing reads it. Removing it touches deploy config for no gain, so it stays until something else is being changed there anyway.
Deliberately not done
| Not done | Why |
|---|---|
| Merging the two evaluators. The geofencing stream and trip execution both decide zone membership. They should be one. | The evaluator is 637 lines with zero tests and the stream is 919 lines nothing requires. And they already disagree six ways that are product decisions, not refactors: one fires on the first packet and the other needs two; a NULL-unit fence reads as kilometres to one and metres to the other, a 1000x difference on live rows; one hard-requires a GPS-fix flag that Sun MT013 never sends; and NO_GO_EXIT exists on only one side. The way in is to replay recorded packets through both and diff the emitted events, which is the technique that made the primitive merge provable. |
| Unifying the three coordinate normalisers. | Not proved equivalent, and two already disagree on the two-vertex case. |
| The cross-repo timestamp test. | It drove both writers in one process, because a test using only one proves nothing: node-postgres is self-consistent and would pass even if the columns reverted. It cannot live in one repo. What replaced it asserts the schema directly. The real test has no home and no runner. |
Deleting trip-core/ and trip-execution/ from geofencing. | Only after the new services actually run in UAT and TRIP_HOSTED_IN_GEOFENCING=0 is set. The local rig boots through hosted mode, and it is the working fallback if the new containers misbehave. |
| Snapshotting the no-go list and checkpoint definitions. | Known gap, deliberately deferred. Corridor width is pinned from the snapshot; zone definitions are still read live. Closing it means reading snapshot.no_go_zones[] and snapshot.checkpoints[] instead. |
Defects, fixed and open
Fixed
| Defect | Consequence it had |
|---|---|
Batch consumer took slice(0,1) while kafkajs auto-resolved the whole batch | Bursts of alerts dropped with no log line |
timestamp: new Date() in the winston defaultMeta, evaluated once at require | Every log line showed process start time |
| Naive timestamps, two writers with two conventions | TRIP_DELAYED on every trip, about 330 minutes |
| Stop events deferred to a stream that could not see the stop | Arrival and departure alerts reached nobody, for 132 of 144 stops |
| Flat metadata overwrite on re-upsert | Deviation position was the recovery point, not the breach. 124 m stored where the real breach was 266 m. |
Alert feed asked for OPEN only | Acknowledging deleted the alert from the console permanently |
| Trip detail fetched once while warnings polled | One card showed two different ages |
Stops counted only DEPARTED | Completed trips read one stop short of their own total |
evaluation/index.ts imported a module never committed | geofencing HEAD could not boot |
tsx declared but absent from the lockfile | npm ci failed; the image could not build at all |
publishTripEvent returned quietly with no broker | Outbox row marked published. Event destroyed, no retry. |
EditControl stale closure in the builder | Every drawn zone took the first zone's name |
Seed re-created geofence.trip_meta on every rig start | Local database diverged from UAT |
PATCH could write status: APPROVED | Approval gate, separation of duty and the version freeze all bypassed |
| Dispatch minted a route version when none existed | A trip could run against a template nobody approved |
| Cancel blocked on DISPATCHED and EN_ROUTE | In-flight trips had no way out; the vehicle stayed occupied forever |
| SCHEDULED was not an assignable status | A trip created SCHEDULED could never be assigned |
| Snapshot embedded previous versions | Multiplicative snapshot bloat on every approval |
Open, flagged not fixed
| Defect | Impact | Why it is still open |
|---|---|---|
NO_GO_EXIT published 3 times, delivered 0 times | medium Nobody learns a vehicle left a restricted zone | Suspected severity or type gate in notification-service. That repo is 404 for our account. |
A dropped TRIP_CANCELLED leaves a zombie trip alerting forever | high Alerts on a trip nobody is running | Nothing re-checks trip status per packet. Needs a TTL on the hot state or a periodic status re-read. Pre-existing. |
Acknowledging a live ROUTE_DEVIATION re-raises on the next packet | medium Alert fatigue | The dedup key is still open, so the cooldown path re-notifies. Needs an acknowledged-suppresses-renotify rule. |
alert_time: "Invalid date" on the geofencing account path | medium Affects 100% of alerts on that path | moment(null). Fix lives in the 404 repo. |
| Committed GCP service-account keys in notification-service | high Credentials in git history | Rotation and history purge needs whoever owns the projects. |
| A GitLab PAT in plaintext across 22 local checkouts | medium | Worth rotating. Not caused by this work. |
Kong Admin API on :8001 is unauthenticated and publicly bound | high | Pre-existing, not caused by this work, still worth closing. |
web-portal is about 44 commits behind origin/dev, 4 known conflicts | low | Deliberate. Merge after review. |
Found while writing this
Three things surfaced during this pass that are not in any earlier document.
All verified against the code on branch UAT.
route-trip-mgmt/tests/osrm-coverage-guard.test.ts exists, has
three cases, and passes. It is not in the npm test script.
The script names five files explicitly and this one, added by the most recent
commit, was never added to the list.
$ npx tsx --test tests/osrm-coverage-guard.test.ts
ok degenerate Ok answer falls back to straight line
ok degenerate Ok answer is a hard error under ROUTING_ENGINE=osrm
ok a sane answer passes through untouched
# pass 3 fail 0
This is exactly the class of failure the test-count floors exist to catch: a suite that "passes" while covering less than it did. The coverage guard it tests is the thing standing between a city-scoped OSRM and a stored zero-length corridor. Add it to the script.
src/modules/exception/router.ts is imported by nothing at all.
app.ts mounts exception/routes.ts instead. The two
files define overlapping exception endpoints with different behaviour, so anyone
reading the dead one will describe the API wrongly.
src/modules/trip/router.ts exports both
tripRouter and driverRouter, and only
driverRouter is imported. Its tripRouter duplicates
four endpoints that trip/routes.ts already serves. Verified:
grep -rn "exception/router" src/ returns nothing, and the only
import from trip/router is driverRouter.
docs/TRIP_STACK_CHEATSHEET.md is dated 21 August and describes
the pre-split shape. It still says geofence.trip_meta is stamped at
dispatch (the column is gone), that the trip stack rides geofencing's pipeline
(it has its own two repos and its own CI), and lists "PATCH can set status
directly" as an accepted gap (it is fixed and returns 409). Anyone handed that
document as an introduction will form a wrong model.
It should carry a superseded banner pointing at the handoff and at this page. See the document map.
What needs DevOps
Shorter than it was. Three of the six items previously listed as blockers turned out not to be.
Still genuinely needed
| # | Thing | Why it cannot be automated away |
|---|---|---|
| 1 | Developer access to mobitra/geofencing and mobitra/notification-service for mobitra_developer2 (user id 21950467) | Both 404. 43 and 9 commits sit on a laptop, including the alert-delivery fixes. |
| 2 | Two compose directories on the VM, with a .env beside the mgmt one | CI only rewrites an image tag in a file that already exists. It never creates one. |
| 3 | TRIP_JWT_SECRET | Must be byte identical to spring-custm-os app.usermanagement.secretkey, raw string not base64. It is in no repo we can read. Everything else can be prepared without it; the mgmt container cannot start without it. |
| 4 | UAT_DATABASE_URL as a masked CI variable on route-trip-mgmt | So the migrate job does not carry a password in a file. Optional: the VM .env works too. |
| 5 | Two Kafka topics | One command. Explicit beats auto-create, so partition count is a decision. |
| 6 | Kong service and route | One command block. Execution needs none. |
| 7 | Portal build after Kong exists | REACT_APP_TRIP_CORE_URL is compiled into the bundle. |
No longer blockers
| Previously listed | Actual state |
|---|---|
| "The runner cannot deploy, no sudo, not in the docker group" | The runner starts with --user root and is a shell executor on the UAT VM. Real CD is available today. |
"CI variables ARF_REG, P_GCP_PROJ_ID, U_ARF_BASE need adding" | Inherited. A build already succeeded and pushed an image. |
| "Kafka topics need creating by DevOps" | Auto-create is on, so they would appear anyway. Creating them explicitly is a choice about partition count, not a dependency. |
Open questions to answer before the schema step
- What is
TRIP_JWT_SECRET? Someone with the spring-custm-os config has to hand it over. - Does anyone object to
_prisma_migrationsgaining trip rows? It is safe, and it is still the one shared object. The owner of notification-service should hear it from us rather than from a warning. - Where does the OSRM repo live?
osrm-india/is not a git repository at all: no remote, no CI, one laptop. And the gateway has no authentication of any kind, which is correct on a private docker network and completely wrong for anything with a public address.
The recommendation is one small VM in GCP asia-south1, reached
over the internet by IP allowlist plus a bearer token. asia-south1
because the Artifact Registry and the prod GKE cluster are already there, so when
prod needs routing it reaches the same box over the VPC with no internet hop.
The VPN is not in the path. Nothing external needs to
reach into the VM; the call goes outward, from one source address. Serving is
cheap: a 304 MB Bangalore dataset served from 48 MB resident, because
osrm-routed --mmap pages in only what is touched. Building is
the expensive part and it is a different machine problem. Do not size the serving
box for the build.
With OSRM_URL set and
ROUTING_ENGINE unset, an OSRM outage silently degrades to straight
lines. A route published during that window keeps straight-line geometry
permanently, and the corridor evaluator then judges real vehicles against a line
that ignores the road. Either watch planned_via_osrm on published
routes during that window, or move to ROUTING_ENGINE=osrm
quickly.
Document map
What to read for what, and which documents have gone stale.
| Question | Source | State |
|---|---|---|
| Everything, for a reviewer | this page | current |
| Why does a route behave that way? | ~/.claude/plans/trm-registration-and-runtime-quirky-frost.md | the spec decisions there override later prompts |
| The whole feature across five repos | docs/TRIP_PLATFORM_HANDOFF.md | current 2026-08-27 |
| UAT health, rollout and where OSRM lives | docs/UAT_TRM_ROLLOUT_AND_OSRM.md | current 2026-08-27 |
| Schema and the publish contract | docs/TRIP_SCHEMA.md | current |
| What env does the container need? | each repo's .env.example and deploy/uat/docker-compose.yaml | current |
| Is this change safe? | geofencing/scripts/verify.sh | current |
| Did the zone maths change? | spatial_golden.json, both repos | current |
| What does a full trip look like? | local-dev/scripts/drive-route.js | current |
| Mental model of the stack | docs/TRIP_STACK_CHEATSHEET.md | stale pre-split, still describes trip_meta |
| How do I deploy? | geofencing/UAT_DEPLOY.md | stale for these two services describes hosted-in-geofencing |
| Datastore map | docs/TRM_DATASTORE_MAP.html | stale two of its findings are now fixed, trip_meta is gone |