advanced
geofence
priority
postgis

Stacked Geofence Priority

The 6-tier priority ladder that orders city policy zones, operator zones, and system defaults — and how PostGIS returns the strictest rule at each point.

Levy Fleets TeamMay 18, 202612 min read

Stacked Geofence Priority

When city policy geofences overlap with operator zones, somebody has to win. Levy uses a 6-tier priority ladder, materialized as an integer priority column on every zone, and resolved at read time via a PostGIS RPC that returns all zones at a point ordered by priority descending.

The 6-tier ladder

Higher priority wins. The first non-null rule for each rule_type becomes the active rule at that point.

TierPrioritySourceExamples
11000City policy prohibited_areas + speed_limit"No riding on Pearl Street", "5 km/h in the plaza"
2950City policy parking_zones + equity_zones"Park only in painted corrals", "deploy >= 20% in zone X"
3700Operator-defined no-ride zonesYour own no-go around a private property
4500Operator-defined slow zonesYour own pedestrian-area speed limit
5300Operator-defined parking zonesYour own corral definitions
6100System defaultsFallback subaccount-level speed and parking rules

The numeric values are deliberately spaced 50-200 apart so operators can drop in a custom intermediate-priority zone if they ever need to (e.g., a temporary VIP event zone at priority 800). Today the UI doesn't expose custom priority editing — every operator zone gets its tier default — but the column allows it.

The conflict resolver

src/lib/compliance/conflict-resolver.ts exposes two functions:

FunctionWhat it does
resolveStack(zones)Takes a list of zones at a point, returns the active rule per rule_type (one speed limit, one no-ride flag, one parking rule).
detectOperatorOverrides(operatorZones, policyGeofences)Returns the operator zones currently shadowed by a higher-priority policy.

resolveStack() is the function that powers current_speed_limit_kph in MDS responses and the GBFS geofencing_zones.json priority order. It's also the function the zone-crossing engine calls on every GPS telemetry update during an active ride.

detectOperatorOverrides() powers the conflict banner on the compliance dashboard.

The zones_containing_point RPC

Migration 20270601004000_07_zones_priority_source.sql ships a Postgres function:

CREATE FUNCTION zones_containing_point(
  p_subaccount_id uuid,
  p_lat double precision,
  p_lng double precision
)
RETURNS SETOF zones
LANGUAGE sql STABLE
AS $$
  SELECT *
  FROM zones
  WHERE subaccount_id = p_subaccount_id
    AND deleted_at IS NULL
    AND ST_Contains(geom, ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326))
  ORDER BY priority DESC, created_at ASC;
$$;

The geom column is auto-populated from the geojson column via a trigger. Every zone, whether operator-authored or mirrored from a policy, has a non-null geom, so the PostGIS query is the canonical answer to "what zones contain this point?".

stackForPoint(point)

In JS, src/lib/compliance/zone-stack.ts wraps the RPC and additionally consults policy_geofences (which currently uses in-JS pointInPolygon because the PostGIS column on that table is left null pending the ST_GeomFromGeoJSON migration). The combined list is then handed to resolveStack().

const stack = await stackForPoint({
  subaccountId,
  lat: vehicle.last_lat,
  lng: vehicle.last_lng,
});
// stack: [
//   { source: 'city', priority: 1000, rule_type: 'speed', rule_value: 8, ... },
//   { source: 'operator', priority: 500, rule_type: 'speed', rule_value: 10, ... },
//   { source: 'operator', priority: 300, rule_type: 'parking', ... },
// ]

const active = resolveStack(stack);
// active.speed.rule_value === 8 // city wins
// active.parking exists // operator parking still applies (city has no parking rule here)

Conflict detection

detectOperatorOverrides() is called by the operator dashboard to surface the warning banner. It returns, for the given subaccount, all operator zones whose geometry intersects an active policy_geofences row of higher priority and the same rule_type.

Field on the resultWhat it means
operator_zoneThe shadowed zone
shadowing_ruleThe MDS policy rule that outranks it
affected_areaThe polygon intersection (for the map preview)
rule_typeThe rule type where the conflict exists (speed, no_ride, parking)

In the UI, the operator can:

  • Delete the shadowed zone — if it's now redundant with the city policy.
  • Edit it to fit alongside — if the operator zone exists for a different reason (e.g., a slow zone that surrounds the city's no-go zone, providing context for riders).
  • Acknowledge — dismiss the alert without changing anything. The override remains; the banner just stops nagging.

Reading the ladder in mobile clients

The customer mobile app reads the GBFS 3.0 geofencing_zones.json (ordered by priority desc) and treats the first matching feature as the active rule. This is what GBFS validators expect, and it matches the ladder semantics: the city rule comes first because it has the higher priority.

If your mobile client implements its own zone resolution, the same rule applies — sort by priority desc, take the first matching feature for each rule_type.

Scenarios

Scenario 1: City slow-zone overlaps operator slow-zone

ZoneSourcePrioritySpeed limit
City "Downtown pedestrian"City10008 km/h
Operator "Plaza"Operator50010 km/h

Result: city's 8 km/h wins at the overlap. Outside the city zone but inside the operator zone, the 10 km/h limit applies. The operator zone shows in the conflict banner as "shadowed by City Downtown pedestrian".

Scenario 2: City no-go inside operator parking zone

ZoneSourcePriorityType
City "Construction site"City1000no_ride
Operator "Main Street corral"Operator300parking

Result: the rules are different types, so both apply. Inside the construction site, the vehicle cannot ride (city no_ride wins for no_ride rule_type). Outside the construction site but inside the operator zone, parking is allowed (operator parking is the only parking rule).

Scenario 3: City policy expires

When policy_geofences.is_active flips to false (because active_until passed), the zones_containing_point RPC stops returning those rows. The operator zone underneath comes back to the top of the ladder automatically. No manual cleanup; no audit-log update; the conflict banner clears on the next page load.

Scenario 4: Two city policies overlap

Cities sometimes publish two policies with overlapping geometry (e.g., a permanent slow-zone plus a temporary event no-ride zone). Both are at priority 1000 (city tier), but the resolver sorts ties by rule_type severity: no_ride outranks speed, speed outranks parking. The strictest rule applies.

If the same rule_type clashes at the same priority, we sort by start_date descending — the most recently activated policy wins. This handles "city updated their slow-zone speed limit from 10 to 8 km/h" cleanly.

What you can change

KnobDefaultNotes
Operator-zone priorityTier default (700/500/300)Custom values require a direct DB update today.
Acknowledge a conflictn/aDashboard action; the override remains but the banner stops nagging.
Delete the shadowed zonen/aDashboard action; mirrored city zones are not deletable from the operator UI.

City zones cannot be edited by the operator. They're owned by the Policy feed; the only way to change them is to ask the city to update their feed.

What's next