Trip & Route Management Runbook and review guide
rev 2026-08-27
Nothing matches that filter.

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.

2new services
45HTTP endpoints
13owned tables
11alert types
5repos touched

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.

Deployment shape

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Finish on the defect ledger and what this pass found. Six items are still open and named, including one that has no owner yet.
Three questions worth asking
  • 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_CANCELLED leaves a zombie trip alerting forever, because nothing re-reads trip status per packet. Pre-existing, still open.
  • NO_GO_EXIT is published three times and delivered zero times. The fix lives in a repo we cannot push to.

What the platform does

The objects

ObjectWhat it isTable
Operational stopA reusable place in a catalogue: a depot, a customer site, a swap station. Has coordinates, optionally a geofence.operational_stop
RouteThe template. Corridor geometry, a width in metres, ordered stops, checkpoints, no-go zones, free-form restrictions.route, route_stop, route_zone
Route versionA frozen publish of a route: the full snapshot plus geometry, stamped with who approved it and when. Immutable.route_version
TripOne vehicle, one driver, one frozen route version, one run. Carries the checklist and the lifecycle status.trip
Trip zoneThe trip's own copy of each stop and checkpoint, with runtime progress: planned versus actual arrival, hit time, status.trip_zone
AlertSomething 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.

StatusSet byMeans
DRAFTcreateCreated, not scheduled. Assignable.
PLANNEDcreate (default)Ready to be assigned.
SCHEDULEDcreateHas a scheduled start. Assignable.
ASSIGNEDPOST /trips/:id/assignVehicle and driver attached. The only status dispatch accepts.
DISPATCHEDPOST /trips/:id/dispatchMonitoring is armed. Redis hot state exists. Execution owns the trip from here.
EN_ROUTEtrip-execution, first packetThe vehicle has actually moved.
COMPLETEDtrip-executionReached the end. Hot state deleted. Terminal.
CANCELLEDPOST /trips/:id/cancelStopped 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

StatusMeans
DRAFTEditable. Cannot carry a new trip.
APPROVEDHas a frozen current_version_id. Trips can be created against it. Still editable, but any structural edit demotes it back to DRAFT.
ARCHIVEDRead only. Every write returns 409 ROUTE_ARCHIVED.

Services and repos

RepoOwnsRuntimePush accessState
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
Access gap

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

ConcernOwnerWhy there
Route and trip CRUD, approval, dispatchroute-trip-mgmtIt owns the schema and the state machine.
Corridor deviation, ETA, delay, missed checkpointsroute-trip-executionContinuous geometry on a polyline. Nothing else has the frozen snapshot in memory.
Checkpoint hit, no-go entry, stop arrive and depart notificationsgeofencing streamIt already does enter and exit edge detection for every geofence on the platform.
Stop arrive and depart notifications for stops with no geofenceroute-trip-executionThe stream structurally cannot see them. See the negated predicate.
Delivery to a phonenotification-serviceOwns FCM tokens and per-user preferences.
Geofence row creationspring-custm-osThe 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

web-portal React, polls 3s OEM integrations Sun, Intellicar, Ather... route-trip-mgmt HTTP :8100 + outbox owns the schema Kafka location-packet-topic trip.events gps-notifications trip.dlq route-trip-execution consumer, no HTTP corridor, ETA, zones geofencing stream zone enter and exit notification-service FCM and inbox Postgres facts Redis trip:active {IMEI} REST outbox write read only, live views read solid orange: the trip stack's own path grey: shared platform paths that existed already
The portal only ever talks to route-trip-mgmt over REST. Kafka never reaches the browser. Postgres holds facts, Redis holds now.

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:

RoleWhoWhat for
Only writerroute-trip-executionLoads it at dispatch, updates it on every packet, deletes it at complete or cancel.
Readerroute-trip-mgmtThe live views. Position, ETA, corridor state, progress, without touching the telemetry warehouse.
Readergeofencing streamTo 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.

Why the live views do not use Influx

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.

Isolation, measured not assumed

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.

The one shared object

_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

