METHODOLOGY · MARITIME · v1.0

Audit-grade transparency.
Zero black box.

Dark-fleet detection, AIS forensics, sanctions enforcement.

Every flag SENTINEL surfaces on a vessel is reproducible in arithmetic from sources we cite. No proprietary models. No opaque AI behaviour scoring. Below: the literal rules running in production for the maritime domain.

100 % OSINTDeterministicNo LLM scoringNo black-box weights

FOUR HARD RULES · APPLIED PLATFORM-WIDE

What we will never do

Lloyd's, Windward and Pole Star ship behavioural-AI scores. We don't. Here is why and what we ship instead.

Every flag is sourced

Every signal — maritime, investigation, dark-web, social — links back to the upstream dataset, the source file, and the public endpoint that produced it. No hidden corpus.

Every threshold is published

There are no hidden weights, no proprietary models. Every rule on this site is the literal Python code that runs in production, pinned to a specific service file.

Every contribution is explicit

Risk and threat scores return their contribution arrays verbatim. An analyst can defend any flag in front of a regulator by pointing to one row.

AI assists — never alone

Where AI is used (Investigation, Hidden Network, AI Assessment), every output is paired with citations and deterministic evidence. The model never produces a score without a verifiable trail.

Vessel Risk Score (0 – 100)

Vessel Risk Score (0 – 100), broken down line-by-line

The composite is a strict arithmetic sum of the contributions below, clamped to [0, 100], then bucket-mapped to a colour band. Every response includes the contribution array verbatim so any flag can be defended against a regulator.

0 – 20

LOW

No verifiable risk signals.

21 – 40

MODERATE

One or more soft signals (FoC flag, single short AIS gap).

41 – 60

ELEVATED

Direct sanction OR multiple corroborating signals.

61 – 80

HIGH

Multi-regime sanction stack OR severe operational anomalies.

81 – 100

CRITICAL

Direct sanction + identity laundering + EEZ transit + dark time.

FactorCapSourceFormula
Sanctions+35 ptsOpenSanctions / OFAC / EU CSL / UN / UK / CA / AU / JP / CH / NZmin(30, regime_count × 5) + recency_bonus (5 pts < 6 mo · 2 pts < 2 y)
AIS Dark Time+20 ptsAISstream.io continuous capture · 90-day windowmin(20, dark_time_pct × 0.25)
AIS Spoofing+15 pts`maritime_positions` jump analysis · implied speed > 18 kts after AIS gapmin(15, suspect_events × 5)
AIS Gaps+10 pts`maritime_positions` · silence ≥ 6 h closes a windowmin(10, dark_events_count × 1)
Flag Risk+10 ptsOpenSanctions FtM `country` field on Vessel docsanctioned country flag (RU/IR/KP/SY/VE/CU/MM): +10 · flag of convenience: +5
Sanctioned port calls+5 pts47-port gazetteer + OSM POI fallback · 90-day port_callsany port call within 10 NM of Bandar Abbas / Kharg / Nampo / Tartus / Havana / Caracas / Sevastopol / Mariupol / Berdyansk: +5
Same Hull / New Name+15 ptsOpenSanctions FtM `aliases[]` aggregated per IMO4+ distinct names: +10 · 8+ distinct names: +15
Flag hopping+15 ptsOpenSanctions FtM `countries[]` aggregated per IMO2 flags: +5 · 3 flags: +10 · 5+ flags: +15
Derived sanctions (50% rule)+25 ptsOpenSanctions Ownership graph (`opensanctions_relations`) · OFAC FAQ 401verified_majority (cumulative ≥ 50% with all numeric links): +25 · assumed_controlling: +15 · minority_only: +0
Sanctioned EEZ transits+20 pts15 polygon features · IR×3, KP×2, SY, CRIMEA×2, RU×4, VE, CU, MMmin(20, distinct_countries × 8) · single transit must last ≥ 60 min
Loitering events+15 ptsAIS Type-3 SOG + Type-1 position · sanctioned vessels only by defaultmin(15, events × 5) · event = SOG < 1.5 kts ≥ 3 h, > 5 NM from any known port, ≥ 12 NM from coast
Draft / ballast anomalies+15 pts`vessel_voyage_history` (AIS Type-5 deltas only) · 180-day window+10 per anomaly capped at +15 · patterns: no_discharge, no_loading, false_ballast
ETA divergence+10 ptsAIS Type-5 broadcast ETA vs great-circle / smoothed-SOG predictorsuspect (24-72 h Δ): +5 · heavy (≥ 72 h Δ): +10

