Fisheries VMS Domain · Lesson 17 of ∞ · Second pass · ← Lesson 16

Second pass · design practice, and where the design actually lives

Geofence intersection logic: designed in one repo, unbuilt in the other

Lesson 16 found the deepest stub in the pipeline. This lesson goes to design it — and finds the intended algorithm already written down in detail, just not where the code is.

World practice: two phases, because checking everything against everything doesn't scale

The naive approach — test every vessel position against every geofence polygon — is O(vessels × zones). At e-Boat's own declared scale (more on the exact numbers below), that's untenable per position update. The standard answer, in PostGIS and in spatial computing generally, is a two-phase model:

  1. Broad phase (the index filter)Cheap and approximate. A spatial index — PostGIS's GiST, built as an R-tree — doesn't store full polygons, just each one's bounding rectangle. A bounding-box overlap check quickly discards the vast majority of zones a point obviously isn't near.
  2. Narrow phase (the recheck)Expensive and exact. Only the small candidate set that survived the broad phase gets the real test — ST_Contains or equivalent polygon-containment logic. Two bounding boxes can overlap while the actual shapes don't; the narrow phase is what makes the answer correct, not just fast.

This is the same shape as broad-phase/narrow-phase collision detection in game physics engines — different domain, identical problem: cheaply cull the impossible before spending real computation on the possible.

Sources: PostGIS — How do I use spatial indexes?, Crunchy Data — PostGIS Performance: Indexing and EXPLAIN.

What exists in the codebase today: very little, and one aspiration

EBoat.Contracts/Geo/GeoPoint.cs is the entire geometry primitive layer, twelve lines:

public readonly record struct GeoPoint(double Latitude, double Longitude);

public readonly record struct BoundingBox(double MinLat, double MinLon, double MaxLat, double MaxLon)
{
    public bool Contains(GeoPoint p) =>
        p.Latitude >= MinLat && p.Latitude <= MaxLat &&
        p.Longitude >= MinLon && p.Longitude <= MaxLon;
}

That's a rectangle-containment check, not a polygon one — useful for the map's own viewport clustering, not for a real geofence shape. No Geofence entity, DTO, or table exists anywhere in the repo. RuleDefinition.ScopedToGeofences (Lesson 16) is typed IReadOnlyList<Guid> — geofences are referenced only by opaque id, with nothing backing that id yet.

A fourth flavor of "not implemented"

EBoatDbContext.OnModelCreating declares the PostGIS extension explicitly: builder.HasPostgresExtension("postgis");, with a comment explaining it's there "already because geofences ... land in this same context." But no NetTopologySuite package (the standard EF Core↔PostGIS geometry bridge) is referenced anywhere, and today's VesselPosition stores Latitude/Longitude as plain double columns, not a geometry type. This is a distinct pattern from Lesson 14's three — call it infrastructure reserved, application layer untouched: the database is told to be ready; nothing in C# has tried to use it yet.

The design intent that isn't in this repo at all

The stub's own doc comment gestures at an answer — "PostGIS R-tree index plus a precomputed 'vessel to candidate zones' table" — but the real specification lives in the tender's architecture brief, not in eboat-wrk:

"Geofence evaluator. Read tracker.raw, run ST_Contains against in-memory R-tree refreshed from PostGIS; emit geozone.entered/geozone.exited."

That single sentence answers the question a naive reading of "uses PostGIS" would get wrong: the hot path — the code that runs on every incoming position — never queries PostGIS directly. PostGIS is the authoring store, where geofence polygons are defined and edited. A background refresh (every 30 seconds, per the mitigation below) pulls the current polygon set out of PostGIS into an in-memory spatial index that the evaluator actually checks positions against. A database round-trip per position, at e-Boat's declared throughput, would be the latency budget's first casualty — an in-memory structure refreshed periodically is what makes the throughput numbers in Lesson 13 and the geofence-specific SLO below compatible with each other.

The numbers this is actually sized against

Lesson 13 flagged e-Boat's 10,000-concurrent-object target as larger than any documented comparable and left it as an open question. The tender's load model resolves it: 10,000 is Ukraine's own declared baseline fleet (70% vessels over 12m, ~7,000 units; 30% at or under 12m, ~3,000 units) — not a padded ceiling — with a stated 30,000-vessel, 3× growth headroom target behind it. The geofence evaluator is explicitly sized against a related number: up to 5,000 zones held in the in-memory R-tree at once.

And the geofence-specific latency target is tighter than the general map latency from Lesson 3: p95 position-event → geozone-event lag ≤ 10 seconds, against the ≤30s device-to-map figure covering the whole pipeline. That gap is worth reading through Lesson 3's lens directly — detecting a violation early enough to still investigate it (UNFSA's duty) is a stricter requirement than simply keeping the map current, so it earns its own, tighter budget.

The risk this design already has a name for

A specific, rated risk exists for exactly this component in the tender's risk register — R-003, "Geozone eval latency drift":

FieldValue
Triggerp95 geofence eval > 1.5s over a 5-minute rolling window; a single zone serving > 300 vessels concurrently
SeverityProbability 3 × Impact 3 = 9 → Medium
MitigationR-tree spatial index in PostGIS; precomputed vessel→candidate-zone table refreshed every 30s; horizontal scaling of the evaluator consumer; per-zone latency dashboard
ContingencyReduce eval cadence 10s → 30s during peak, with operator notice; temporarily disable low-priority advisory zones

Two things worth taking from this table directly. First, the 1.5s trigger is an early-warning threshold, well inside the 10s SLO — the system is meant to notice drift and react long before it breaches the actual promise. Second, the contingency plan is a graceful-degradation path, not a failure mode: under real peak load, the system is designed to knowingly trade eval frequency for continued operation, with an operator told what's happening — the same instinct behind Lesson 13's honest 99.5% availability target rather than an aspirational one.

A citation that doesn't resolve

The stub's doc comment cites "risk 3.3.3(1)" — that exact string doesn't match anything in the tender repo. The real identifier is R-003, documented in risk-register.md and cross-linked from architecture-brief.md §13.4. Worth reading this the way Lesson 14 read the bare compliance-matrix row: not as evidence the code is wrong, but as a live example of why a cross-repo reference needs to be an exact, checkable string — a comment that cites something that doesn't resolve is a broken pointer, however well-intentioned, and worth flagging back to whoever wrote it rather than silently working around.

Sketching the evaluator

GeofenceRefreshService (background, every 30s)
   │  SELECT geofences FROM PostGIS (source of truth, editable via UI)
   ▼
in-memory R-tree  (NetTopologySuite STRtree — not yet a dependency)
   ▲
   │  broad phase: candidate zone ids whose bounding box is near the point
GeofenceEvaluatorService.ExecuteAsync (per position, from eboat.telemetry.position)
   │  narrow phase: exact polygon-contains test against candidates only
   │  compare to vessel's previous zone membership
   ▼
on transition ─▶ publish geozone.entered / geozone.exited
                  onto eboat.events (Subjects.DomainEvent)

What would need to be built

Check your recall

Something unclear, or want to go deeper on any term here? Ask the agent that built this lesson — it's your teacher for this workspace, not just a lesson generator.