ColumnTypeNotes
route.current_version_idint, nullableThe 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_mreal, default 100Metres either side of the line. Snapshotted at approval.
route_zone.no_go_typevarchar(10)HARD or SOFT. Runtime severity only, see below.
route_zone.geofence_idint, nullableScalar FK with no constraint. geofence is not trip-owned.
trip.vehicle_imeivarcharDenormalised from device at assign. Everything downstream keys on IMEI.
trip.checklistjsonb{status, items[], completed_by, completed_dt}. Must be PASSED before dispatch.
trip_assignment.kindvarchar(20)vehicle or driver. One table, partial unique on (trip_id, kind) WHERE status='ACTIVE'.
alert.dedup_keyvarchar(255)Partial unique on (account_id, alert_type, dedup_key) WHERE status='OPEN', created as raw SQL in migration 20250811010000.
alert.metadatajsonbCarries snapshot, the vehicle vitals at the moment of the breach. Write once.
alert.case_statusvarchar(30), nullableNon-null promotes the alert into the Exceptions view. Same row, two lenses.
Prisma cannot see the partial unique index

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.

Deploy gate

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

EnvironmentBaseNotes
Local righttp://localhost:8100Header auth stub is allowed here and nowhere else.
UAT, in clusterhttp://route-trip-mgmt-container:8100Kong reaches it by container name on mobitra-uat-bridge-net. Port 8100 is deliberately not published on the host.
UAT, from a browserhttps://proxy.testmbtrsas.com/fleet-tripKong 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.

X-Account-Id is not identity any more

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.

Kong is not treated as a guarantee

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.

Tiertrip: create / assign / dispatch / cancel / checklistroute: create / editroute: approvealert & exceptionconfig
superadminyesyesyesyesyes
mobitraadminyesyesyesyesyes
administratoryesyesyesyesyes
otheryesyesnoyesno
mobitrasupportnononoyesno
externalnonononono
Why "other" cannot approve

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/trips
  • POST /api/v1/trips/:id/dispatch
  • POST /api/v1/trips/:id/driver-events
  • POST /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.

SituationResult
New keyRequest runs. Response body cached on success.
Same key, same body, original finished200 with the cached body, handler not re-run.
Same key, same body, original still running409 IDEMPOTENCY_IN_PROGRESS
Same key, different body409 IDEMPOTENCY_BODY_MISMATCH
Same key, different account409 IDEMPOTENCY_CONFLICT
Original failedClaim 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.

MethodPathPermissionPurpose
GET/healthnoneLiveness plus the active auth mode
POST/api/v1/stopsauthenticatedCreate a catalogue stop
GET/api/v1/stopsauthenticatedList stops
GET/api/v1/stops/:idauthenticatedOne stop
PATCH/api/v1/stops/:idauthenticatedEdit or deactivate a stop
POST/api/v1/routesroute:createCreate a route shell
GET/api/v1/routesauthenticatedList routes with publish state
GET/api/v1/routes/:idauthenticatedFull route with zones and stops
PATCH/api/v1/routes/:idroute:editEdit metadata, archive, demote
PUT/api/v1/routes/:id/waypointsauthenticatedReplace the shape points
PUT/api/v1/routes/:id/stopsauthenticatedReplace the ordered stop list
PUT/api/v1/routes/:id/checkpointsroute:editReplace checkpoints
PUT/api/v1/routes/:id/no-go-zonesroute:editReplace no-go zones
PUT/api/v1/routes/:id/restrictionsroute:editReplace the restrictions blob
PUT/api/v1/routes/:id/geometryauthenticatedSet geometry directly
POST/api/v1/routes/:id/planauthenticatedRoute through OSRM, store geometry
POST/api/v1/routes/:id/approveroute:approveFreeze a version, publish
POST/api/v1/trips/templatesauthenticatedCreate a trip template
GET/api/v1/trips/templatesauthenticatedList active templates
POST/api/v1/tripstrip:createCreate a trip on an approved route
GET/api/v1/tripsauthenticatedList trips for the board
GET/api/v1/trips/liveauthenticatedLive state for every active trip
GET/api/v1/trips/:idauthenticatedFull trip detail
GET/api/v1/trips/:id/liveauthenticatedOne trip's live position and progress
GET/api/v1/trips/:id/eventsauthenticatedPer-trip audit timeline
PATCH/api/v1/trips/:id/stops/:stopIdauthenticatedSet planned times before dispatch
PATCH/api/v1/trips/:id/checkpoints/:cpIdauthenticatedSet checkpoint status before dispatch
POST/api/v1/trips/:id/driver-eventsdriver or dispatcherDriver app action on a trip
POST/api/v1/trips/:id/assigntrip:assignAttach vehicle and driver
POST/api/v1/trips/:id/reassigntrip:assignSwap vehicle or driver, including mid-trip
POST/api/v1/trips/:id/checklisttrip:checklistPass or fail the pre-trip checklist
POST/api/v1/trips/:id/dispatchtrip:dispatchArm monitoring, emit TRIP_DISPATCHED
POST/api/v1/trips/:id/canceltrip:cancelStop a trip and free the vehicle
GET/api/v1/alertsauthenticatedAlert feed for the console
GET/api/v1/alerts/:idauthenticatedOne alert with vitals and elapsed time
POST/api/v1/alerts/:id/acknowledgeauthenticatedAcknowledge
POST/api/v1/alerts/:id/resolveauthenticatedResolve
GET/api/v1/exceptionsauthenticatedAlerts promoted to cases
POST/api/v1/exceptionsauthenticatedPromote an alert, or raise one by hand
GET/api/v1/exceptions/:idauthenticatedOne case
PATCH/api/v1/exceptions/:idauthenticatedOwn, triage, close
GET/api/v1/devicesauthenticatedDispatchable vehicles with their driver
GET/api/v1/eventsauthenticatedAccount-wide trip event feed
GET/api/v1/driver/tripsauthenticatedThe caller's own trips
POST/api/v1/driver/syncauthenticatedBatch 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.