11 DETECTORS

Per-detector specification

Each detector links to its source file, public endpoint, inputs, method, thresholds and known edge cases.

Phase 6.1 (D)

Same Hull, New Name

The IMO number is permanently and uniquely assigned to one hull for the entire lifetime of the ship. Every distinct name we observe attached to that IMO is a textbook re-flagging / identity-laundering signal.

INPUTS

OpenSanctions FtM `aliases[]` and `previousName[]` aggregated per IMO from the daily `entities.ftm.json` ingest.

METHOD

Canonicalise each candidate name (case-fold, strip "(formerly X)" suffixes, collapse whitespace) and count the distinct set per hull.

THRESHOLDS & CONTRIBUTION

RuleVerdictRisk points
4 ≤ names < 8SUSPECT+10
names ≥ 8HEAVY+15

EDGE CASES & LIMITATIONS

Localized transliterations of the same name are NOT counted twice (e.g. "ARTSAKH" vs "АРЦАХ" canonicalise to the same key). Operator companies (e.g. "MSC ANTONIA" → "MAERSK ANTONIA" same hull, two operators) DO trigger because the IMO is what we anchor on, not the operator.

OUTPUT

`distinct_names`, `severity`, `risk_points`

ENDPOINT

GET /api/maritime/vessel/identity

SOURCE FILE

services/vessel_identity_service.py

Phase 6.1 (E)

Flag Hopping

Industry red flag: shadow-fleet vessels switch flag jurisdictions to chase the cheapest registry that will accept them after the previous one drops them under sanctions pressure (St Kitts → Cameroon → Gabon → Comoros loop is well-documented).

INPUTS

OpenSanctions FtM `countries[]` aggregated per IMO. The FtM dump does NOT carry per-flag dates so we use TOTAL distinct flag count as a credible (and conservative) proxy: a vessel with N flags on file MUST have re-flagged N - 1 times.

METHOD

Lower-case country codes, deduplicate, count.

THRESHOLDS & CONTRIBUTION

RuleVerdictRisk points
flags = 2WARNING+5
flags = 3 or 4SUSPECT+10
flags ≥ 5HEAVY+15

EDGE CASES & LIMITATIONS

Multi-jurisdiction registries that share a flag (e.g. "Bareboat charter (BB)") are treated as their own entry. We do NOT auto-merge them because the operator chose to broadcast them separately.

OUTPUT

`distinct_flags`, `severity`, `risk_points`

ENDPOINT

GET /api/maritime/vessel/identity

SOURCE FILE

services/vessel_identity_service.py

Phase 6.2 (B)

OFAC 50 % Rule (derived sanctions)

Per OFAC FAQ 401 (parallel EU 269 / UK Russia regs / OFSI), an entity owned 50 % or more in aggregate by one or more sanctioned persons is itself automatically blocked, even when not explicitly designated. We apply this rule deterministically over the local FtM Ownership graph.

INPUTS

`opensanctions_relations` collection (10 862 ownership / directorship / unknown-link edges) ingested daily. Captures `percent_held` (numeric) and `control_hint` ("majority" / "minority").

METHOD

Recursive walker upward from the target entity. Cycle-safe per-call `seen` set. Fanout cap 25/node, depth cap 5.

THRESHOLDS & CONTRIBUTION

