Fisheries VMS Domain · Lesson 17 of ∞ · Second pass · ← Lesson 16
Second pass · design practice, and where the design actually lives
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.
Same posture as Lessons 15 and 16: what's quoted below is real; the assembly into a working evaluator is a sketch. What's different here is where the design intent was found — not in the implementation repo at all, but in the tender's own risk register and architecture brief. Reading across repos to find where a decision actually lives is its own skill, and this lesson leans on it directly.
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:
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.
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.
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 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. Readtracker.raw, runST_Containsagainst in-memory R-tree refreshed from PostGIS; emitgeozone.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.
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.
A specific, rated risk exists for exactly this component in the tender's risk register — R-003, "Geozone eval latency drift":
| Field | Value |
|---|---|
| Trigger | p95 geofence eval > 1.5s over a 5-minute rolling window; a single zone serving > 300 vessels concurrently |
| Severity | Probability 3 × Impact 3 = 9 → Medium |
| Mitigation | R-tree spatial index in PostGIS; precomputed vessel→candidate-zone table refreshed every 30s; horizontal scaling of the evaluator consumer; per-zone latency dashboard |
| Contingency | Reduce 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.
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.
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)
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.