Changelog

All notable changes to this project are documented here.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

Two version numbers move independently in this project, and only one of them is this file's subject. ActiveSanction::VERSION is the gem, and is what a release note is about. ActiveSanction::MATCHER_VERSION is the matching pipeline, is stamped onto every MatchResult, and is bumped whenever a change to the normalizer, the index, the similarity algorithms or the scorer could move a score. A change that moves MATCHER_VERSION is called out here as such, because it is the one kind of change that alters what a past screening decision would come out as today.

Unreleased

Nothing yet.

1.1.1 - 2026-09-14

Added

  • The conformance groups ship (#144). require "active_sanction/testing" loads the two shared example groups that define what a source adapter and a storage adapter must do, so an adapter written in your own application is held to the same contract the built-in ones are:

    # spec/spec_helper.rb
    require "active_sanction/testing"
    
    # spec/internal_watchlist_spec.rb
    RSpec.describe MyCompany::InternalWatchlist do
      it_behaves_like "a sanction source", fixture: "internal_watchlist/list.csv"
    end
    

    They were always the stated contract — docs/api_stability.md calls them "the executable statement of what [the extension points] require" — and they lived under spec/, which is excluded from the packaged gem. The audience for the promise was the one group of people who could not run it. Fixture paths resolve under spec/fixtures unless ActiveSanction::Testing.fixture_root says otherwise, and an absolute path is taken as it stands.

    require "active_sanction" does not load any of it, so nothing reaches a production process; requiring it without RSpec says so rather than failing somewhere stranger. The group names and the options they take are public API from here, and a group gaining an example is a minor with a note here naming it.

  • Preparing a release is a button (#146). Prepare release in the Actions tab takes a version, bumps VERSION, moves the accrued Unreleased section under a dated heading, fixes the changelog's link definitions, regenerates the site data that carries the version, and opens a pull request. release.yml verifies all of that and writes none of it, so until now it was a hand-edit somebody had to remember — and hand-edits to this particular shape have already gone wrong silently once, when two open pull requests merged cleanly in sequence and filed a feature under a patch release that did not contain it.

    The edits are bin/prepare_release, with a spec over them, so the workflow is reviewable as Ruby and bin/prepare_release 1.2.3 does the same thing on a laptop. It refuses a version that does not come after the current one, and refuses to prepare a release out of an empty Unreleased — which release.yml also refuses, but only after the gem is published. Nothing is tagged or pushed to main: the output is a pull request, and the version number stays a judgment a person makes.

  • Client#supports? (#144), so a caller can ask what an implementation does rather than find out by rescuing NoMethodError. This client supports all of Client::CAPABILITIES; the method is for the implementations that are not this class — one answering screening questions against data somebody else keeps fresh has no sync! to offer. An unrecognised capability is false rather than an error, deliberately unlike Configuration: the caller asking is usually written against a newer version than the one answering, and wants a fallback path rather than an exception.

1.1.0 - 2026-09-14

A minor, because it adds. Nothing that existed changed: no behaviour under lib/ moved, MATCHER_VERSION stays at 1, and a name scores today exactly what it scored under 1.0.0. Instrumentation is additive and off unless a host asks for it.

Added

  • Instrumentation: six structured events, so a host can measure this library without monkeypatching it (#59). ActiveSanction.configure { |c| c.instrumenter = ... } takes anything answering #call(event) and is handed a finished ActiveSanction::Instrumentation::Event for each of :fetch, :parse, :store, :"index.build", :screen and :sync — every one carrying a duration and the ids needed to correlate it, with no anonymous timings. The event names and every payload key are public API, enumerated in docs/api_stability.md and on the site's instrumentation reference, and covered by the deprecation path from here.

    It is a measurement of the work and never part of it. A subscriber that raises has its exception caught, reported once through the configured logger, and dropped; the sync it was watching finishes and returns the report it was going to return. The converse holds too: a stage that raises emits its event with error: set and then the exception continues exactly as if nothing were listening.

    Nothing is listening by default, and that costs nothing. nil is a branch taken before anything is allocated rather than a no-op object that gets called, so an uninstrumented screening call builds no event and allocates no payload — which is the only way a per-query event could be affordable at all. Measured rather than asserted: building a matcher over 47,051 names allocates 262 more objects than before, out of 5.79 million, and rake benchmark:latency reports the same p50 either side of the change (12.1–12.8 ms against a run-to-run spread that was already that wide).

    Rails hosts get ActiveSanction::Instrumentation::Notifications, which republishes every event into ActiveSupport::Notifications under <event>.active_sanction. It is an adapter and not a dependency: nothing in this gem requires ActiveSupport, and building one in a process that has not loaded it raises ConfigurationError rather than quietly instrumenting nothing.

    The issue asked for ActiveSanction.instrumenter = ..., and this is a configuration setting instead. #55 ended process-global configuration deliberately, and a module-level writer would have rebuilt the default client — dropping the matcher it had indexed every stored list into — as a side effect of naming a subscriber. A Matcher takes its instrumenter at build and freezes it with its weights, so a subscriber swapped halfway through a batch cannot make half of it instrumented; that is the rule every other setting on the query path already follows.

  • Sources::Base#warnings, defaulting to none. Every shipped adapter already exposed it and the adapter rules already required it of a new one, but the base class never said so — and the :parse event counts warnings for every source, which a count that is sometimes a NoMethodError cannot do. An optional hook with a default implementation, so no adapter outside this repository has to change.

1.0.1 - 2026-09-14

A packaging and release-tooling release. Nothing about screening changes: no behaviour in lib/ moved, MATCHER_VERSION is unchanged at 1, and a name scores today exactly what it scored under 1.0.0.

Added

  • Releases publish themselves from a tag, through .github/workflows/release.yml. Pushing v1.2.3 re-runs the three gates against the tagged tree, checks that the tag and VERSION agree, that the tag is an ancestor of main and that the changelog has a section for it, then builds the gem, publishes it and writes the GitHub release from that section. A tag failing any of those publishes nothing.

    No API key exists to leak. It authenticates by trusted publishing: a short-lived OIDC token, verified by rubygems.org as naming this repository and this workflow file, exchanged for a credential that expires with the job. The alternative is a long-lived key in a public repository's settings, one leak away from someone else publishing under this gem's name -- and unlike a bad deploy, a bad gem is already installed by the time anyone could be warned. Same reasoning that keeps a service-account key out of the documentation deploy.

    The gem pushed is the gem that was verified, carried between the two jobs as an artifact rather than rebuilt -- a second build is a second thing, however identical it looks.

    The publish is confirmed against rubygems.org (#136), because gem push exiting 0 says the upload was accepted rather than that a bundle install will find the version. The job that writes the GitHub release waits on that confirmation, so a version rubygems.org did not end up serving is never announced.

  • A gem badge on the README (#136), read live from rubygems.org rather than generated into a file. What it states is what is installable, which is a different question from what was last tagged -- and a version written into the repository would be stale the moment the next one published. Same argument as #104, applied to the one fact about this gem that lives somewhere else entirely.

  • A release can be cut from the Actions tab, without tagging by hand (#139). The workflow's "Run workflow" button takes a tag and a checkbox: ticked, it runs every gate against main and writes the tag only once they have all passed. That is the safer order than git tag && git push, which makes a tag public before anything has checked the tree under it — and this workflow will not move a tag somebody may already have fetched, so the repair for that is a new version number. A tag it writes is annotated but unsigned; push the tag yourself when you want your own signature on it. See CONTRIBUTING.md.

Fixed

  • rake canary:refresh regenerates the catalogue page's data as well as the baselines. site/src/data/sources.json is derived from .github/baselines (#104), so accepting new numbers without rebuilding it left the two disagreeing and spec/site_sources_data_spec.rb red. The rolling baseline pull request the canary opens had failed on this every run since it started opening one, on all six Rubies. Chained onto the task rather than added as a step to each caller: a derived file that callers have to remember to rebuild is stale by the third caller.

  • The canary signs its baseline commit off. Nothing exempts a bot from .github/workflows/dco.yml, which skips merge commits and nothing else, so the rolling pull request failed the DCO check as well.

1.0.0 - 2026-09-13

The first release. Everything below is in it.

Why 1.0.0 and not 0.1.0: the public surface this ships is the one that was inventoried, enumerated in docs/api_stability.md and held in place by spec/api_surface_spec.rb, which fails the build in both directions. Shipping that under a leading zero would say the opposite of what the inventory says. From here the deprecation path in that document is in force — nothing enumerated there is removed without a warning first, and a breaking change waits for 2.0.0.

Added

The canonical record

  • Entity, one source-agnostic record model every adapter parses into: names, dates of birth, addresses, identifiers, nationalities, programs, type, and the publisher's own text kept verbatim in remarks (#4).
  • Name with alias kind and quality (#5), PartialDate for the year-only, approximate and ranged dates these lists actually publish (#6), and Address and Identifier (#7).
  • Snapshot: one source's entities plus a checksum over their content, re-derived on construction, so a truncated or edited list raises rather than screening quietly short (#8).

Fetching

  • HttpClient with a mandatory User-Agent, bounded redirects and retries with backoff (#9).
  • Conditional GET on ETag and Last-Modified, so an unchanged list costs one request and no parse (#10).
  • PayloadCache, a bounded, integrity-verified cache of the raw bytes each publisher served (#11).

Sources

  • Sources::Base, the adapter contract, and an open registry — a source registered from outside this gem is a first-class source (#12), with a declarative field-mapping DSL (#13).
  • Parsers::DelimitedTable, a reusable CSV toolkit (#14), and Parsers::XmlRecords, a streaming XML toolkit with pluggable REXML and Nokogiri backends (#15).
  • The shared "a sanction source" conformance group, and a spec that holds it to being able to fail (#16).
  • OFAC SDN — the SDN, ALT and ADD CSVs, joined (#18).
  • OFAC Consolidated (non-SDN) — the same shape, plus derivation of which of the six sub-lists a record is on from its program codes, exact for 478 of 481 published records (#20).
  • The OFAC remarks parser, which reads dates of birth, places of birth, nationalities and passport numbers out of the one free-text field they are published in — currently recognizing 97.3% of 88,827 segments, and reporting its own coverage per sync (#19).
  • UN Security Council consolidated list (#21).
  • Canada SEMA / JVCFOA consolidated list, with deterministic synthetic ids for a list that publishes none (#22).
  • EU Consolidated Financial Sanctions List (FSF) — 6,234 records in one 25.7 MB document, the first list large enough to exercise the streaming XML interface, read with no change to core (#39). Three judgment calls in it are worth knowing about before relying on the list: the EU marks no name as the official one, so a stated rule picks one; alias quality and alias kind are prose in a per-name <remark> rather than a column, and are read from it; and four birth dates are published in the Islamic calendar, three of which therefore produce no date of birth at all rather than a Gregorian year in the fourteenth century that would conflict with the real one. The endpoint does not honour conditional GET, so this is the one list that downloads in full on every sync.
  • UK Sanctions List — 6,334 designations in one 21.8 MB XML document, read with no change to core (#40). The issue was scoped against OFSI's Consolidated List of Asset Freeze Targets, which the UK retired on 28 January 2026 when it moved every designation onto one list; its blob still answers 200 with a frozen 16.6 MB file, so an adapter reading it would look healthy on every sync and screen against a list that stopped moving in January. If you have your own integration against ConList.csv, that is the thing to check today. Two things about the data are worth knowing before relying on it: a date component the FCDO does not know is spelled out rather than omitted — dd/mm/1962 is a year, 00/00/1975 is another spelling of the same, and 824 of 3,788 birth dates carry one, every one of which reads as nil through an ordinary date parser — and the publisher's own non-Latin script labels disagree with its own strings on three records, which is why Name#script is left unstated here. The endpoint honours conditional GET on both ETag and Last-Modified, so an unchanged list downloads nothing.
  • Four UK spellings added to countries.txt — Congo (Democratic Republic), St Kitts and Nevis, St Lucia, St Vincent — and Palestinian and Occupied Palestinian Territories, which between them resolve 53 of the 63 UK nationality values that previously did not. Purely additive: no existing spelling resolves differently, and the accuracy report is unchanged.
  • Australia's Consolidated List (DFAT) — 3,906 records, read with no change to core (#41). The endpoint the issue named was unverified and is now gone: regulation8_consolidated.xlsx redirects to a .xls last modified in March 2022, which is served 200 and would look healthy on every sync. This adapter reads the file DFAT's own page links today. Two things are worth knowing before relying on it. The Control Date is not a listing date — DFAT defines it as when the entry was last edited, it is on all 11,163 rows, and mapping it to listed_on would report the Taliban listings of January 2001 as having been made this year; the real listing date is prose, and is read on 1,438 of the 3,906 records. And DFAT publishes no document number of any kind — no passport, no national identity number, no company registration — so the only identifier on the list is an IMO number on 344 vessel rows, which makes a clean Australian result weaker evidence than a clean OFAC one for the same reason a Canadian one is. The endpoint also rejects this gem's User-Agent outright: its edge drops a request whose leading product token it does not recognise, so this source sends the configured agent inside Mozilla/5.0 (compatible; …) — the same identification, in a shape the edge parses. It is the only place any source departs from Sources::Base.
  • Parsers::Spreadsheet, a reusable .xlsx toolkit, and no new dependency. Australia publishes its list as a spreadsheet and as nothing else, so reading one is the price of screening against Australian sanctions at all — but an .xlsx is a ZIP of XML parts, zlib is stdlib and this gem already reads XML, so what was missing was a ZIP header unpacker. It resolves the shared string table and reads xl/styles.xml, which is not optional: 18798 is a date if the cell is formatted as one and a year if it is not, and the Australian list has 4,183 of the first and 2,709 of the second in the same column. Date cells arrive as ISO 8601 at the precision the cell displays, which PartialDate::Parser reads directly.
  • Two spellings added to countries.txt — Democratic People's Republic of Korea (North Korea) and Slovak Republic — which resolve the only 2 of Australia's 77 nationality values that previously did not. Purely additive: no existing spelling resolves differently.
  • docs/adding_a_source.md, the end-to-end walkthrough for an eighth (#17).

Storage

  • Storage::Base, five methods, and nothing on the query path naming a concrete store; plus Storage::Memory (#23).
  • Storage::FileSystem, the gzipped-JSON default, committing with one atomic rename so an interrupted sync leaves the previous list intact (#24).
  • Storage::ActiveRecord, optional, with an indexed prefilter and a Rails install generator. ActiveRecord is not a dependency of this gem and the adapter loads only where a host has already loaded it (#25).
  • The shared "a storage adapter" conformance group, and a spec that holds it to being able to fail.
  • The bundle format: one list in one file that a different machine loads and trusts without reaching the publisher, specified byte for byte in docs/bundle_format.md so that it can be produced and read outside Ruby (#57). ActiveSanction.export writes one and .import loads it, over Snapshot::Bundle.write/.read. Deterministic — the same snapshot always produces the same bytes, since records are ordered by content, header keys have a fixed order, the compression level is named by the specification and nothing in the file says when it was written — so two mirrors of one list are comparable.
  • Detached signatures over a bundle, openssl and nothing else. The signature covers the header, which carries a digest of every record, so an unknown signer is refused before a byte of what they sent is decompressed. Verification is opt-in and unsigned bundles stay fully usable; a tampered file, a file signed by the wrong key, an unsigned file somebody asked to verify, and a file from a newer gem each raise a different error, because each has a different fix.
  • Snapshot#trusted? and MatchResult#verified?, so a screening decision records whether the list that answered it was attested. Deliberately in-memory: a signature covers a bundle's bytes, not the copy a store rewrites into its own layout, so trusted? does not survive a write to disk. MatchResult gains verified as the sixth field of its reproducibility stamp — an addition to the serialized shape, which a record written without it reads back as false.

Matching

  • Normalizer: Unicode NFKD, mark stripping, casefolding, punctuation, whitespace, and a table for the Latin letters decomposition cannot reach. One code path for the index and the query, memoized and thread-safe (#26).
  • Token dictionaries — legal forms, honorifics, organization stopwords, and a preserve list that always wins — applied per entity type, as editable data files (#27).
  • Jaro-Winkler and Levenshtein (#28), token sort and token set ratios (#29), and Double Metaphone phonetic keys (#30) — all pure Ruby.
  • An inverted index for candidate generation, keyed on tokens and phonetic codes (#31).
  • Scorer: a 0-100 score that is the sum of its reasons, with secondary-identifier adjustments on document numbers, dates of birth and nationality, an absent-is-not-conflict rule, and early exits that are bounds rather than approximations — a thresholded call returns exactly the scores an unthresholded one does (#32).
  • Country, resolving both sides of a nationality comparison against a shipped ISO 3166-1 table, so RU meets Russian Federation and an unrecognized value is absent rather than a contradiction.

The public API

  • Matcher, Query and MatchResult, plus ActiveSanction.screen and .screen_all. A matcher is immutable once built and screens from many threads without a lock; nothing on the query path reads configuration (#33).
  • Every MatchResult carries the snapshot checksum, matcher version, weights and query, and round-trips losslessly through to_h / from_h, so a screening decision can be re-derived by somebody who has neither this process nor this version of the gem.
  • ActiveSanction.sync!: per-source failure isolation, a failed source keeping its previous snapshot with a visible age, polite by-publisher concurrency, and a serializable report with an exit code (#34).
  • ActiveSanction.diff: additions, delistings and amendments between two snapshots, joined by entity id, so a book of business is re-screened against what moved (#35).
  • ActiveSanction.rescreen: who a list change affects, which is the step that turns a diff into an alert (#60). Screening a customer once is a checkbox; the obligation is ongoing, and the naive way to meet it — every subject against every record, every night — costs the whole book times the whole corpus. This costs the book times the handful of records that moved: 10,000 subjects against a typical daily OFAC diff in 1.2 s, against 54 s to screen the same book against the whole list.
    • Subject, a book entry: the host's own stable id plus every evidence field screen accepts, in every spelling it accepts them in. An alert names a customer rather than a name, because a book screened by position cannot survive being filtered, streamed in batches, or containing the same name twice.
    • Rescreen::Alert classifies what happened to the subject's match — newly_listed, delisted, details_changed — and carries a full MatchResult for each side of the change, each stamped with the checksum of the list version it was scored against. Both checksums are on the alert itself, so keeping one is keeping enough to derive the run again, and the prior score is what lets it say a subject moved from 71 to 94 rather than only that it now matches. It round-trips through to_h / from_h like a MatchResult.
    • A delisting raises an alert too. It is a change of status a compliance team has to record, and it is the half a re-screen against new records only would miss.
    • An amendment that does not move the score still raises one. A program added or an address corrected changes what a hit means without changing what it scores, and filtering those would be deciding which sanctions hits a host is willing to miss.
    • A large book streams past a small diff. Subjects are read one at a time and only alerts are kept, the block is called with each alert as it is raised, and one Rescreen is reusable across batches so its index is built once. Nothing here touches the matcher: a rescreen indexes the diff and nothing else, so applying one never costs an index build over the whole corpus.
    • An empty diff does no work at all — not one subject is folded — which is what makes rescreening after every sync affordable. A first sync is a baseline rather than a list of additions, so it raises nothing either.
  • ActiveSanction.doctor: whether a source's format has drifted, aggregated out of the health signals a normal fetch and parse already produce (#68). It catches the format change a sync cannot see — the one where the file still parses cleanly and means something different, which today nothing would notice for months.
    • Field fill rates per source, the check that catches a clean parse of a changed file: 19,321 entities carrying zero passports looks exactly as healthy as 19,321 carrying 23,429 if the only thing counted is records. Measured over the records that could carry the field, so a date of birth is a share of individuals.
    • The baseline is the last stored snapshot, not a threshold committed per adapter — one of those goes stale on its own, and the day somebody widens it to make a build pass is the day it stops being read. Floors declared with floor :remarks_coverage, 0.90 remain as a coarse backstop for a run with nothing to compare against.
    • Column shape assertions for positional files. OFAC ships headerless CSVs, so the declared width catches a column inserted upstream and nothing catches one reordered — which parses cleanly and builds entities out of shifted fields. Parsers::ColumnShape asserts what the values are, not only how many there are.
    • Free-text coverage, warning classes and orphaned child rows compared the same way, with the severities meaning something specific: error is a reading no publisher could produce by changing its list, warn is one a human should look at before the next sync.
    • A serializable Doctor::Report with exit_code for cron and CI, and human-readable to_s for the CLI verb to print. The doctor writes nothing — not the snapshot, not the payload cache, not the conditional-GET validators — so diagnosing a source can never be the reason a later sync decides it is unchanged, and nothing here repairs anything.
  • ActiveSanction.configure, with a working default for every setting and a ConfigurationError raised where a bad value is set rather than three hours into a sync. A setting that does not exist is refused too, and the message names the ones that do.
  • Client, the object a server holds, and the end of process-global configuration (#55). Everything at the module level — ActiveSanction.screen, .sync!, .diff, .doctor — is now sugar over a default client that ActiveSanction.configure populates, so the quickstart is unchanged and a script never has to know this exists. What it buys is what a global could not express: several configurations alive at once.
    • ActiveSanction::Client.new(storage:, sources:, user_agent:, ...) takes every setting configure takes, holds it frozen, and shares nothing with another client — its own store, its own lists, its own index, its own publisher identity. Two clients screen against their own data with no cross-talk, which is what a pinned list version for an audit re-run beside the current one for live traffic, and a source set per tenant, both need.
    • Configuration became that client's value object rather than global state. It is frozen when a client is built, #with derives a mutable copy from a frozen one, and the default store is settled at freeze rather than memoized on first read — so no two threads can race to construct it.
    • The thread-safety contract is written down: a built client and its loaded index are safe to screen from concurrently, and sync! is safe alongside readers but is not concurrent-safe against another sync of the same storage. See the README table.
    • ActiveSanction.reset! (previously reset_configuration!) drops the default client outright, which is what a suite runs between examples.
    • A sync that fans out now carries the configuration it was started under into each worker thread, so a client's User-Agent does not depend on sync_concurrency:; and the source registry is built at load rather than on first write, so two adapters registering from two threads cannot each create half of it.
  • One documented error hierarchy under ActiveSanction::Error, which rescue catches everything this library raises from a public method (#58): ConfigurationError, SourceError (FetchError, ParseError, IntegrityError), StorageError, UnsupportedError, InvalidArgument (QueryError) and MissingKey.
    • Every error carries structured attributes rather than only a message: source_id, status, retryable? and to_h. retryable? is first-class, so a host application builds backoff from a predicate instead of from message strings — a 503 or a timeout is retryable, a 403 or a parse failure is not, and a misconfiguration never is.
    • ParseError says where: line, record or offset, appended to its own message, so a 25 MB payload that turns out not to be XML is diagnosable.
    • The list a failure belongs to is stamped on as the error leaves the adapter, since the layer that raises usually cannot know it — the HTTP client sees a URL.
    • No public method leaks an exception class from net/http, openssl, csv, rexml, nokogiri, zlib or json, and nothing raises a bare RuntimeError or ArgumentError. InvalidArgument is an ::ArgumentError and MissingKey a ::KeyError, so surrounding code keeps the rescue it already has; ActiveSanction::Error is a module so that both can be in the hierarchy anyway.

Measurement and tooling

  • rake benchmark:rescreen, which measures applying a diff to a book of business against the naive full rescreen it replaces, and sweeps how the cost moves with how much the list did (#60).
  • rake benchmark:accuracy and rake benchmark:latency, an 87-query labeled set, and a committed accuracy report — a diff in benchmark/results/accuracy.md is a change in what this library finds (#37). The default threshold of 75 is where F1 peaks on that set, measured rather than chosen.
  • The upstream canary: a scheduled workflow that fetches all seven lists from their real publishers on weekdays, parses them, and compares what it measures against the baselines committed under .github/baselines (#69). It is ActiveSanction.doctor pointed at a file instead of a snapshot, and it is for the maintainer rather than the operator — a downstream doctor warning that OFAC's remarks vocabulary moved can only ever result in an issue filed here, because the label table lives here.
    • The output is a GitHub issue, one per source, opened with the findings, rewritten by every run that still finds something, and closed by the first run that comes back clean. Deliberately not a red badge: this never runs as part of CI, because a red build should mean our code broke rather than that a source went down.
    • A fetch failure is reported separately from a parse difference, and nothing is opened until two consecutive runs agree about it. Government endpoints 403 a non-browser user agent and block cloud IP ranges, and a canary that cried wolf on one bad afternoon would be muted inside a week. Each run keeps its report as a workflow artifact and the next run confirms against it.
    • The baseline is a committed file, with tolerances per key — 5% on a record count, which moves every business day, and 2% on free-text coverage, which does not move on its own at all. A diff in .github/baselines is a change in what a government publishes, and a clean run opens a rolling pull request keeping those numbers current.
    • It found something on its first run: OFAC's Consolidated list inherited the SDN file's 90% remarks-coverage floor and reads at 80.3%, because the CMIC rows publish a vocabulary — Purchase/Sales For Divestment, Equity Ticker, HKAA Section 5 — that has no equivalent on the SDN file and nothing for this parser to do with it. The floor is now declared on the Consolidated adapter at 0.75, so a first doctor run against a fresh deployment no longer warns about a list that is doing exactly what it always does.
  • Benchmarks for the similarity algorithms, the index and the scorer (#28, #31, #32).
  • Sorbet at typed: strict across lib/, with per-query signatures declared .checked(:tests) and a supported way for a host to turn every runtime check off (#73).
  • CI across every Ruby the gem supports — 3.1, 3.2, 3.3, 3.4 and 4.0, plus a non-blocking ruby-head — with the matrix, .ruby-version, required_ruby_version and RuboCop's TargetRubyVersion held to each other by a spec, so the version a change is developed on cannot again be the one version no build runs (#2, #80).
  • A hermetic suite — an un-stubbed HTTP call fails rather than quietly reaching a government server (#3).
  • The same suite isolates global configuration: ActiveSanction.configure replaces the process-wide default client and nothing put it back, so an example that configured anything decided what every example RSpec ran after it saw, and the build passed or failed on its seed. The configuration is reset after every example (#123).

The documentation site

  • Scaffold, build and deploy for a GitHub Pages site, built with Astro and Starlight and structured by the four Diátaxis quadrants (#103). Dark mode, a responsive sidebar and full-text search come from the theme rather than from anything written here. Sources live in site/, not in docs/ — building from docs/ would ship a config file, a theme and a set of layouts into every application that installs this gem, and docs/ is in the package on purpose. spec/licensing_spec.rb now holds both halves: site/ never ships, and docs/ always does.
  • Navigation is named for what a reader wants, not for the framework. Get started, Guides, Reference, Explanation. Diátaxis is the discipline for whoever writes the pages; nobody arrives at a documentation site wanting a quadrant.
  • Generated API documentation is published under /api/, built by rake doc during deploy and linked from the navigation, so the handwritten reference can point into a signature rather than restating one.
  • A link checker that gates on internal links and anchors, and not on external ones. A link into a page that no longer has that heading still lands somewhere real, at the top, silently — which is the breakage worth catching. External links are counted and not fetched: half of them point at government publishers that 403 a non-browser user agent on purpose, and a build that went red when Treasury rate-limited a runner would be muted inside a week. Same reasoning that keeps the canary out of CI.
  • Every fenced Ruby sample on the site declares itself runnable or illustrative, and spec/site_samples_spec.rb runs the first kind, parses both, and fails on a block that declares neither. An illustrative block has to say why it cannot run. The dangerous sample is not the one somebody marked wrong — it is the one nobody thought about, which looks exactly like a tested one to a reader.
  • Generated pages are skipped as a source of links, while staying a valid target. YARD renders the README as its index page, where every relative link in it resolves against the repository rather than against the site; crawling that output reported 126 broken links that were all correct where they were written. The navigation's own link to /api/ is still checked, which is what catches a deploy that forgot to copy it.
  • Deploys from a workflow rather than from a branch, so the build runs the link check before anything is published and a pull request gets the same check without publishing. rake site:check reproduces that whole sequence locally, in the same order. The site pins its own Node, deliberately separate from the gem's 3.1-to-4.0 Ruby matrix: an Astro release must never be the reason the library's build goes red.

Instructions for a coding agent

  • Two skills under .claude/skills/, so an agent asked to add a sanctions list, or to fix one whose publisher changed its format, arrives with the procedure already loaded instead of inferring it (#113). adding-a-source is the walkthrough compressed into a working order; repairing-a-source is the canary-to-fix loop. Two rather than one, because they are two jobs with different failure modes: adding a list goes wrong by guessing at a rule nobody would guess, and repairing one goes wrong by fixing the parse while quietly changing what a field means.
  • The reason this is in the gem rather than in somebody's dotfiles is that docs/adding_a_source.md is 1,083 lines written for a human reading it start to finish, and an agent does not read it that way. It greps, finds section 5, and misses the three rules in section 6 that make an id stable — and the failure is silent, because the adapter parses, the spec passes, and the ids change on every sync. A skill costs a contributor who does not use one nothing, sits next to the code it describes, and goes stale visibly.
  • One copy of the rules, in .claude/rules/adapter-rules.md, which both skills read: every date is a PartialDate, an id never depends on anything outside the record's own bytes, the publisher's free text is kept verbatim, a fixture is trimmed from a real published file and never written by hand, and no skill fetches a government endpoint on its own — publishers 403 non-browser user agents, so the command is named and a person runs it. Each rule links to the section of docs/adding_a_source.md that argues for it rather than restating the argument.
  • spec/agent_instructions_spec.rb is what makes "goes stale visibly" true. Every relative link in an agent-facing document has to resolve and every anchor has to name a heading that still exists, because a pointer into a retitled section still lands somewhere real, at the top, silently — the same breakage the site's link checker exists for. It also holds the skill frontmatter to its shape, including the unquoted # that YAML reads as a comment and truncates a description at without an error anywhere.
  • AGENTS.md for tools that do not read Claude skills, as a thin pointer to CONTRIBUTING.md rather than a third copy of the same rules.
  • None of it ships. .claude/ was already excluded by the gemspec's leading-dot rule and AGENTS.md is now named alongside Gemfile and Rakefile: both are instructions for working on this repository and say nothing to an application that installed the gem.

API stability, and what may change

  • The public surface is enumerated rather than inferred, in docs/api_stability.md (#62). Roughly 500 constants are reachable from ActiveSanction; 137 are promised. The rest are marked @api private, hidden from the rendered documentation, and may be renamed or removed in a patch release. Every constant a user can reach is one somebody will reach, and without a stated boundary an internal becomes load-bearing by accident.
  • spec/api_surface_spec.rb fails the build when the code and that document disagree in either direction — a new public constant nobody wrote down, or a name written down that no longer exists. Widening the surface is now a diff somebody reviewed rather than a side effect of adding a class.
  • SemVer, with the pre-1.0 rule said out loud. Before 1.0 a minor version may break the public API — 0.4.0 may remove what 0.3.0 promised, which is what the leading zero means — and a patch release never does.
  • ActiveSanction::Deprecation, and one full minor release of overlap. Something deprecated in 0.4.0 works through all of 0.5.x and may be removed in 0.6.0, so an application upgrading one minor at a time always meets the warning at least one release before the breakage. removal_for computes that version rather than leaving it to be remembered.
    • Warnings go through Kernel#warn with category: :deprecated, so Warning[:deprecated] = false silences them — the line a host already has in their spec_helper, rather than a setting of ours they would have to discover.
    • Each call site warns once however many times it is reached: a deprecated method called while looping over 19,000 records writes one line, not 19,000. Working out which call site took some care, because sig wraps every method here and Sorbet's validation wrapper both hides the real caller and moves once the fast path is swapped in.
  • Sources::Base, Storage::Base and ValidatorStore carry the strongest guarantee. Breaking one forks every adapter written outside this repository at once, and those authors are not reading these release notes. The two conformance groups are the executable statement of what each requires.
  • rake doc now renders the public surface only, at 100% documented. The 140 constants YARD reported as undocumented were internal — column names, regex fragments, the MEMBERS lists a value object serializes through — and the answer to them was a boundary rather than 140 comments restating their names (#38).
  • Named as deliberately not public: the matching internals (Index, Similarity, Phonetics, and the scorer's Adjustments and NameScore), HttpClient, Fetcher and PayloadCache as classes though their errors are public, every MEMBERS list, the per-adapter Record classes, and the parser toolkits' readers and backends.

Governance

  • Contributions are accepted under the Developer Certificate of Origin — a Signed-off-by trailer, which git commit -s writes and which a CI job checks on every pull request, printing the rebase that fixes a branch already pushed (#61). There is no contributor licence agreement. The commercial advantage here is operational rather than code secrecy, so there is no right to relicense worth reserving, and a contributor pays one flag instead of a signature.
  • The licence stays MIT, and that was a decision rather than a default. A move to Apache-2.0 was considered and declined: this library implements no patentable technique — Jaro-Winkler, Levenshtein, token-set ratios and Double Metaphone are all long published and none encumbered — so an express patent grant would defend against a thicket that does not exist, and MIT is the lowest-friction signal in an ecosystem that is overwhelmingly MIT. spec/licensing_spec.rb holds LICENSE.txt, the gemspec and the README to saying the same thing, because a licence file and a spec.license that disagree are read by different audiences and neither one notices.
  • CONTRIBUTING.md and SECURITY.md, both shipped inside the gem rather than only on GitHub — a dependency is often audited from a vendored bundle or an air-gapped host, and the address to report a vulnerability to is exactly what that reader is looking for.
  • SECURITY.md treats a false negative as a security bug. A screening library that fails to report a listed name is not merely inaccurate; somebody may be relying on an empty result to clear a payment. So a systematic screening bypass, a bundle signature that verifies when it should not, and any path by which a screened name leaves the host process are all in scope for private disclosure — while a single wrong score stays a public issue and a row in the labeled set.
  • Issue forms for a bug, a name this version gets wrong, and a list the gem does not read yet; a pull request template; and CODEOWNERS.
  • A trademark note in the README and CONTRIBUTING.md. MIT grants no rights in the name either way — this says so out loud, so a fork does not have to guess. The bundle format stays open and unencumbered: anyone can produce one.

Known limitations at this release

Documented in full in the README under Known data limitations, per source, and summarized here because they are what a reader of a first release most needs:

  • Seven lists, across six jurisdictions: ofac_sdn, ofac_consolidated, un_consolidated, canada_sema, eu_fsf, uk_sanctions_list and australia_dfat. Nowhere else is read, and a name absent from all seven has been screened against those seven and nothing more.
  • Non-Latin script is not transliterated. A Cyrillic name matches a Cyrillic query and nothing else.
  • OFAC's secondary identifiers come from heuristic parsing of free text, at 97.3% segment coverage.
  • Canada publishes no nationality, address, place of birth or document number at all, and no identifier of its own — so its ids are synthetic, and a clean Canadian result is weaker evidence than a clean OFAC one.
  • The EU publishes no primary name and no alias-quality column, so which of a record's names is called primary is this library's rule rather than the Commission's, and most EU aliases arrive ungraded. Its endpoint ignores conditional GET, so every sync of it transfers 25.7 MB.
  • The labeled accuracy set covers four of the seven lists — OFAC SDN, OFAC Consolidated, UN and Canada — so the committed recall and precision figures describe those. The EU, UK and Australian adapters each have their own spec, and the conformance group holds all seven to the same floor, but the accuracy report does not yet speak for the last three.
  • Recall at the default threshold is 0.939 overall on the labeled set, and every record this version misses is named in the committed accuracy report.