Worth knowing

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.

Careful

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.

The demotion rule

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
} ] }
published_version, not current_version_id

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_REQUIREDSending status: "APPROVED". Use the approve endpoint.
400 VALIDATION_ERRORAny status other than DRAFT or ARCHIVED.
409 ROUTE_ARCHIVEDThe route is archived. All writes are refused.
404 NOT_FOUND
Why PATCH cannot approve

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.

Replacement detaches trip zones

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 and SOFT are severity, not routing

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.

planned_via_osrm is the field to watch

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.

The coverage guard

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_ENGINEOSRM_URLBehaviour
unsetunsetStraight line, haversine distance, about 30 km/h. Never errors.
unsetsetTry OSRM, fall back to straight line on failure. This is what UAT ships.
osrmsetOSRM failures are hard errors.
straightlineanythingSkip 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_FOUNDNo such route in this account.
409 ROUTE_NOT_PLANNEDNo geometry. Plan it first.
409 ROUTE_NO_ENDPOINTSFewer 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_FORBIDDENThe caller created the route. Separation of duty.
403 FORBIDDENThe caller's tier lacks route:approve.
TRIP_ALLOW_SELF_APPROVAL

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.

Snapshot contents

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
  1. Pins route_version_id from the route's current_version_id.
  2. Expands every route_stop into a trip_zone with role stop, status PENDING.
  3. Expands every checkpoint route_zone into a trip_zone with role checkpoint.
  4. Seeds the checklist, from the template if there is one, otherwise the default items.
  5. Writes an audit_event and an outbox row for TRIP_CREATED.
Returns

201 with the full trip including its zones.

Errors
409 ROUTE_NOT_APPROVEDRoute 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_STATUSAsked for a status later than SCHEDULED. A trip born DISPATCHED never emits the dispatch event, so execution would never know about it.
400 VALIDATION_ERRORNo 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_REQUIREDNo 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 }
} ] }
Two details that bit before
  • /live is declared before /:id. Express matches in order, and live would otherwise be captured as a trip id and fail Number().
  • stops_done counts ARRIVED as done, not only DEPARTED. 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
TypeEffectAllowed when
START_TRIPMarks the trip started from the driver side.Only DISPATCHED. Allowing ASSIGNED skipped the dispatch gate entirely.
STOP_ARRIVEManual arrival at a stop.Driver-writable statuses.
STOP_DEPARTManual departure.Driver-writable statuses.
CHECKLIST_COMPLETEMarks the checklist done from the app.Any status. This is the one exception to the status gate.
SOSRaises an alert.Driver-writable statuses.
EXPENSERecords 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
CheckKind
Device exists in this accountblocking
Device is activeblocking
Asset row exists for the deviceblocking
An ACTIVE asset_assignment links this driver to this devicesoft, 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_assignment row REPLACED and writes a new ACTIVE one.
  • Keeps the denormalised trip columns in sync.
  • Emits TRIP_REASSIGNED carrying old_imei, imei and vehicle_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 DISPATCHED200 with "Already dispatched". Not an error.
409 INVALID_STATUSNot ASSIGNED.
409 NOT_ASSIGNEDNo device or no driver.
409 DISPATCH_BLOCKEDRe-validation failed. The assignment can go stale between assign and dispatch: device decommissioned, driver unassigned.
409 CHECKLIST_REQUIREDChecklist is not PASSED.
409 NO_ROUTETrip has no route.
409 ROUTE_NOT_APPROVEDNo frozen version to run. Dispatch never mints one.
409 ROUTE_ZONE_INVALIDA no-go zone with no geofence, or a checkpoint with no geofence or detached by a later route edit.
409 VEHICLE_BUSYAnother DISPATCHED or EN_ROUTE trip already holds this IMEI.
One live trip per vehicle

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.