RuleVerdictRisk points
cumulative percent_held ≥ 50 % AND every link numericverified_majority+25
chain reaches sanctioned ancestor BUT some links lack numeric %assumed_controlling+15
every chain has cumulative % < 50minority_only+0 (informational)
no Ownership/Control edge to any sanctioned ancestorno_chain+0

EDGE CASES & LIMITATIONS

A vessel directly listed in the sanctions corpus does NOT receive derived points (avoids double-counting against the direct-Sanctions factor). Chains with `Directorship`/`Representation` ONLY are surfaced for completeness but do NOT trigger the 50 % rule (control-only without ownership is not a 50 % case).

OUTPUT

`derived: bool`, `confidence`, `regimes[]`, `chain[]`

ENDPOINT

GET /api/maritime/vessel/sanctions-derived

SOURCE FILE

services/ofac_50_percent_rule.py

Phase 6.3 (I)

UBO 7-layer recursive resolver

Maltego-style ownership tree (parent → children, recursive) over the OpenSanctions ownership graph. Where the OFAC 50 % detector returns ONE chain that triggers the rule, this detector walks the COMPLETE network so analysts can visualise every direct & indirect beneficial owner and every sanctioning regime applied upstream.

INPUTS

Same `opensanctions_relations` graph. Considers Ownership, InlineAssetOwnership, UnknownLink, Directorship, Representation, Membership, Employment edges.

METHOD

Iterative breadth-first walker. Per-call cycle-detection set passed by reference (never global). Per-call node counter (fully re-entrant for concurrent users).

THRESHOLDS & CONTRIBUTION

RuleVerdictRisk points
depth captunable7 layers (default)
fanout captunable25 children per node
node captunable50 nodes total

EDGE CASES & LIMITATIONS

When the cap is hit the response returns `truncated: true` with the exact param values used. Sanctioned nodes carry their full regime list so the analyst can see which jurisdiction designated which upstream entity.

OUTPUT

`{root, children[], stats, truncated, params}`

ENDPOINT

GET /api/maritime/vessel/ubo-chain · /api/maritime/entity/{entity_id}/ubo-chain

SOURCE FILE

services/vessel_ubo_chain_service.py

Phase 6.2 (F)

Sanctioned EEZ Transit

Did this vessel transit Iranian / North Korean / Syrian / Crimean / Venezuelan / Cuban / Myanmar / sanctioned-Russia waters in the last N days? Pure point-in-polygon over the historical AIS trail.

INPUTS

`maritime_positions` historical trail and 15 polygon features tuned conservatively against IHO maritime atlas + EEZ shapefiles. Multi-polygon countries (Iran, Russia) get multiple bboxes unioned logically.

METHOD

For each AIS position, point-in-bbox. Consecutive INSIDE pings are grouped into a transit segment. Single-ping drive-bys (< 60 min) are dropped. AIS gap > 6 h closes the segment.

THRESHOLDS & CONTRIBUTION

RuleVerdictRisk points
min transit durationconfig60 min
gap inside transit closing the segmentconfig6 h
riskper distinct EEZ visited+8 (max +20)

EDGE CASES & LIMITATIONS

Sanctioned-Russia bboxes deliberately exclude Vladivostok and other Pacific ports that are NOT under sanction. Crimea bboxes are split from mainland Russia so analysts can distinguish the two.

OUTPUT

`country_breakdown`, `transits[]`, `polygons_used[]`

ENDPOINT

GET /api/maritime/vessel/sanctioned-transits · /api/maritime/sanctioned-eez/polygons

SOURCE FILE

services/sanctioned_eez_service.py

Phase 6.2 (C)

Offshore Loitering Detector

Single sanctioned vessel quietly stationary offshore — a textbook precursor to ship-to-ship transfers, AIS dark gaps, or EEZ-line waiting games. Restricted to sanctioned/watchlisted vessels by default to avoid noise from drifting fishing boats and anchorages.

INPUTS

`maritime_positions` SOG + position trail. Cross-checked against the 47-port gazetteer and a 12 NM coast buffer.

METHOD

