Real-Time Speed Enforcement
When a city Policy rule activates, Levy fans out per-OEM IoT commands to every vehicle currently inside the rule's geometry. This is the moment that proves to the city that you're not just reporting compliance — you're enforcing it. The target latency is <10s p95 from rule activation to first command sent.
Target latencies
OKAI Cat-M roundtrip is ~3-5s. Omni 4G is ~5-8s. Segway BLE-relay can spike to 30s+ on cold vehicles — for Segway fleets we document the gap to cities rather than promise sub-10s. Queclink and ZIMO sit between OKAI and Omni.
The enforcement service
src/lib/compliance/enforcement.ts exposes two entry points:
| Function | Caller | What it does |
|---|---|---|
fanOutEnforcementForRule(ruleId, reason) | mds-policy-activate cron + zone-crossing engine | Queries vehicles in the rule's geometry, skips stale-GPS, dispatches per-OEM speed-limit command, logs to policy_enforcement_events. |
setSpeedLimit(vehicleUuid, kph, reason) | Dashboard "manual override" action | Same per-OEM dispatch path but for a single vehicle, called from operator UI. |
Both go through the same src/lib/iot/dispatch.ts module that handles the OEM-specific command formatting.
Per-OEM command catalogue
For each vehicle in the affected geometry, the service looks up iot_devices.iot_type and routes to the correct command:
| OEM | Command | Format |
|---|---|---|
| OKAI | •••••sign in | •••••sign in — ECU speed cap |
| Segway | •••••sign in | Iotrip parameter S4 over HBCS protocol — sets max throttle |
| Omni 4G (Levy Max, Acton, Feishen) | •••••sign in | SCOS protocol S4 parameter — max speed in km/h |
| Queclink | •••••sign in | •••••sign in — speed alarm + ECU cap |
| ZIMO | MQTT •••••sign in | JSON payload •••••sign in on the device's command topic |
For no_ride rules, the same dispatcher emits the OEM's lock command instead (OKAI •••••sign in, Segway •••••sign in, Omni •••••sign in, etc.). For parking rules, no IoT command is sent — parking enforcement happens at ride-end, not on the vehicle.
Idempotency
Every command is tagged with a sha256 idempotency key derived from:
sha256(rule_id + vehicle_uuid + action + rule_value + activation_timestamp)
The key is written into policy_enforcement_events as a unique column. A re-run (e.g., the activation cron fires twice during a deployment) tries to insert the same key, hits the unique constraint, and silently skips. The IoT layer is never asked to send a duplicate command.
This is the same pattern we use for Sentry-noise filtering and OKAI firmware OTA. It means the enforcement loop can be safely retried.
The stale-GPS skip
When a rule activates, the service queries vehicles by their last known GPS sample:
SELECT v.* FROM vehicles v
JOIN vehicle_events e ON e.vehicle_uuid = v.id
WHERE e.event_type = 'gps'
AND e.created_at > now() - interval '5 minutes'
AND ST_Contains(<rule.geometry>, ST_MakePoint(e.lng, e.lat))
If a vehicle's last GPS is older than 5 minutes, it is skipped and a row is logged with error = 'stale_gps'. The reasoning:
- We don't actually know where the vehicle is, so we don't know whether the rule applies.
- Cat-M wakeups can be slow on shelved vehicles — a command sent to a sleeping radio queues up and arrives much later, sometimes after the vehicle has moved out of the zone.
- When the vehicle wakes up and sends a fresh GPS, the existing zone-crossing engine will check
policy_geofencesand dispatch the command then — no command is lost.
The 5-minute threshold is a constant in enforcement.ts. We chose it to match the existing telemetry freshness window used elsewhere in the codebase (the auto-lock cron, the SCOR battery-trust check).
Sequence on rule activation
mds-policy-activate cron
|
v
flip mds_policies.status from 'pending' -> 'active'
|
v
for each rule in policy:
|
v
fanOutEnforcementForRule(rule.id, "policy_activated")
|
+---> query vehicles in geometry, fresh GPS only
|
+---> for each vehicle:
| lookup iot_devices.iot_type
| compute sha256 idempotency key
| INSERT INTO policy_enforcement_events (idempotency unique)
| dispatch per-OEM command via iot-proxy
| await ack (best-effort, 5s timeout)
| UPDATE policy_enforcement_events SET command_ack_at = ...
|
v
log run summary to Sentry (success count, skip count, error count)
Sequence on vehicle entering an existing zone
The activation cron is the "rule changed" path. The other path is "vehicle moved into a zone that was already active". That's handled by the existing zone-crossing engine (src/lib/zones/enforcement.ts), which now checks policy_geofences in addition to operator zones:
GPS telemetry update arrives during active ride
|
v
stackForPoint(point) // PostGIS RPC + policy_geofences scan
|
v
resolveStack(stack) // priority ladder
|
v
active.speed_limit_kph differs from current_speed_limit_zone_id?
|
v
yes -> dispatch per-OEM speed-limit command (deduped against current state)
The same idempotency key shape applies, but with rule.activation_timestamp replaced by the GPS sample's timestamp. This means a vehicle that ping-pongs across a zone boundary doesn't generate duplicate commands every GPS update.
What riders see
In-app, riders get a slow-zone notification:
Title: "City slow zone"
Body: "You are now in a Boulder city slow-zone. Top speed limited to 8 km/h."
For no-ride rules:
Title: "City no-ride zone"
Body: "Ride controls paused while you are in this area. Move to an approved area to continue."
These messages are differentiated from operator-zone notifications by the "City" prefix, so riders know the operator isn't the one putting the rule in place. The notification copy is configurable in Settings -> Notifications -> Compliance.
What the dashboard shows
The Enforcement Events view at /dashboard/compliance/{jurisdiction-id} (Enforcement tab) is the canonical record. Each row carries:
| Field | Notes |
|---|---|
rule_id | FK to mds_policy_rules |
vehicle_uuid | The affected vehicle |
action | speed_limit, lock, unlock_on_exit |
command_sent_at | UTC ts at dispatch |
command_ack_at | UTC ts when the OEM acked (null if no ack yet) |
command_response | OEM-specific response JSON |
error | stale_gps, offline, oem_rejected, etc., or null |
idempotency_key | sha256 hex |
Filterable by jurisdiction, rule, vehicle, and date range. Useful when a city asks "how do I know you enforced the slow zone at 3 PM on Tuesday?".
Limits and known gaps
- Segway BLE-relay latency: vehicles that haven't been near a phone in a while can take >30s to wake up. For Segway-heavy fleets, document this with the city in writing rather than over-promising.
- Vehicles in
non_operationalstate: skipped automatically. We don't try to push commands to vehicles you've taken offline. - Vehicles with no
iot_devicesrow: skipped witherror = 'no_iot_device'. Most often this is a vehicle imported without IMEI linkage. policy_geofences.geometryis currently NULL — the in-JSpointInPolygonis the fallback. Moving toST_GeomFromGeoJSONat ingest is on the roadmap; it'll cut the in-JS scan time for fleets with thousands of policy geofences.
Manual override from the dashboard
If you ever need to override a policy enforcement decision for a single vehicle (e.g., to demo the system to a city contact, or to recover a vehicle from a no-ride zone for a technician), use Vehicle detail -> Compliance actions -> Set speed limit. This calls setSpeedLimit(vehicleUuid, kph, reason) directly. The action is logged to policy_enforcement_events with action = 'manual_override' and reason carrying the dashboard user's note.
Manual overrides expire on the next zone-crossing event. They're a tactical tool, not a way to permanently exempt a vehicle from a policy.
What's next
- Policy Ingestion from Cities — where the rules come from.
- Stacked Geofence Priority — how the active rule is chosen.
- Troubleshooting — when enforcement doesn't fire.