Zones armed, checked at dispatch

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.

Why the IMEI is in the payload

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
Ask for both statuses

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 snapshot is write once

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.

Open behaviour

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

Same table, second lens

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" }
auth_mode is the deploy gate

"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.

CodeHTTPMeaning and what to do
UNAUTHORIZED401Missing, malformed or expired token; or a valid signature whose subject is not an active platform user. Deliberately does not say which.
CONSUMER_MISMATCH401Kong's forwarded consumer does not match the token subject. Misconfigured gateway route.
INVALID_ACCOUNT400X-Account-Id is not a positive integer.
FORBIDDEN403The caller's role tier lacks the permission. The message names the tier and the permission.
SELF_APPROVAL_FORBIDDEN403A route must be approved by someone other than its author.
NOT_FOUND404No such row in the caller's effective account. Cross-tenant rows read as absent, never as forbidden.
VALIDATION_ERROR400Bad or missing body field. The message names it.
INVALID_REF_DATA400A trip_type or stop_type that is not in ref_data.
GEOFENCE_INVALID400A zone references a geofence that does not exist, belongs to another account, or has no usable shape.
APPROVAL_REQUIRED409Tried to set status: APPROVED through PATCH.
ROUTE_ARCHIVED409Writing to an archived route.
ROUTE_NOT_PLANNED409Approving a route with no geometry.
ROUTE_NO_ENDPOINTS409Approving a route with no start and end.
ROUTE_NOT_APPROVED409Creating or dispatching a trip on a route that is not published. Refetch the route: it was probably demoted by an edit.
ROUTE_ZONE_INVALID409A zone on this trip cannot be monitored. Fix the route, re-approve, create a new trip.
INVALID_STATUS400 / 409The transition is not legal from the current status.
INVALID_STATE409A driver event that the trip's status does not allow.
EXECUTION_OWNED409Human tried to write a trip_zone status after dispatch.
ASSIGNMENT_BLOCKED400Hard validation failure on device or driver.
DISPATCH_BLOCKED409Re-validation at dispatch failed. Something went stale since assign.
CHECKLIST_REQUIRED409Checklist is not PASSED.
NOT_ASSIGNED409No device or driver on the trip.
NO_ROUTE409Trip has no route.
NO_CHANGE400A reassignment that changes nothing.
VEHICLE_BUSY409Another live trip holds this IMEI. The message names it.
ALERT_RESOLVED409Acknowledging an already resolved alert.
IDEMPOTENCY_REQUIRED400Missing Idempotency-Key.
IDEMPOTENCY_IN_PROGRESS409The first request with this key is still running. Do not start a second.
IDEMPOTENCY_BODY_MISMATCH409Key reused with a different body.
IDEMPOTENCY_CONFLICT409Key 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.

  1. Draw the zones. In the Route Builder, each circle or polygon becomes a real geofence row, 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 404

    Not 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.

  2. Create the route and attach everything. POST /routes, then the full-replace PUTs for waypoints, stops, checkpoints and no-go zones. Status is DRAFT throughout.
  3. Plan. POST /routes/:id/plan asks OSRM for a road-snapped line and stores geometry, distance and duration. Check planned_via_osrm. The builder now previews the routed line before anything can be published.
  4. Approve. POST /routes/:id/approve freezes a route_version and sets current_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.
  5. Create the trip. POST /trips with an idempotency key. It pins the version and expands stops and checkpoints into trip_zone rows. Status PLANNED.
  6. Assign. POST /trips/:id/assign attaches vehicle and driver, denormalises the IMEI, records assignment history. Status ASSIGNED.
  7. Checklist. POST /trips/:id/checklist with status: PASSED and every required item checked.
  8. 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: status DISPATCHED, an audit row, and an outbox row for TRIP_DISPATCHED.
  9. The outbox publisher picks it up. A 1 to 2 second poll reads unpublished rows, wraps each in an envelope and produces to trip.events partitioned by account_id:trip_id, then marks the row published. Publish failures leave the row unpublished so the next poll retries.
  10. Execution activates the trip. It consumes TRIP_DISPATCHED, loads the trip from Postgres bound to the account the event claims, and writes fleetgo:trip:active:{IMEI}. From here every packet for that IMEI is evaluated.
  11. Packets flow. First packet sets EN_ROUTE and emits TRIP_STARTED. Then corridor, no-go, checkpoints, stops and ETA, in that order, on every packet. See below.
  12. Complete. When the trip reaches its end, execution sweeps any required checkpoint never reached into CHECKPOINT_MISSED, marks the trip COMPLETED, emits TRIP_COMPLETED and 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.

