Oil slicks, sanctioned vessels, and a ledger of claims

20 July 2026

The tanker AETHER SAIL was a leading candidate for two oil slicks in twelve months. It ranked second for a slick in August 2025, months before OFAC designated it. It ranked first for a 4.5 km² slick this April, while listed, its track passing through the site 96 minutes before the satellite image. Three weeks after that, its AIS went silent. When a signal returned in July, the same hull was broadcasting a new identity. All of it comes from free, public data. This post is about how we stored that data as claims in an append-only log, where facts arrive months apart from sources that disagree, and the picture assembles itself at read time.

The result

Sentinel-1 slick detections · SkyTruth Cerulean · Persian Gulf & Gulf of Oman · Aug 2025 – Jul 2026
AIS track: pending
slick with a sanctioned-vessel candidate vessel position (AIS, Global Fishing Watch) AIS silent > 24 h

A year of Cerulean detections across three AOIs (Black Sea, Mediterranean, Persian Gulf) is 3,209 credible slicks. Joined by MMSI against the 1,939 vessels in the OpenSanctions sanctions collection, 53 sanctioned vessels appear among the candidates, nine as the top-ranked candidate for at least one slick. AETHER SAIL is the strongest match of them. The demo is a replay of claims over time. The detections, the scored attributions, the AIS track, the sanctions listing, the identity change. Each is a sourced, timestamped row in an append-only log. The demo is a query over the log. This post explains how those rows work.

Why a ledger

Joining two APIs is easy. The real work is in structuring your data such that you can ask further questions of it. The hypothesis is that by storing all data as claims in an append-only log you will be able to use data to answer questions you have not yet thought to ask.

When Cerulean scored the August slick, AETHER SAIL wasn't sanctioned. In December OFAC sanctioned it, and the August attribution lit up retroactively. The join is recomputed on every read, and the new listing found the old slick on its own.

The sources

Three sources, all free. Cerulean (SkyTruth) detects oil slicks in Sentinel-1 radar and scores candidate attributions rather than naming a culprit. OpenSanctions consolidates sanctions data, including 1,939 vessels with their identifiers. Global Fishing Watch holds AIS tracks and vessel identities back to 2012. None of them agrees on what a vessel is, which is the point of everything that follows.

Claims, not records

The ledger is kg, a small library we wrote for this. It is an append-only log of claims plus a handful of folds, in a few hundred lines of dependency-free Python. One assertion is one row, and the row carries everything an analyst would ask of it.

claimsubjectpredicateobjectsourceconfobserved_atingested_at
e3ab551bslick-4354844attributed_tovessel-511101256skytruth-collation (algorithm)522025-08-302026-07-19
2e250abavessel-511101256ident_mmsi511101256skytruth-collation (algorithm)2025-08-302026-07-19
32a56a2fslick-4834473attributed_tovessel-511101256skytruth-collation (algorithm)552026-04-122026-07-19
a43beee7NK-aqnaYqBgUKEvXwxqoHfeo4on_sanctions_listsanctioned-vesselopensanctions-vessels (list)2026-07-012026-07-19
9bc6fcc4NK-aqnaYqBgUKEvXwxqoHfeo4ident_mmsi511101256opensanctions-vessels (list)2026-07-012026-07-19
4e528890NK-aqnaYqBgUKEvXwxqoHfeo4ident_imo9277371opensanctions-vessels (list)2026-07-012026-07-19
b27b813dvessel-511101256ident_imo9277371gfw-registry (list)2026-07-19
60e7d3c8vessel-649615000ident_imo9277371gfw-registry (list)2026-07-19

Cerulean asserts candidacy with its score passed through untouched. OpenSanctions asserts a listing and two identifiers. The Global Fishing Watch registry ties a second MMSI to the same IMO. Nothing merges the three vessel subjects into one "AETHER SAIL" record.

Ingest never invents a confidence. Sources that publish a score have it passed through. Sources without a score leave the cell empty. Judgment about what that means happens at read time.

The join is a read, not a merge

Every source's identifiers are claims (ident_mmsi, ident_imo). The join is a fold over them. Because the join is a read, its rules are yours to write, and that is where subject matter expertise belongs. An analyst knows an IMO number survives a re-flagging and an MMSI does not. Get a rule wrong and you change a query, not data.

The graph is a view

The same claims are a graph. Subjects and values are nodes and claims are edges. "Everything ever known about this vessel" is just every edge that touches it, and because each edge carries its dates, the graph is knowledge across time. Below are the eight claims from the table (kg.py query mmsi=511101256, rendered).

Hover an edge to see the claim behind it. Scroll to zoom, drag to rearrange. Circles are entities, squares are identifier values. Red squares are values claimed by more than one subject, the join surface.

Show your working

The code, the parsers, and everything needed to reproduce this post are at kg. Every dataset used is free.

A candidate attribution is not proof of discharge. Rank 1 means most consistent among tracked candidates, and the join is on MMSI, an identifier vessels change. Nothing here goes beyond what the sources publish. Data: Global Fishing Watch (CC BY-NC 4.0) · SkyTruth Cerulean · OpenSanctions.