Continuous SOG < 1.5 kts for ≥ 3 h. Vessel position must be > 5 NM from any known port AND ≥ 12 NM from inland coast. Sample gap > 6 h closes the window.

THRESHOLDS & CONTRIBUTION

RuleVerdictRisk points
max speed in windowconfig1.5 kts
min durationconfig3 h
port bufferconfig5 NM
coast bufferconfig12 NM
riskper event+5 (max +15)

EDGE CASES & LIMITATIONS

Loitering events are persisted to `vessel_loitering_events` to enable cluster-wide scans. Non-sanctioned vessels can be scanned via the `sanctioned_only=false` query param but UI defaults to sanctioned-only.

OUTPUT

`events[]` with lat/lon, duration_hours, distance_to_coast_nm

ENDPOINT

GET /api/maritime/vessel/loitering · /api/maritime/loitering

SOURCE FILE

services/loitering_service.py

Phase 6.3 (G)

Draft / Ballast Anomaly

Three well-documented sanctions-evasion cargo patterns over the AIS Type-5 broadcast history. Compliance officers (Lloyd's, Pole Star) flag these manually; we automate them.

INPUTS

`vessel_voyage_history` time-series (append-only on changes ≥ 0.2 m or status/destination delta) sourced from AIS Type-5 ShipStaticData.

METHOD

3 deterministic patterns: no_discharge (laden vessel exits port still laden between distinct destinations, Δ < 0.5 m, both ≥ 6 m), no_loading (ballast vessel transits 2 ports unchanged), false_ballast (NavStatus implies ballast but max_draught_m ≥ 0.7 × observed maximum for this hull).

THRESHOLDS & CONTRIBUTION

RuleVerdictRisk points
no-discharge Δconfig< 0.5 m
laden minimum draughtconfig≥ 6.0 m
ballast maximum draughtconfig≤ 5.0 m
false-ballast ratioconfig≥ 0.7 × max observed
riskper anomaly+10 (max +15)

EDGE CASES & LIMITATIONS

Tankers and bulkers under 6 m draught are excluded from the no_discharge rule (the laden floor would generate false positives). Vessels with < 3 historical voyage snapshots return zero anomalies (insufficient evidence).

OUTPUT

`anomalies[]` with type, draught_delta, ports involved, severity

ENDPOINT

GET /api/maritime/vessel/draft-anomalies · /api/maritime/draft-anomalies

SOURCE FILE

services/draft_anomaly_service.py

Phase 6.3 (L)

Deterministic ETA Predictor

Independent verification of the captain's broadcast ETA. The declared ETA in AIS Type-5 can be falsified to mask a deviation toward a sanctioned port. We re-derive the ETA from observable physics.

INPUTS

Last 6 SOG samples (filtering idle pings < 0.5 kts). Destination resolved via 3-tier fallback: KNOWN_PORTS exact match (47 strategic + 35 dark-fleet extension) → UN/LOCODE 5-char table → substring match.

METHOD

ETA_predicted = NOW + great_circle_distance(current_position, destination) / smoothed_speed. ±20 % uncertainty band. Compared against `vessel_voyage_data.eta_iso`.

THRESHOLDS & CONTRIBUTION

RuleVerdictRisk points
|Δ| < 24 hconsistent+0
24 h ≤ |Δ| < 72 hsuspect+5
|Δ| ≥ 72 hheavy+10
forecast min speedconfig2 kts

EDGE CASES & LIMITATIONS

When destination cannot be resolved (e.g. "FOR ORDER" / "TBA" / "GULF OF FINLAND") the response returns `confidence: low` with `destination_resolved: false` so the UI can show "destination unresolved · ETA unverifiable" instead of inventing a verdict. Stationary vessels (smoothed SOG < 2 kts) return `confidence: low` rather than dividing by ~zero.

OUTPUT

`predicted_eta_iso`, `predicted_band`, `delta_hours`, `delta_severity`, `confidence`

ENDPOINT

GET /api/maritime/vessel/eta

SOURCE FILE

services/vessel_eta_service.py

Phase 1

AIS Dark Time

Percentage of the lookback window during which the vessel was NOT broadcasting AIS. AIS is mandatory under SOLAS Chapter V Reg 19 for tankers ≥ 600 GT; sustained silence is itself a signal.

INPUTS

`maritime_positions` continuous capture from AISstream.io WebSocket (Type-1 / 3 / 18 / 19 broadcasts).

METHOD

For each consecutive position pair within the window, accumulate gap duration if > 6 h. Total dark seconds / total window seconds × 100.

THRESHOLDS & CONTRIBUTION

RuleVerdictRisk points
gap to count as darkconfig> 6 h
risklinearmin(20, dark_pct × 0.25)

EDGE CASES & LIMITATIONS

Vessels with < 5 positions in the lookback window are reported as `dark_time_pct: null` (insufficient evidence) — they do NOT receive risk points by default.

OUTPUT

`dark_time_pct`, `dark_events_count`, `total_dark_hours`

ENDPOINT

GET /api/maritime/vessel/timeline

SOURCE FILE

services/vessel_history_service.py

Phase 1

AIS Spoofing (implausible re-emergence)

When a vessel turns AIS back on at a position whose distance from the last-known fix is impossible to cover at any plausible speed, we flag it as a spoofing-suspect event. Distinguishes deliberate identity / position spoofing from a benign AIS dropout.

INPUTS

Position pairs separated by an AIS gap > 6 h.

METHOD

implied_speed_kts = great_circle_distance / gap_hours. If implied_speed_kts > 18 kts → spoofing-suspect (commercial tankers do not exceed ~16 kts in normal operations).

THRESHOLDS & CONTRIBUTION

RuleVerdictRisk points
implausible speedflag> 18 kts
riskper event+5 (max +15)

EDGE CASES & LIMITATIONS

The check is only applied AFTER an AIS gap (otherwise a single fast ferry would trigger). The 18 kt threshold is deliberately conservative to avoid false positives on container ships in clear weather.

OUTPUT

`spoofing_suspect_count`, `spoofing_events[]`

ENDPOINT

GET /api/maritime/vessel/timeline

SOURCE FILE

services/vessel_history_service.py

Phase 5

Choke Point Live Monitor

Real-time situational awareness over the 8 critical maritime bottlenecks (Hormuz, Suez, Bab el-Mandeb, Malacca, Bosphorus, Panama, Dover, Gibraltar). ~30 % of all global maritime trade and ~60 % of seaborne crude oil pass through these 8 chokepoints.

INPUTS

`maritime_positions` (latest fix per MMSI) cross-referenced against `maritime_sanctions` by IMO + MMSI + `mmsi_known` (covers dark-fleet identity rotation).

METHOD

For each chokepoint bbox, count vessels with the most recent fix inside. Compute heat level 0-3 from sanctioned vessel count. 60 s in-process cache (matches AIS collector freshness).

THRESHOLDS & CONTRIBUTION

RuleVerdictRisk points
heat 0idle< 5 sanctioned vessels
heat 1normal5 – 9 sanctioned
heat 2elevated10 – 19 sanctioned
heat 3congested≥ 20 sanctioned

EDGE CASES & LIMITATIONS

Bbox coordinates verified against the IHO maritime atlas. "Latest fix" is the aggregation of the most recent position per MMSI, NOT a moving snapshot — vessels recently transited but currently outside the bbox are NOT counted.

OUTPUT

Per-chokepoint `total / sanctioned / dark_fleet / heat / vessels[]` payload

ENDPOINT

GET /api/maritime/choke-points/live

SOURCE FILE

services/maritime_chokepoint_service.py

OTHER PILLARS

Browse the rest of the methodology

Bring this transparency to your compliance team.

Every flag, every threshold, every source — fully reproducible. Defendable in front of any regulator.

Last reviewed Apr 28, 2026 · 11 detectors documented for the Maritime pillar · 13 contribution caps

Made with Emergent