Consumer idempotency

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.

SettingDefaultControls
CORRIDOR_BREACH_COUNT3Consecutive outside-corridor packets before ROUTE_DEVIATION.
CORRIDOR_OK_COUNT3Consecutive inside packets before it resolves.
DEBOUNCE_ENTER_COUNT2Packets inside a stop before ARRIVED.
DEBOUNCE_EXIT_COUNT2Packets outside before DEPARTED.
OFFLINE_GAP_MS900000 (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_MS300000 (5 min)Minimum gap between re-notifications on an alert that is still open.
DELAY_THRESHOLD_SEC600Seconds past planned arrival before TRIP_DELAYED.
EVAL_POLYLINE_TOLERANCE_DEG0.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_SEC60How long an IMEI is remembered as having no active trip before Postgres is asked again.
Do not tune these casually

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.

AlertDedup 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.

EventAlert row written byPushed to the phone by
ROUTE_DEVIATION, ROUTE_DEVIATION_RESOLVEDexecutionexecution
TRIP_DELAYEDexecutionexecution
CHECKPOINT_MISSEDexecutionexecution
TRIP_STARTED, TRIP_COMPLETEDnoneexecution
NO_GO_ENTRYexecution (deduped row)geofencing stream
CHECKPOINT_HITexecutiongeofencing stream
STOP_ARRIVED, STOP_DEPARTED, stop has a geofenceexecutiongeofencing stream
STOP_ARRIVED, STOP_DEPARTED, stop has no geofenceexecutionexecution
The stop notification gap that reached nobody

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

TypeSeverityRaised whenClears
ROUTE_DEVIATIONWARNDistance 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_RESOLVEDINFOBack inside the corridor.n/a, it is the clear.
NO_GO_ENTRYCRITICAL 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_HITINFOPoint enters a checkpoint zone.n/a, it is a fact not a problem.
CHECKPOINT_MISSEDWARNA 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_ARRIVEDINFODEBOUNCE_ENTER_COUNT packets inside the stop.n/a
STOP_DEPARTEDINFODEBOUNCE_EXIT_COUNT packets outside after arriving.n/a
TRIP_STARTEDINFOFirst packet after dispatch. Sets EN_ROUTE.n/a
TRIP_DELAYEDWARNProjected ETA is more than DELAY_THRESHOLD_SEC past planned arrival.Manual resolve. Raised once per trip.
TRIP_COMPLETEDINFOReached the end. Hot state deleted.n/a
EXCEPTIONas setRaised 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.

Message wording is shared on purpose

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

TopicProduced byConsumed byState in UAT
location-packet-topicevery OEM integrationgeofencing, route-trip-execution, notification-service, status-infolive
trip.eventsroute-trip-mgmt outbox, route-trip-executionroute-trip-executionmust be created 6 partitions
gps-notificationsroute-trip-execution, geofencingnotification-servicelive
trip.dlqroute-trip-executionnobody yetmust be created 1 partition
Create the topics explicitly

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

GroupTopicExpected state
trip-execution-locationlocation-packet-topicStable, 1 member
trip-execution-trip-eventstrip.eventsStable, 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

KeyWriterReadersLifecycle
fleetgo:trip:active:{IMEI}route-trip-executionexecution, route-trip-mgmt, geofencingCreated 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-executionitself60 second negative cache so idle vehicles skip Postgres entirely.
fleetgo:trip:seen_events:{event_id}route-trip-executionitself24 hour idempotency marker for trip.events. Returned on handler failure.
geofence:*geofencinggeofencingZone 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.

SurfaceURLWho uses it
Ops map console/trip-dispatchDispatcher, control room
Assign Trip pairingbutton on the consoleDispatcher
Route Builder/trip-dispatch?mode=builderRoute author, then an approver
Alerts and Cases/trip-dispatch?tab=alertsSupport, ops lead
Trip detailfrom the consoleAnyone investigating one trip
Trip Events tabdashboardOps lead, grant gated

Dispatcher: get a vehicle on the road

  1. 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.
  2. 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.
  3. Press dispatch. The portal runs create, assign, checklist and dispatch as four calls in sequence. A 409 ROUTE_NOT_APPROVED means somebody edited the route since it was published; the panel refetches and shows the new version.
  4. 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

  1. Open the builder. Search swaps between builder and catalogue; catalogue rows load routes editable, keeping endpoints, zones, corridor and mid shape points.
  2. 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.
  3. Preview. The builder shows the routed line before anything can be published, and refuses to publish checkpoints no vehicle can reach.
  4. Press Publish. One button: plan through OSRM, then approve. A 403 from self-approval is caught and shown as "awaiting approval" rather than an error.
  5. A second person opens the same route and presses Approve as-is. That button is gated on the role tiers that hold route:approve.
A real measurement from the rig

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 mobitrasupport tier 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.

There is no realtime channel

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 facts, measured 2026-08-27
Hoste2e-101-183 / 216.48.184.183, Ubuntu 22.04.3, 16 vCPU
Disk123 G used, 58 G free (68%), filling about 0.7 G per day
Memory21 Gi used, 7.5 Gi available, down 2 Gi in a fortnight
Postgres12.22, not on this VM. 164.52.207.108:5432, database usermanagement, 44 public tables
KafkaOn the VM, confluentinc/cp-kafka:7.6.2, 98 topics, auto-create on
RedisOn the VM, redis:6379 on the shared network, no password
NetworkOne shared mobitra-uat-bridge-net, 172.18.0.0/16
Port 8100free, 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.

Why a human has to do this

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.

Do not run this while the stack is up

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
strip_path=true matters

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.

Why the fallback is a relative URL

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

WhatHow
CodeThe 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.
SchemaUsually unnecessary. The tables are additive and inert while nothing calls the API. prisma/uat-rollback.sql exists if it is needed.
Whole featureRemove 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.

JobRepoStageWhat it does
build_image_uatbothbuild_uatGuards 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_uatmgmt onlymigrate_uatRuns 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_uatbothdeploy_uatInstalls 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.
The runner can deploy. It runs as root.

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 and generate needs 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/client imports and dist/index.js exists. 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.
  • CMD is node dist/index.js, not npm start. npm start would run migrations, and the deploy applies them out of band on purpose.

Configuration reference

route-trip-mgmt

VariableDefaultNotes
PORT8100Not published on the UAT host. Kong reaches the container by name.
NODE_ENVdevelopmentproduction makes header auth mode refuse to start.
DATABASE_URLrequiredPercent-encode the password. Shared usermanagement database.
REDIS_URLredis://localhost:6379Read-only use. A blip degrades the live views, it does not error them.
KAFKA_BROKERlocalhost:9092Outbox publisher only.
KAFKA_TRIP_EVENTS_TOPICtrip.events
OUTBOX_POLL_MS2000, UAT sets 1000How fast a dispatch reaches execution.
TRIP_AUTH_MODEjwtheader is the local stub and refuses NODE_ENV=production.
TRIP_JWT_SECRETrequired for jwtByte identical to spring-custm-os app.usermanagement.secretkey. Raw string, not a base64 decode.
TRIP_JWT_ISSUERSemptyComma list of accepted iss. Empty means do not check.
TRIP_IDENTITY_TTL_SEC60Identity cache. Also the lag on a role change taking effect.
TRIP_CORS_ORIGINSemptyComma list. * is refused under jwt auth, at boot.
TRIP_TRUST_PROXYfalseTrue 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_APPROVALunsetlocal rig only Disables separation of duty on approval. Never set it anywhere shared.
OSRM_URLhttp://localhost:5000UAT points at http://osrm-lucknow-container:5000.
ROUTING_ENGINEunsetUnset with a URL set means soft fallback. See the plan endpoint.
ROUTING_FALLBACK_SPEED_MS8.33 (about 30 km/h)Duration for straight-line geometry.
GEOFENCE_URLn/aspring-custm-os base for zone creation.

route-trip-execution

VariableDefaultNotes
DATABASE_URLrequiredThe only variable with no working default. This service owns no schema and must never run a migration.
PG_POOL_MAX10 in UAT
KAFKA_BROKERlocalhost:9092
REDIS_URLredis://localhost:6379Sole writer of the active-trip key.
KAFKA_TRIP_EVENTS_TOPICtrip.events
KAFKA_LOCATION_PACKET_TOPIClocation-packet-topic
KAFKA_GPS_NOTIFICATIONS_TOPICgps-notifications
KAFKA_DEAD_LETTER_TOPICtrip.dlq
CORRIDOR_WIDTH_M100Fallback only. The real width comes from the pinned snapshot.
DEBOUNCE_ENTER_COUNT / EXIT2 / 2Stop arrive and depart.
CORRIDOR_BREACH_COUNT / OK3 / 3Deviation raise and clear.
OFFLINE_GAP_MS900000Streak reset after a long silence.
ALERT_COOLDOWN_MS300000Re-notification interval on an open alert.
DELAY_THRESHOLD_SEC600
EVAL_POLYLINE_TOLERANCE_DEG0.00010 disables simplification.
NO_TRIP_CACHE_TTL_SEC60
EVENT_ID_TTL_SEC86400Idempotency marker lifetime.
HANDLER_MAX_ATTEMPTS3Then the dead letter queue.
HANDLER_RETRY_BACKOFF_MS250

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: true on the plan response, if OSRM was provisioned. False means straight-line geometry got stored.
  • zones_armed on the dispatch response matching what you drew.
  • The trip appearing in GET /api/v1/trips/live with monitored: true within a couple of seconds. False means execution has not loaded it.

6. Scripted

ScriptWhat it proves
route-trip-mgmt/scripts/guardrails-smoke.sh92 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.shThe create, assign, checklist, dispatch chain.
route-trip-mgmt/scripts/softzone-e2e.shDispatch plus an outside and an inside packet, then asserts alert rows, the geofencing publish and the notification log.
local-dev/scripts/drive-route.jsDrives a real route over telemetry, firing every alert type. Measures where each zone actually sits along the road line first.
geofencing/scripts/verify.shClones HEAD, installs from the lockfile, runs every suite, builds the image. Refuses a dirty tree and enforces per-suite test-count floors.
Verify the committed tree, not the laptop

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

SymptomMost likely causeCheck
Every trip request 404s from the browser but works from inside the networkKong strip_path is falsecurl $A/routes/mobitra-uat-trip-route on the Admin API
Four endpoints fail from the browser, work from curlIdempotency-Key missing from the CORS allowed headersThe Kong cors plugin config, and TRIP_CORS_ORIGINS
Container refuses to start, no request ever servedTRIP_JWT_SECRET empty, or TRIP_CORS_ORIGINS=* with jwt authContainer logs. Both fail at boot on purpose rather than 500ing later.
Health says "auth_mode":"header"The environment block did not applyThis is a security regression. The CD job already rolls back on it.
Trip is DISPATCHED but monitored: falseExecution never loaded itExecution 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 yesterdayA trip stuck at DISPATCHED or EN_ROUTEThe message names the offending trip. Cancel it.
Every trip raises TRIP_DELAYED at about 330 minutesThe naive-timestamp bug. The timestamptz migration did not apply.information_schema.columns for the trip tables
Migration fails with P3009A FAILED row in the shared _prisma_migrations, possibly from notification-serviceprisma 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 buildingsOSRM down, or coordinates outside the loaded regionplanned_via_osrm in the response, and route.osrm_meta.engine
Plan returns a route of 0 mOut-of-coverage snappingThe coverage guard should have caught it. If it did not, check the guard is still in planning/osrm.ts.
Stop alerts never reach a phoneThe stop has no geofence and the notify predicate driftedtripExecutionNotifiesStop against the stream's admission test
Acknowledging an alert makes it vanish from the consoleThe feed asked for OPEN onlySend ?status=OPEN,ACKNOWLEDGED
A cancelled trip keeps alertingThe TRIP_CANCELLED event was dropped, so the hot state survivedDelete fleetgo:trip:active:<imei> by hand. Known open defect.
Route Builder cannot create a zonespring-custm-os returned 404 with a business messageRead 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.

Two local traps
  • 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.
Do not tidy these

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 doneWhy
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

DefectConsequence it had
Batch consumer took slice(0,1) while kafkajs auto-resolved the whole batchBursts of alerts dropped with no log line
timestamp: new Date() in the winston defaultMeta, evaluated once at requireEvery log line showed process start time
Naive timestamps, two writers with two conventionsTRIP_DELAYED on every trip, about 330 minutes
Stop events deferred to a stream that could not see the stopArrival and departure alerts reached nobody, for 132 of 144 stops
Flat metadata overwrite on re-upsertDeviation position was the recovery point, not the breach. 124 m stored where the real breach was 266 m.
Alert feed asked for OPEN onlyAcknowledging deleted the alert from the console permanently
Trip detail fetched once while warnings polledOne card showed two different ages
Stops counted only DEPARTEDCompleted trips read one stop short of their own total
evaluation/index.ts imported a module never committedgeofencing HEAD could not boot
tsx declared but absent from the lockfilenpm ci failed; the image could not build at all
publishTripEvent returned quietly with no brokerOutbox row marked published. Event destroyed, no retry.
EditControl stale closure in the builderEvery drawn zone took the first zone's name
Seed re-created geofence.trip_meta on every rig startLocal database diverged from UAT
PATCH could write status: APPROVEDApproval gate, separation of duty and the version freeze all bypassed
Dispatch minted a route version when none existedA trip could run against a template nobody approved
Cancel blocked on DISPATCHED and EN_ROUTEIn-flight trips had no way out; the vehicle stayed occupied forever
SCHEDULED was not an assignable statusA trip created SCHEDULED could never be assigned
Snapshot embedded previous versionsMultiplicative snapshot bloat on every approval

Open, flagged not fixed

DefectImpactWhy it is still open
NO_GO_EXIT published 3 times, delivered 0 timesmedium Nobody learns a vehicle left a restricted zoneSuspected severity or type gate in notification-service. That repo is 404 for our account.
A dropped TRIP_CANCELLED leaves a zombie trip alerting foreverhigh Alerts on a trip nobody is runningNothing 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 packetmedium Alert fatigueThe 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 pathmedium Affects 100% of alerts on that pathmoment(null). Fix lives in the 404 repo.
Committed GCP service-account keys in notification-servicehigh Credentials in git historyRotation and history purge needs whoever owns the projects.
A GitLab PAT in plaintext across 22 local checkoutsmediumWorth rotating. Not caused by this work.
Kong Admin API on :8001 is unauthenticated and publicly boundhighPre-existing, not caused by this work, still worth closing.
web-portal is about 44 commits behind origin/dev, 4 known conflictslowDeliberate. 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.

1. A passing test suite that never runs

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.

2. Two dead route files

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.

3. The cheat sheet is now wrong in three places

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

#ThingWhy it cannot be automated away
1Developer 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.
2Two compose directories on the VM, with a .env beside the mgmt oneCI only rewrites an image tag in a file that already exists. It never creates one.
3TRIP_JWT_SECRETMust 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.
4UAT_DATABASE_URL as a masked CI variable on route-trip-mgmtSo the migrate job does not carry a password in a file. Optional: the VM .env works too.
5Two Kafka topicsOne command. Explicit beats auto-create, so partition count is a decision.
6Kong service and routeOne command block. Execution needs none.
7Portal build after Kong existsREACT_APP_TRIP_CORE_URL is compiled into the bundle.

No longer blockers

Previously listedActual 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

  1. What is TRIP_JWT_SECRET? Someone with the spring-custm-os config has to hand it over.
  2. Does anyone object to _prisma_migrations gaining 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.
  3. 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.
Routing placement, for reference

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.

The gap between step 3 and step 4 of the routing rollout

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.

QuestionSourceState
Everything, for a reviewerthis pagecurrent
Why does a route behave that way?~/.claude/plans/trm-registration-and-runtime-quirky-frost.mdthe spec decisions there override later prompts
The whole feature across five reposdocs/TRIP_PLATFORM_HANDOFF.mdcurrent 2026-08-27
UAT health, rollout and where OSRM livesdocs/UAT_TRM_ROLLOUT_AND_OSRM.mdcurrent 2026-08-27
Schema and the publish contractdocs/TRIP_SCHEMA.mdcurrent
What env does the container need?each repo's .env.example and deploy/uat/docker-compose.yamlcurrent
Is this change safe?geofencing/scripts/verify.shcurrent
Did the zone maths change?spatial_golden.json, both reposcurrent
What does a full trip look like?local-dev/scripts/drive-route.jscurrent
Mental model of the stackdocs/TRIP_STACK_CHEATSHEET.mdstale pre-split, still describes trip_meta
How do I deploy?geofencing/UAT_DEPLOY.mdstale for these two services describes hosted-in-geofencing
Datastore mapdocs/TRM_DATASTORE_MAP.htmlstale two of its findings are now fixed, trip_meta is gone

Trip and Route Management runbook. Written 27 August 2026 against route-trip-mgmt and route-trip-execution on branch UAT, with the portal on ship/uat-trip.

Endpoint behaviour, error codes, permission tiers, environment variables and tuning defaults in this document were read from source rather than from earlier documentation. Where a number is quoted from a measurement, the measurement is named. Anything marked open is open.