Fisheries VMS Domain · Lesson 16 of ∞ · Second pass · ← Lesson 15
Second pass · design practice, three stages deep
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.
Same posture as Lesson 15: everything cited below is real and quoted from the codebase; the entity design is a sketch built from those pieces, not a description of shipped code. What's different this time is how much is already decided — a real taxonomy, a real rule DSL, a real interface — with the actual persistence and dispatch missing. Read this as "how far did the design get before the code stopped," which is its own useful skill.
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.
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.
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.
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.
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.
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:
RuleCode, Category, LegalBasis copied at detection timeRuleDefinition aloneVessel.Id / VesselPosition.VesselId: int, assigned by VesselIdentity hashing — never a DB sequenceNotificationMessage.RelatedViolationId: typed Guid? — the only place in the repo that anticipates a violation id at allThat 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 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.
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.