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

Second pass · design practice, three stages deep

The Violation entity: rules already return categories, nothing turns them into a record

Lesson 15 hit a wall: Documents can't FK to Violations because Violations doesn't exist. This lesson goes to look — and finds the gap runs three stages deep, with a real taxonomy and a real unresolved design tension already sitting in the code.

The taxonomy already exists — and it's split down a legal fault line

EBoat.RuleEngine/Model/RuleDefinition.cs defines ViolationCategory, and it's split into two groups by a doc comment that's worth reading exactly as written:

// Explicit CMU No153 provisions
ZoneEquipmentMismatch = 1,   // section 6  — equipment does not match the fishing zone
TrackerMalfunction = 2,      // section 17-19 — malfunction or signal loss
ForeignVesselNoNotice = 3,   // section 16 — entry without 24-hour notice
ControlCheckDiscrepancy = 4, // section 12 — control check coordinate discrepancy

// Inferred from practice — require confirmation by the enforcement unit
GeofenceBreach = 10,
SpeedAnomaly = 11,
AisOffLoitering = 12,
CrossBorderIncursion = 13,
TrackerTamper = 14,
QuotaExceedance = 15,
MissingLogbookEntry = 16

This is Lesson 3's legal ladder made concrete, sitting directly in an enum: the first four values cite exact sections of a named regulation (CMU No153 — a Cabinet of Ministers resolution). The second seven are the team's own inference from general VMS/EU IUU practice, explicitly marked as unconfirmed pending Inception. And that second group reads like a checklist of this workspace's own earlier lessons — AisOffLoitering is Lesson 1's "going dark" example by name; SpeedAnomaly is exactly the kind of thing Lesson 4's polling-interval accuracy research would need to flag carefully, since a low-frequency track can manufacture a false speed anomaly out of nothing but sparse sampling.

Why the two-group split matters for the entity

A violation whose category cites a specific regulation section carries more evidentiary weight than one inferred from general practice and not yet confirmed by the enforcement unit. Whatever entity eventually stores a detected violation should probably carry that distinction forward — not flatten "explicit regulation" and "inferred practice, pending confirmation" into one undifferentiated category field.

"Rules are data, not code" — a real pattern, not yet a formal decision

RuleDefinition is a configuration record, not a rule written in C#: Expression (a DSL string, "interpreted, never compiled into an assembly"), Thresholds (a dictionary the expression references), ScopedToGeofences, IsEnabled, and LegalBasis (a free-text citation like "CMU No153 section 17-19"). ai/AGENTS.md §6 states the reason directly: "Do not add a switch over violation types and do not hard-code thresholds in C#... This is a requirement, not a style preference: the Customer changes regulatory thresholds without a redeployment." The pattern this implements has a name — the Rules Engine pattern, which treats business rules as data loaded at runtime rather than scattered conditionals in application code.

Worth flagging precisely: this is a real, well-justified decision — but it lives only as a note in ai/AGENTS.md, not as a formal ADR alongside the other three in docs/adr/. A decision this load-bearing (it shapes the entire rule-storage and evaluation design) is exactly the kind of thing Lesson 8's platform correction suggests should be written down formally, not left as a comment someone might not read.

What the evaluator actually returns — and the gap right after it

IRuleEvaluator exists as an interface, unimplemented:

Task<IReadOnlyList<RuleDefinition>> EvaluateAsync(PositionRecord position, CancellationToken ct = default);

Read that return type carefully: it hands back the rules that fired, not violation records. Nothing in the codebase takes that list and a triggering position and turns the pair into a persisted, queryable violation. That translation step — not the entity alone — is the actual missing piece. A companion IRuleStore interface (GetActiveRulesAsync, ReloadAsync) exists too, matching the "config update, not redeploy" requirement with a hot-reload method — also unimplemented.

The gap runs one stage further back than it looks

It's tempting to assume the pipeline is: geofence check → rule evaluation → violation record, with only the last step missing. It's actually two steps missing before that: GeofenceEvaluatorService itself — the component Lesson 5 described doing "geofence intersections" — is a complete stub. Its ExecuteAsync logs a startup message and returns; the injected event bus is explicitly discarded (_ = eventBus;). No intersection logic exists, and no domain event type exists either — nothing named GeofenceBreached or similar is defined anywhere in EBoat.Contracts. Lesson 5's fan-out diagram describes the intended shape correctly; it just hasn't been built yet, at any of its three stages:

PositionRecord
   │
   ▼
GeofenceEvaluatorService   ◀── stub: logs only, no intersection logic, no event type
   │  (would publish a domain event on breach)
   ▼
IRuleEvaluator             ◀── interface only, no DSL interpreter implementation
   │  returns: RuleDefinition[] that fired
   ▼
??? (missing)              ◀── nothing turns a fired rule + position into a Violation
   │
   ▼
Violation entity           ◀── doesn't exist: no table, no migration
   │
   ▼
eboat.violations (NATS)    ◀── wired into the EBOAT_EVENTS JetStream stream already,
                               but nothing publishes or subscribes to it yet

That last line is a genuine nuance worth being precise about: the Violation subject is provisioned in EBoat.Messaging/JetStreamProvisioner.cs's EBOAT_EVENTS stream — the broker topology is real infrastructure, not a bare string constant. It's "designed, not built" one layer further out than the entity itself: the pipe exists, nothing flows through it.

Sketching the entity, and the tension already sitting in the code

A Violation row needs to answer, permanently: which rule fired, against what position, on which vessel, when, and what happened to the case afterward. Two things already in the codebase shape that design:

Snapshot, don't just reference

A real, unresolved type tension

That second point is worth sitting with rather than silently picking one: Guid and int aren't interchangeable choices here — VesselIdentity's whole design exists specifically so a vessel's numeric id is derived deterministically rather than assigned by the database (Lesson 5's territory: "a second copy of this rule silently splits one vessel into two"). Whichever type Violation.Id ends up as should be a deliberate decision made before writing the entity, not an accident of whichever field got typed first.

The "history" half of the requirement group

The ToR's own wording — "Violations: catalogue, capture, history" — names a third thing beyond detection and storage: a lifecycle. A formal pattern for exactly this exists in the compliance literature: detected → under review → confirmed or dismissed → remediated, with every transition recorded as its own immutable, timestamped entry rather than overwriting a status field.

Detected ──▶ UnderReview ──▶ Confirmed ──▶ Remediated
     │             │              │
     └─────────────┴──────────────┴──▶ Dismissed

Source: "The Violation Situation Pattern: Persistent Representation of Compliance Violations in Knowledge Graphs". The detail worth keeping from it: "Each accepted transition writes a ... node ... to the ViolationInstance" — status is a log of transitions, not a single mutable field. This is Lesson 12's immutable-capture principle again, one layer up: a ViolationStatusHistory table that only ever gets rows appended, never updated, alongside the Violation row itself.

NOAA's real enforcement ladder is a concrete example of what those transitions correspond to operationally — a verbal warning, a "Fix-It Ticket" with a correction window, or escalation to a Notice of Violation, each a step a status field alone can't capture without a history behind it.

Source: NOAA — Enforcement FAQ.

What would need to be built, in dependency order

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.