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" endThey were always the stated contract —
docs/api_stability.mdcalls them "the executable statement of what [the extension points] require" — and they lived underspec/, 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 underspec/fixturesunlessActiveSanction::Testing.fixture_rootsays 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 releasein the Actions tab takes a version, bumpsVERSION, moves the accruedUnreleasedsection under a dated heading, fixes the changelog's link definitions, regenerates the site data that carries the version, and opens a pull request.release.ymlverifies 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 andbin/prepare_release 1.2.3does 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 emptyUnreleased— whichrelease.ymlalso refuses, but only after the gem is published. Nothing is tagged or pushed tomain: 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 rescuingNoMethodError. This client supports all ofClient::CAPABILITIES; the method is for the implementations that are not this class — one answering screening questions against data somebody else keeps fresh has nosync!to offer. An unrecognised capability isfalserather than an error, deliberately unlikeConfiguration: 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 finishedActiveSanction::Instrumentation::Eventfor each of:fetch,:parse,:store,:"index.build",:screenand: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 indocs/api_stability.mdand 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.
nilis 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, andrake benchmark:latencyreports 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 intoActiveSupport::Notificationsunder<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 raisesConfigurationErrorrather 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. AMatchertakes 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:parseevent counts warnings for every source, which a count that is sometimes aNoMethodErrorcannot 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. Pushingv1.2.3re-runs the three gates against the tagged tree, checks that the tag andVERSIONagree, that the tag is an ancestor ofmainand 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 pushexiting 0 says the upload was accepted rather than that abundle installwill 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
mainand writes the tag only once they have all passed. That is the safer order thangit 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. SeeCONTRIBUTING.md.
Fixed
-
rake canary:refreshregenerates the catalogue page's data as well as the baselines.site/src/data/sources.jsonis derived from.github/baselines(#104), so accepting new numbers without rebuilding it left the two disagreeing andspec/site_sources_data_spec.rbred. 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 inremarks(#4).Namewith alias kind and quality (#5),PartialDatefor the year-only, approximate and ranged dates these lists actually publish (#6), andAddressandIdentifier(#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
HttpClientwith 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), andParsers::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/1962is a year,00/00/1975is 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 whyName#scriptis 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— andPalestinianandOccupied 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.xlsxredirects to a.xlslast 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 tolisted_onwould 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 insideMozilla/5.0 (compatible; …)— the same identification, in a shape the edge parses. It is the only place any source departs fromSources::Base. Parsers::Spreadsheet, a reusable.xlsxtoolkit, 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.xlsxis a ZIP of XML parts,zlibis stdlib and this gem already reads XML, so what was missing was a ZIP header unpacker. It resolves the shared string table and readsxl/styles.xml, which is not optional:18798is 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, whichPartialDate::Parserreads directly.- Two spellings added to
countries.txt—Democratic People's Republic of Korea (North Korea)andSlovak 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; plusStorage::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.mdso that it can be produced and read outside Ruby (#57).ActiveSanction.exportwrites one and.importloads it, overSnapshot::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,
openssland 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?andMatchResult#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, sotrusted?does not survive a write to disk.MatchResultgainsverifiedas the sixth field of its reproducibility stamp — an addition to the serialized shape, which a record written without it reads back asfalse.
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, soRUmeetsRussian Federationand an unrecognized value is absent rather than a contradiction.
The public API
Matcher,QueryandMatchResult, plusActiveSanction.screenand.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
MatchResultcarries the snapshot checksum, matcher version, weights and query, and round-trips losslessly throughto_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 fieldscreenaccepts, 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::Alertclassifies what happened to the subject's match —newly_listed,delisted,details_changed— and carries a fullMatchResultfor 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 throughto_h/from_hlike aMatchResult.- 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
Rescreenis 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.90remain 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::ColumnShapeasserts 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:
erroris a reading no publisher could produce by changing its list,warnis one a human should look at before the next sync. - A serializable
Doctor::Reportwithexit_codefor cron and CI, and human-readableto_sfor 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 aConfigurationErrorraised 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 thatActiveSanction.configurepopulates, 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 settingconfiguretakes, 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.Configurationbecame that client's value object rather than global state. It is frozen when a client is built,#withderives 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!(previouslyreset_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, whichrescuecatches everything this library raises from a public method (#58):ConfigurationError,SourceError(FetchError,ParseError,IntegrityError),StorageError,UnsupportedError,InvalidArgument(QueryError) andMissingKey.- Every error carries structured attributes rather than only a message:
source_id,status,retryable?andto_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. ParseErrorsays where:line,recordoroffset, 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,zliborjson, and nothing raises a bareRuntimeErrororArgumentError.InvalidArgumentis an::ArgumentErrorandMissingKeya::KeyError, so surrounding code keeps the rescue it already has;ActiveSanction::Erroris a module so that both can be in the hierarchy anyway.
- Every error carries structured attributes rather than only a message:
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:accuracyandrake benchmark:latency, an 87-query labeled set, and a committed accuracy report — a diff inbenchmark/results/accuracy.mdis 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 isActiveSanction.doctorpointed 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/baselinesis 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 firstdoctorrun 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: strictacrosslib/, 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_versionand RuboCop'sTargetRubyVersionheld 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.configurereplaces 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 indocs/— building fromdocs/would ship a config file, a theme and a set of layouts into every application that installs this gem, anddocs/is in the package on purpose.spec/licensing_spec.rbnow holds both halves:site/never ships, anddocs/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 byrake docduring 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
runnableorillustrative, andspec/site_samples_spec.rbruns 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:checkreproduces 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-sourceis the walkthrough compressed into a working order;repairing-a-sourceis 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.mdis 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 aPartialDate, 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 ofdocs/adding_a_source.mdthat argues for it rather than restating the argument. spec/agent_instructions_spec.rbis 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.mdfor tools that do not read Claude skills, as a thin pointer toCONTRIBUTING.mdrather than a third copy of the same rules.- None of it ships.
.claude/was already excluded by the gemspec's leading-dot rule andAGENTS.mdis now named alongsideGemfileandRakefile: 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 fromActiveSanction; 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.rbfails 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.0may remove what0.3.0promised, which is what the leading zero means — and a patch release never does. -
ActiveSanction::Deprecation, and one full minor release of overlap. Something deprecated in0.4.0works through all of0.5.xand may be removed in0.6.0, so an application upgrading one minor at a time always meets the warning at least one release before the breakage.removal_forcomputes that version rather than leaving it to be remembered.- Warnings go through
Kernel#warnwithcategory: :deprecated, soWarning[:deprecated] = falsesilences them — the line a host already has in theirspec_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
sigwraps every method here and Sorbet's validation wrapper both hides the real caller and moves once the fast path is swapped in.
- Warnings go through
Sources::Base,Storage::BaseandValidatorStorecarry 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 docnow renders the public surface only, at 100% documented. The 140 constants YARD reported as undocumented were internal — column names, regex fragments, theMEMBERSlists 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'sAdjustmentsandNameScore),HttpClient,FetcherandPayloadCacheas classes though their errors are public, everyMEMBERSlist, the per-adapterRecordclasses, and the parser toolkits' readers and backends.
Governance
- Contributions are accepted under the
Developer Certificate of Origin — a
Signed-off-bytrailer, whichgit commit -swrites 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.rbholdsLICENSE.txt, the gemspec and the README to saying the same thing, because a licence file and aspec.licensethat disagree are read by different audiences and neither one notices. CONTRIBUTING.mdandSECURITY.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.mdtreats 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_listandaustralia_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.