active_sanction 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +538 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/CONTRIBUTING.md +312 -0
- data/LICENSE.txt +21 -0
- data/README.md +616 -0
- data/SECURITY.md +97 -0
- data/active_sanction.gemspec +98 -0
- data/docs/adding_a_source.md +1083 -0
- data/docs/api_stability.md +357 -0
- data/docs/bundle_format.md +407 -0
- data/lib/active_sanction/address.rb +170 -0
- data/lib/active_sanction/client.rb +341 -0
- data/lib/active_sanction/configuration.rb +721 -0
- data/lib/active_sanction/countries.txt +292 -0
- data/lib/active_sanction/country.rb +164 -0
- data/lib/active_sanction/deprecation.rb +168 -0
- data/lib/active_sanction/diff/change.rb +208 -0
- data/lib/active_sanction/diff.rb +348 -0
- data/lib/active_sanction/doctor/checkup.rb +356 -0
- data/lib/active_sanction/doctor/diagnosis.rb +245 -0
- data/lib/active_sanction/doctor/finding.rb +183 -0
- data/lib/active_sanction/doctor/profile.rb +384 -0
- data/lib/active_sanction/doctor/report.rb +227 -0
- data/lib/active_sanction/doctor.rb +347 -0
- data/lib/active_sanction/entity.rb +293 -0
- data/lib/active_sanction/error.rb +332 -0
- data/lib/active_sanction/fetcher/result.rb +116 -0
- data/lib/active_sanction/fetcher.rb +242 -0
- data/lib/active_sanction/http_client/errors.rb +94 -0
- data/lib/active_sanction/http_client/response.rb +129 -0
- data/lib/active_sanction/http_client.rb +324 -0
- data/lib/active_sanction/identifier.rb +212 -0
- data/lib/active_sanction/index/builder.rb +89 -0
- data/lib/active_sanction/index/candidate.rb +63 -0
- data/lib/active_sanction/index/entry.rb +66 -0
- data/lib/active_sanction/index/features.rb +112 -0
- data/lib/active_sanction/index.rb +381 -0
- data/lib/active_sanction/match_result.rb +345 -0
- data/lib/active_sanction/matcher.rb +388 -0
- data/lib/active_sanction/name.rb +183 -0
- data/lib/active_sanction/normalizer/cache.rb +82 -0
- data/lib/active_sanction/normalizer/dictionaries/honorifics.txt +30 -0
- data/lib/active_sanction/normalizer/dictionaries/legal_forms.txt +72 -0
- data/lib/active_sanction/normalizer/dictionaries/organization_stopwords.txt +20 -0
- data/lib/active_sanction/normalizer/dictionaries/particles.txt +35 -0
- data/lib/active_sanction/normalizer/dictionary/stoplist.rb +121 -0
- data/lib/active_sanction/normalizer/dictionary.rb +243 -0
- data/lib/active_sanction/normalizer/form.rb +279 -0
- data/lib/active_sanction/normalizer.rb +137 -0
- data/lib/active_sanction/parsers/column_shape.rb +274 -0
- data/lib/active_sanction/parsers/delimited_table/reader.rb +194 -0
- data/lib/active_sanction/parsers/delimited_table/row.rb +81 -0
- data/lib/active_sanction/parsers/delimited_table.rb +151 -0
- data/lib/active_sanction/parsers/format.rb +107 -0
- data/lib/active_sanction/parsers/join.rb +149 -0
- data/lib/active_sanction/parsers/spreadsheet/archive.rb +258 -0
- data/lib/active_sanction/parsers/spreadsheet/reader.rb +213 -0
- data/lib/active_sanction/parsers/spreadsheet/row.rb +88 -0
- data/lib/active_sanction/parsers/spreadsheet/workbook.rb +314 -0
- data/lib/active_sanction/parsers/spreadsheet.rb +168 -0
- data/lib/active_sanction/parsers/xml_records/backends/nokogiri.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/backends/rexml.rb +157 -0
- data/lib/active_sanction/parsers/xml_records/backends.rb +103 -0
- data/lib/active_sanction/parsers/xml_records/builder.rb +90 -0
- data/lib/active_sanction/parsers/xml_records/reader.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/record.rb +174 -0
- data/lib/active_sanction/parsers/xml_records.rb +140 -0
- data/lib/active_sanction/parsers.rb +94 -0
- data/lib/active_sanction/partial_date/parser.rb +124 -0
- data/lib/active_sanction/partial_date.rb +303 -0
- data/lib/active_sanction/payload_cache/checksum.rb +66 -0
- data/lib/active_sanction/payload_cache/entry.rb +304 -0
- data/lib/active_sanction/payload_cache.rb +366 -0
- data/lib/active_sanction/phonetics/double_metaphone.rb +662 -0
- data/lib/active_sanction/phonetics.rb +40 -0
- data/lib/active_sanction/query.rb +290 -0
- data/lib/active_sanction/rescreen/alert.rb +314 -0
- data/lib/active_sanction/rescreen.rb +332 -0
- data/lib/active_sanction/scorer/adjustments.rb +241 -0
- data/lib/active_sanction/scorer/name_score.rb +279 -0
- data/lib/active_sanction/scorer/reason.rb +136 -0
- data/lib/active_sanction/scorer/result.rb +129 -0
- data/lib/active_sanction/scorer/subject.rb +235 -0
- data/lib/active_sanction/scorer/weights.rb +306 -0
- data/lib/active_sanction/scorer.rb +303 -0
- data/lib/active_sanction/similarity/jaro_winkler.rb +210 -0
- data/lib/active_sanction/similarity/levenshtein.rb +179 -0
- data/lib/active_sanction/similarity/token_set.rb +159 -0
- data/lib/active_sanction/similarity/token_sort.rb +120 -0
- data/lib/active_sanction/similarity.rb +183 -0
- data/lib/active_sanction/snapshot/bundle/header.rb +302 -0
- data/lib/active_sanction/snapshot/bundle/payload.rb +185 -0
- data/lib/active_sanction/snapshot/bundle/signature.rb +201 -0
- data/lib/active_sanction/snapshot/bundle.rb +381 -0
- data/lib/active_sanction/snapshot.rb +305 -0
- data/lib/active_sanction/sources/australia_dfat/published_date.rb +166 -0
- data/lib/active_sanction/sources/australia_dfat/record.rb +319 -0
- data/lib/active_sanction/sources/australia_dfat.rb +291 -0
- data/lib/active_sanction/sources/base.rb +295 -0
- data/lib/active_sanction/sources/canada_sema/record.rb +282 -0
- data/lib/active_sanction/sources/canada_sema/source_ref.rb +89 -0
- data/lib/active_sanction/sources/canada_sema.rb +153 -0
- data/lib/active_sanction/sources/definition.rb +360 -0
- data/lib/active_sanction/sources/eu_fsf/record.rb +467 -0
- data/lib/active_sanction/sources/eu_fsf.rb +203 -0
- data/lib/active_sanction/sources/ofac/record.rb +251 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/coverage.rb +120 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/vocabulary.rb +121 -0
- data/lib/active_sanction/sources/ofac/remarks_parser.rb +312 -0
- data/lib/active_sanction/sources/ofac.rb +248 -0
- data/lib/active_sanction/sources/ofac_consolidated/record.rb +37 -0
- data/lib/active_sanction/sources/ofac_consolidated.rb +203 -0
- data/lib/active_sanction/sources/ofac_sdn.rb +43 -0
- data/lib/active_sanction/sources/remarks.rb +84 -0
- data/lib/active_sanction/sources/uk_sanctions_list/published_date.rb +104 -0
- data/lib/active_sanction/sources/uk_sanctions_list/record.rb +412 -0
- data/lib/active_sanction/sources/uk_sanctions_list.rb +214 -0
- data/lib/active_sanction/sources/un_consolidated/record.rb +279 -0
- data/lib/active_sanction/sources/un_consolidated.rb +140 -0
- data/lib/active_sanction/sources.rb +196 -0
- data/lib/active_sanction/storage/active_record/reader.rb +124 -0
- data/lib/active_sanction/storage/active_record/row.rb +175 -0
- data/lib/active_sanction/storage/active_record/writer.rb +141 -0
- data/lib/active_sanction/storage/active_record.rb +277 -0
- data/lib/active_sanction/storage/base.rb +216 -0
- data/lib/active_sanction/storage/file_system.rb +373 -0
- data/lib/active_sanction/storage/memory.rb +67 -0
- data/lib/active_sanction/storage/meta.rb +188 -0
- data/lib/active_sanction/storage.rb +74 -0
- data/lib/active_sanction/subject.rb +271 -0
- data/lib/active_sanction/sync/report.rb +245 -0
- data/lib/active_sanction/sync/result.rb +296 -0
- data/lib/active_sanction/sync.rb +390 -0
- data/lib/active_sanction/validator_store/file_system.rb +103 -0
- data/lib/active_sanction/validator_store/memory.rb +41 -0
- data/lib/active_sanction/validator_store.rb +119 -0
- data/lib/active_sanction/validators.rb +195 -0
- data/lib/active_sanction/version.rb +23 -0
- data/lib/active_sanction.rb +331 -0
- data/lib/generators/active_sanction/install/install_generator.rb +60 -0
- data/lib/generators/active_sanction/install/templates/create_active_sanction_tables.rb.tt +142 -0
- metadata +236 -0
data/README.md
ADDED
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://babystep.tech/active_sanction/logo.svg" alt="" width="180" height="180">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# ActiveSanction
|
|
6
|
+
|
|
7
|
+
[](https://github.com/Babystep-Technologies/active_sanction/actions/workflows/ci.yml)
|
|
8
|
+
|
|
9
|
+
**[Full documentation, guides and the source catalogue →](https://babystep.tech/active_sanction/)**
|
|
10
|
+
|
|
11
|
+
Screen a name against government sanctions lists, in Ruby, in your own process.
|
|
12
|
+
|
|
13
|
+
ActiveSanction fetches the lists a jurisdiction publishes, parses each of them into one record model, stores them where you tell it to, and scores a name against them with an account of every point it awarded. The lists live on your disk or in your database, screening runs in your process, and no name you screen leaves it.
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
ActiveSanction.sync!
|
|
17
|
+
|
|
18
|
+
ActiveSanction.screen(name: "Bosco Ntaganda", type: :individual, date_of_birth: "1973")
|
|
19
|
+
# => [#<MatchResult score=100.0 source=:un_consolidated matched_name="Bosco Ntaganda">]
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## A screening aid, not legal advice
|
|
23
|
+
|
|
24
|
+
**This library is a screening aid. It is not legal advice, and it is not a compliance program.**
|
|
25
|
+
|
|
26
|
+
**Verify every hit against the official published list before acting on it.** What this gem produces is one library's reading of a government file. The government's own published list is the record that counts, and a report generated here has no standing of its own.
|
|
27
|
+
|
|
28
|
+
**A clean result is not a legal clearance.** No name matcher finds everything. A spelling this version does not reach, a jurisdiction this gem does not yet read, a designation published an hour after your last sync — each of them returns an empty array, and an empty array means *nothing over the threshold in the lists we currently hold*. It does not mean *not sanctioned*. [`benchmark/results/accuracy.md`](benchmark/results/accuracy.md) names every record this version misses and every one it wrongly alerts on, because a user deciding whether to rely on this is entitled to see them.
|
|
29
|
+
|
|
30
|
+
**You remain responsible for your own compliance obligations.** Which lists to screen, at what threshold, how often, what to do with a hit, and what your regulator expects of you are decisions this library cannot make and does not make. A false negative here can mean a sanctions violation, and the liability for one stays with you.
|
|
31
|
+
|
|
32
|
+
## The gem, and the service
|
|
33
|
+
|
|
34
|
+
**The gem is complete on its own.** Every supported list is fetchable, parseable and screenable locally, forever, with no account and no key. Nothing is withheld to make anything else more attractive: the matching engine, every adapter, the weights, the dictionaries and the measured accuracy of all of it are in this repository, and they are the whole of what we run.
|
|
35
|
+
|
|
36
|
+
**A commercial hosted service exists, and what it sells is operations rather than capability** — data kept fresh through upstream breakage, an availability guarantee, continuous monitoring of a book of business, and a retained audit trail. Upgrading is one configuration line ([#56](https://github.com/Babystep-Technologies/active_sanction/issues/56)). Not upgrading costs no capability; it costs the work of running the sync yourself, and noticing when a publisher changes its format.
|
|
37
|
+
|
|
38
|
+
## What it does
|
|
39
|
+
|
|
40
|
+
- **Fetches.** Conditional GET on ETag and Last-Modified, bounded redirects, retries with backoff, a checksum-verified cache of the raw payloads, and a User-Agent that identifies you to the publisher.
|
|
41
|
+
- **Parses.** Seven lists today, into one `Entity`: names and aliases with their kind and quality, dates of birth as `PartialDate` (year-only, approximate and ranged dates are all real on these lists), addresses, document numbers, nationalities, programs — and the publisher's own text kept verbatim in `remarks` whether the parser understood it or not.
|
|
42
|
+
- **Stores.** One checksummed `Snapshot` per source, in gzipped JSON on disk, in your application's database, in memory, or in a store you write. Nothing on the query path names a concrete store.
|
|
43
|
+
- **Publishes.** Any stored list, as one signed, versioned, byte-for-byte reproducible file that another machine loads and trusts without reaching the publisher — for an air-gapped installation, or for the afternoon a government endpoint is down. The [format is specified](docs/bundle_format.md) in enough detail to be implemented outside Ruby.
|
|
44
|
+
- **Screens.** Fold the name, retrieve candidates from an inverted index, score each with four string algorithms and a phonetic pass, adjust on dates of birth, nationalities and document numbers, and report the reasons — which sum to the score exactly.
|
|
45
|
+
- **Diffs.** What changed between two syncs, so a book of business is re-screened against the handful of records that moved rather than against the whole list.
|
|
46
|
+
- **Rescreens.** Applies that diff to a book of business and reports who it affects — newly listed, delisted, or listed under details that moved — with the prior score, both snapshot checksums, and the caller's own id on every alert.
|
|
47
|
+
- **Diagnoses.** Whether a list still parses the way we think it does — field fill rates, the free-text vocabulary, the shape of a positional column — measured against the version stored at the last sync, because the dangerous format change is the one where the file still parses cleanly and means something different.
|
|
48
|
+
- **Stamps.** Every result carries the snapshot checksum, the matcher version, the weights, the query, and whether the list that answered was a signed bundle that verified — so a screening decision made today can be re-derived in three years by somebody who has neither this process nor this version of the gem.
|
|
49
|
+
|
|
50
|
+
## What it does not do
|
|
51
|
+
|
|
52
|
+
- **It does not decide anything.** A score is evidence for a human. The threshold at which a score becomes an alert, and what happens to that alert, are policy your compliance function owns.
|
|
53
|
+
- **It is not a case management system.** No alert queue, no dispositions, no audit store. It produces the record; keeping it is your application's job.
|
|
54
|
+
- **It screens names against lists, and nothing more.** No politically-exposed-person data, no adverse media, no beneficial ownership, no OFAC 50 Percent Rule resolution — a subsidiary that is sanctioned only by virtue of its owners is not on any of these files and will not be found here.
|
|
55
|
+
- **Seven lists ship: two US, one UN, one Canada, one EU, one UK, one Australia.** If your obligations cover a jurisdiction outside that set, this gem does not cover them.
|
|
56
|
+
- **Non-Latin script is not transliterated.** `Путин` does not fold to `putin`; a Cyrillic name matches a Cyrillic query and nothing else. What makes it survivable is that these publishers ship a romanized name alongside the original — see [Normalizing a name](https://babystep.tech/active_sanction/explanation/how-matching-works/#normalizing-a-name) for what that does and does not leave open.
|
|
57
|
+
- **It does not monitor your deployment.** It syncs when you tell it to, and it diagnoses when you tell it to. `ActiveSanction.doctor` will notice that a publisher changed its format, but only in a job you schedule — nothing in your process runs overnight on its own, and nothing wakes anybody when it finds something. What does run overnight is [the upstream canary](https://babystep.tech/active_sanction/how-to/detecting-format-drift/#the-upstream-canary-the-same-idea-run-on-a-schedule-against-real-endpoints), on this repository rather than on yours: it watches the seven published lists on weekdays and files an issue here when one of them changes, which is how the adapters get fixed — but it knows nothing about whether *your* sync ran ([#69](https://github.com/Babystep-Technologies/active_sanction/issues/69)).
|
|
58
|
+
- **There is no CLI.** It is a library, called from an initializer, a rake task or a job.
|
|
59
|
+
|
|
60
|
+
## Installation
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
gem "active_sanction"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
$ bundle install
|
|
67
|
+
|
|
68
|
+
or
|
|
69
|
+
|
|
70
|
+
$ gem install active_sanction
|
|
71
|
+
|
|
72
|
+
Ruby 3.1 or newer. The dependencies are `csv`, `rexml` and `sorbet-runtime` — all pure Ruby, so nothing here builds a native extension or asks a deployment to. Nokogiri is supported as an XML backend and is deliberately not a dependency; see `xml_backend` under [Configuration](#configuration). Australia publishes its list as an Excel workbook and no spreadsheet gem was added for it either — see [The Australian Consolidated List](#the-australian-consolidated-list).
|
|
73
|
+
|
|
74
|
+
## Quickstart
|
|
75
|
+
|
|
76
|
+
**Configure.** One setting is worth setting; everything else has a working default.
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
# config/initializers/active_sanction.rb
|
|
80
|
+
ActiveSanction.configure do |c|
|
|
81
|
+
c.user_agent = "acme-bank/1.0 (compliance@acme.example)"
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
OFAC answers a request with no User-Agent with a 403, so this can never be blank — and a publisher that needs to reach whoever is hammering its endpoint can otherwise only reach this repository. Identify yourself.
|
|
86
|
+
|
|
87
|
+
**Sync.** Download every configured list and store it.
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
report = ActiveSanction.sync!
|
|
91
|
+
puts report
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
7 sources in 62.31s: 7 updated
|
|
96
|
+
australia_dfat updated 3906 records just fetched 13.33s
|
|
97
|
+
canada_sema updated 5690 records just fetched 3.99s
|
|
98
|
+
eu_fsf updated 6234 records just fetched 14.48s
|
|
99
|
+
ofac_consolidated updated 481 records just fetched 3.23s
|
|
100
|
+
ofac_sdn updated 19329 records just fetched 9.92s
|
|
101
|
+
uk_sanctions_list updated 6334 records just fetched 13.90s
|
|
102
|
+
un_consolidated updated 1011 records just fetched 3.44s
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The first run downloads each list in full. Later ones ask every publisher whether anything has changed and usually download nothing at all, so a sync scheduled hourly costs a handful of conditional requests on most days. Snapshots land in `~/.active_sanction` unless you name a store. A source that fails does not stop the others and **keeps the list it already had**; `report.failed?` and `report.exit_code` are what a cron job alerts on. See [Syncing every list, and what happens when one is down](#syncing-every-list-and-what-happens-when-one-is-down).
|
|
106
|
+
|
|
107
|
+
**Screen.**
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
results = ActiveSanction.screen(
|
|
111
|
+
name: "Bosco Ntaganda",
|
|
112
|
+
type: :individual,
|
|
113
|
+
date_of_birth: "1973",
|
|
114
|
+
countries: %w[CD]
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
hit = results.first
|
|
118
|
+
hit.score # => 100.0
|
|
119
|
+
hit.source # => :un_consolidated
|
|
120
|
+
hit.matched_name.value # => "Bosco Ntaganda", an alias; the UN
|
|
121
|
+
# publishes him as "BOSCO TAGANDA"
|
|
122
|
+
hit.explanation.map(&:to_s)
|
|
123
|
+
# => ["+100.0 name: matched alias \"Bosco Ntaganda\" (aka)",
|
|
124
|
+
# "+6.0 dob: date of birth 1973 overlaps listed 1973 to 1974",
|
|
125
|
+
# "+6.0 nationality: CD matches",
|
|
126
|
+
# "-12.0 clamp: 112.0 capped to 100.0"]
|
|
127
|
+
|
|
128
|
+
JSON.generate(hit.to_h) # into your audit record
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
An empty array is the ordinary answer — most people are not on a sanctions list. Everything that could make an empty array a lie rather than a fact raises instead: screening a store nobody has synced raises `Matcher::NotSynced`, and naming a list the matcher does not hold raises `Storage::MissingSnapshot` rather than quietly screening two lists when you asked for three.
|
|
132
|
+
|
|
133
|
+
**Keep it fresh.** Sync on a schedule, and drop the shared matcher so the next screening call answers from what was just synced.
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
# lib/tasks/sanctions.rake
|
|
137
|
+
task sync_sanctions: :environment do
|
|
138
|
+
report = ActiveSanction.sync! # calls ActiveSanction.reload! itself when anything changed
|
|
139
|
+
warn report.to_s
|
|
140
|
+
exit report.exit_code # 1 if any source failed
|
|
141
|
+
end
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Then re-screen your book of business against what actually moved, rather than against the whole list:
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
diff = ActiveSanction.diff(:ofac_sdn, from: last_nights_snapshot)
|
|
148
|
+
diff.changed # => [Entity], the additions and amendments to re-screen against
|
|
149
|
+
diff.removed # => [Entity], delistings — the alerts you can now close
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
That is the whole of the working library. Everything below is either a fact about the data you should know before relying on it, or an account of how one of those five calls works.
|
|
153
|
+
|
|
154
|
+
## The lists
|
|
155
|
+
|
|
156
|
+
Seven lists ship today: two US, one UN, one Canada, one EU, one UK, one Australia. The key, jurisdiction, authority, endpoint, format and record count for each — generated from the adapter registry and the canary's last measurement, never hand-typed — are in the [source catalogue](https://babystep.tech/active_sanction/reference/sources/).
|
|
157
|
+
|
|
158
|
+
No publisher commits to a schedule, and none of them announce a change out of band, which is why every fetch here is conditional: asking daily costs one request per file on the days nothing happened. Sync on your own risk appetite rather than on a publisher's calendar.
|
|
159
|
+
|
|
160
|
+
### Known data limitations, per source
|
|
161
|
+
|
|
162
|
+
These are the facts a screening policy has to be built on. They are properties of what the government publishes, not of this parser, and none of them can be fixed downstream.
|
|
163
|
+
|
|
164
|
+
**Both OFAC lists — every secondary identifier is free text.** The SDN CSVs have no column for date of birth, place of birth, nationality or passport number. All of it — 88,827 semicolon-delimited segments, measured against the September 2026 list — is prose in one `Remarks` field, written for a person reading a page. `Sources::Ofac::RemarksParser` reads it heuristically and currently recognizes **97.3%** of those segments. Extraction is additive: a segment nobody has taught it yet costs structure and never content, because `Entity#remarks` keeps the publisher's whole string either way. `source.remarks_coverage` reports the number for the file you actually fetched, and it is worth watching — a drop in it is a publisher changing how it writes.
|
|
165
|
+
|
|
166
|
+
**`ofac_consolidated` — sub-list attribution is derived, and is exact for 478 of 481 records.** OFAC ships six lists (SSI, CMIC, NS-PLC, NS-MBS, CAPTA, FSE) in one file with no column saying which is which, so the program code is what `OfacConsolidated.lists` maps. The three records it does not get exactly right are all `RUSSIA-EO14024` — Gazprom, Transneft and Rosselkhozbank are on both SSI and NS-MBS and come out marked SSI only. A hit is still a hit; which of two US lists it names may be incomplete.
|
|
167
|
+
|
|
168
|
+
**`un_consolidated` — place of birth, gender, title and designation are not structured.** They have no home in the canonical model, so they are appended to `remarks` rather than dropped. They are readable and they are not scored on.
|
|
169
|
+
|
|
170
|
+
**`canada_sema` — the weakest list for corroborating a name match, by some distance.** Global Affairs publishes no nationality, no address, no place of birth and no document number for any of the 5,690 records, and a date of birth for a minority of them. An individual is a surname, given names and a free-text alias field, so there is nothing in a Canadian record to make a name match decisive the way an OFAC passport number usually does. **A clean Canadian result is weaker evidence than a clean OFAC one**, and the measured recall says so: 0.880 against the UN's 1.000.
|
|
171
|
+
|
|
172
|
+
Three further consequences of the same file:
|
|
173
|
+
|
|
174
|
+
* **The ids are ours.** Canada publishes no identifier of any kind, only a citation — which regulation, which schedule, which item — and item numbers get renumbered whenever a schedule is amended. `CanadaSema::SourceRef` hashes the citation *and the name*, which means correcting a typo in a published name re-ids that record and reports in a diff as a delisting plus a listing. Churn in a diff is a nuisance; one id covering two different people would be a screening failure.
|
|
175
|
+
* **Aliases are one comma-joined string with no declared separator, and no kinds.** They are split on semicolons and never on commas, because `Завод "Дагдизель", АО` would otherwise yield a bare Russian legal form as an alias. That costs recall on roughly 300 records whose primary name is published anyway.
|
|
176
|
+
* **`Country-Pays` is not a nationality** and is not read as one. It names the regulation a person is listed under, so it is mapped to `programs`.
|
|
177
|
+
|
|
178
|
+
**`eu_fsf` — no name is marked as the official one, and four birth dates are not Gregorian.** All 31,053 published names are equal `<nameAlias>` elements carrying `strong="true"`, so which one to call primary is this adapter's decision rather than the Commission's — see [The EU consolidated list](#the-eu-consolidated-list) for the rule and what it costs. Separately, `calendarType="ISLAMIC"` appears on four birth dates whose year, month and day are Hijri; three of them carry no Gregorian equivalent and so produce **no date of birth at all**, with the published date kept in `remarks`. A screening policy that expects a date of birth on every EU person will not get one, and that is the safe direction: reading `1343` as a Gregorian year would make a real date of birth *conflict* with the record and push a true hit below the threshold.
|
|
179
|
+
|
|
180
|
+
Two further consequences of the same file:
|
|
181
|
+
|
|
182
|
+
* **Conditional GET does not work, so every sync downloads 25.7 MB.** The endpoint serves `Last-Modified`, sends no `ETag` and `Cache-Control: no-store`, and answers `If-Modified-Since` with 200 and the whole file. The request is still made conditionally, and the parsed content still hashes to the same snapshot checksum, so a re-download of an unchanged list diffs to nothing — but the bandwidth is spent. Budget for it if you sync hourly.
|
|
183
|
+
* **Alias quality is prose, not a column.** The EU publishes no grade. 499 of the 31,053 names carry one in the free-text `<remark>` on the name — "low quality alias", "Good quality a.k.a.", "formerly known as" — which is read; the rest arrive ungraded, which the scorer treats as unstated rather than as good.
|
|
184
|
+
|
|
185
|
+
**`uk_sanctions_list` — the list this one replaced is still on the internet, and still answers 200.** The UK moved every sanctions designation onto one list on 28 January 2026. OFSI's Consolidated List of Asset Freeze Targets stopped being updated that day and its gov.uk page is marked withdrawn — but `ofsistorage.blob.core.windows.net/publishlive/2022format/ConList.csv` still serves a real 16.6 MB file. A screening system pointed at it downloads successfully, parses successfully, reports itself fresh, and screens against a list that has not moved since January. This adapter reads the UK Sanctions List instead. If you have your own integration against `ConList.csv`, that is the thing to check today.
|
|
186
|
+
|
|
187
|
+
**`uk_sanctions_list` — a date component the FCDO does not know is spelled out, not omitted.** 824 of the 3,788 published birth dates — 22% — carry a placeholder where a component goes: `dd/mm/1962` is a year, `dd/07/1978` is a year and a month, and one record spells the same absence with zeros as `00/00/1975`. Read with any ordinary date parser every one of them is nil, and 22% of everything this list says about when a person was born disappears with no warning; read credulously they become invalid dates, or dates the scorer compares as though the FCDO had been precise. `Sources::UkSanctionsList::PublishedDate` reads them at the precision the FCDO stated. One record, `15/08/19yy`, states a day and a month and no year at all: `PartialDate` has no shape for that and it produces **no date of birth**, with the published string kept in `remarks`.
|
|
188
|
+
|
|
189
|
+
Three further consequences of the same file:
|
|
190
|
+
|
|
191
|
+
* **The FCDO's own script labels disagree with its own strings, so `Name#script` is left unstated.** `NonLatinScriptType` is on 2,057 of the 3,856 non-Latin names and agrees with the characters on 2,054 of them — but three names labelled `Cyrillic` are Latin transliterations, and 185 names filed as non-Latin hold no non-Latin character at all. The label and the language are kept in `remarks`, and which script a string is in stays a question about its characters.
|
|
192
|
+
* **A "number" field may be a sentence.** 340 of the 721 business registration numbers open with a label (`INN: 7710137066`), a handful carry several numbers, a country and a newline in one field, and one national identity number reads `Kuwait, number 260012001546`. All of it is kept exactly as published: every rule that peels `INN: ` off the first also has to decide what to do with the rest, and each of those answers is a guess about free text. The exception is a ship's IMO number, where the prefix restates the element it is already inside — `IMO9562233` on 635 of the 670, and bare on the other 35 — and is peeled into the note so that the FCDO's own two spellings of one registry number are one identifier rather than two.
|
|
193
|
+
* **`<CryptoWalletAddresses>` and `<HullIdentificationNumbers>` are in the schema and empty in the data**, so neither is read. They are the first things to add when either appears.
|
|
194
|
+
|
|
195
|
+
**`australia_dfat` — the Control Date is not a listing date, and it is on every row.** DFAT's own [guide to the list](https://www.dfat.gov.au/international-relations/security/sanctions/consolidated-list/guide-australias-consolidated-list) defines it as "the last date the sanction entry was updated or edited on the Consolidated List". It is a real date on all 11,163 rows, it is the only date-typed column in the file, and reading it as `listed_on` would report the Taliban listings of January 2001 as having been made a few months ago — with nothing in a sync report looking wrong. It is kept in `remarks`, labelled. The actual listing date is prose in `Listing Information`, and is read on the **1,438 of 3,906** records that state one; the other 63% name a legislative instrument and no date at all, and get no `listed_on`.
|
|
196
|
+
|
|
197
|
+
Four further consequences of the same file:
|
|
198
|
+
|
|
199
|
+
* **No document number of any kind, for any record.** No passport, no national identity number, no company registration. The only identifier on the whole list is an IMO number, on 344 vessel rows. So an Australian name match has nothing behind it to make it decisive, the way an OFAC passport number usually settles one — the same weakness as the Canadian list, and a screening policy should know it before setting a threshold.
|
|
200
|
+
* **A record is several rows, joined on a reference DFAT suffixes with letters.** `1000` is the primary name, `1000a` and `1000b` its aliases, and every other column is repeated on each — so 11,163 rows are 3,906 records. The repetition is not exact: 80 groups disagree with themselves about the additional information, 39 about the birth dates, 23 about the address. Every column is unioned across the group rather than read off the primary row, because two records have a place of birth, and one an address, only because an alias row carried it.
|
|
201
|
+
* **Birth dates arrive in nine spellings, two of which are only distinguishable through the spreadsheet's styles.** 4,183 are Excel serial numbers, 2,709 are the year somebody was born written as a plain number, and the two are the same kind of cell — a reader that ignores `xl/styles.xml` gets one of the two wrong for every row. The rest are `dd/mm/yyyy` text (day first: DFAT writes Australian dates, and not one of the 2,013 has a middle component above twelve), `mm/yyyy`, `Approximately 1963`, `Approximately: Between 1972 and 1975`, `12 April 1965`, ten-year lists, and pairs separated by a carriage return the workbook escapes as `_x000D_`. Four records out of 3,906 carry a fragment typed wrong at the source — `1980.1981`, `/02/1961`, `7/02/1950/11/1950`, `10/061962` — and each is kept verbatim in `remarks` rather than dropped.
|
|
202
|
+
* **An address is one free-text column and is not decomposed.** DFAT publishes no street, city or country parts, so the whole published string is `Address#street`. Where one cell enumerates several addresses `a) ... b) ...`, which is 859 rows, they are split; a semicolon is not split on, because it appears inside single addresses too.
|
|
203
|
+
|
|
204
|
+
**All seven — non-Latin script is not transliterated.** Cyrillic, Arabic, Han, Kana and Hangul are casefolded and stripped of marks in their own script, and never romanized. These lists publish a non-Latin name as an *additional* variant rather than instead of a Latin one, which is what makes it survivable; the residue is a record carrying one romanization queried with another. See [Normalizing a name](https://babystep.tech/active_sanction/explanation/how-matching-works/#normalizing-a-name), and [the blend, and what it cannot fix](https://babystep.tech/active_sanction/explanation/how-matching-works/#the-blend-and-what-it-cannot-fix).
|
|
205
|
+
|
|
206
|
+
## Reading a score
|
|
207
|
+
|
|
208
|
+
A score is a number from 0 to 100, and it is **the sum of the reasons on the result** — rounded once, with no arithmetic anywhere in this library that can move one without the other. `hit.explanation` is the thing to read; the number is a summary of it, and a 97 that is all name similarity is a different finding from an 82 with a passport number matching even though the score alone does not say so.
|
|
209
|
+
|
|
210
|
+
The default threshold is **75**, measured rather than chosen: it is where F1 peaks against a labeled set of real published records. [How matching works](https://babystep.tech/active_sanction/explanation/how-matching-works/) has the precision/recall/F1 curve, what raising or lowering the threshold costs, and the full account of how a score is built and why absence is never treated as conflict. [Tune the threshold for your risk appetite](https://babystep.tech/active_sanction/how-to/tuning-the-threshold/) is the how-to.
|
|
211
|
+
|
|
212
|
+
## Where the lists live
|
|
213
|
+
|
|
214
|
+
Storage is an interface with five methods, and **nothing on the query path names a concrete store**: `Storage::FileSystem` (the default, gzipped JSON on disk), `Storage::ActiveRecord` (your own database, plus an indexed prefilter), `Storage::Memory`, or one you write yourself. [Choose a storage backend](https://babystep.tech/active_sanction/how-to/choosing-a-store/) has the trade-offs and the conformance group every adapter is held to.
|
|
215
|
+
|
|
216
|
+
A store is where *this* machine keeps its lists. To move one *between* machines — an air-gapped host, or a mirror for the afternoon a publisher is down — export it as a signed bundle: one file, one command, and a signature an auditor can check. See [Publish and verify a signed bundle](https://babystep.tech/active_sanction/how-to/publishing-a-signed-bundle/).
|
|
217
|
+
|
|
218
|
+
## Performance
|
|
219
|
+
|
|
220
|
+
Numbers from `rake benchmark:latency`, `rake benchmark:index` and `rake benchmark:rescreen` on the machine they were last run on, against a corpus the size of the real lists — 47,051 indexed names over 27,000 entities. Your own are one command away; these are here so you can size a deployment before installing anything.
|
|
221
|
+
|
|
222
|
+
| | |
|
|
223
|
+
|---|---|
|
|
224
|
+
| Building the matcher | **3.84 s**, 49 MB resident. Paid once at boot, not per query |
|
|
225
|
+
| Screening, p50 | **14.8 ms** at the default threshold of 75 |
|
|
226
|
+
| Screening, p95 / p99 | 47.9 ms / 85.7 ms |
|
|
227
|
+
| Screening with no threshold | 51.4 ms p50 — a threshold is roughly two thirds of the cost, and changes no score |
|
|
228
|
+
| YJIT | Roughly halves the scoring cost. `RUBYOPT=--yjit` |
|
|
229
|
+
| Rescreening 10,000 subjects against a typical daily diff | **1.2 s** — against 54 s to screen the same book against the whole list. See [Rescreening a book of business](#noticing-what-changed-between-two-syncs-and-rescreening-a-book-against-it) |
|
|
230
|
+
| A sync where nothing changed | One conditional request per file, no download and no parse |
|
|
231
|
+
| A full OFAC SDN sync | Three files downloaded, joined across 19,321 entities, and 88,827 remarks segments parsed. The expensive half is the parse, which is exactly what a 304 skips |
|
|
232
|
+
|
|
233
|
+
```
|
|
234
|
+
threshold mean p50 p95 p99 slowest
|
|
235
|
+
0 54.7 ms 51.4 ms 100.6 ms 128.6 ms 140.3 ms
|
|
236
|
+
75 18.2 ms 14.8 ms 47.9 ms 85.7 ms 91.5 ms
|
|
237
|
+
85 11.7 ms 10.2 ms 22.7 ms 39.1 ms 40.2 ms
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
A matcher is immutable once built, so many threads screen through one without a lock, and a sync builds a new one rather than mutating the old — requests in flight finish against one consistent list version. See [Holding a client, and screening from many threads](#holding-a-client-and-screening-from-many-threads).
|
|
241
|
+
|
|
242
|
+
Every early exit in the scorer is a bound on what a pair *could* reach, never an approximation of what it did reach, so **a result at or above the threshold is exactly the result the same call without a threshold returns**. `rake benchmark:scorer` fails loudly if a threshold ever changes a score.
|
|
243
|
+
|
|
244
|
+
## Configuration
|
|
245
|
+
|
|
246
|
+
Everything has a working default; `ActiveSanction.configure` exists so that a caller *can* identify itself, not so that one must recite the schema.
|
|
247
|
+
|
|
248
|
+
| Setting | Default | What it is |
|
|
249
|
+
|---|---|---|
|
|
250
|
+
| `user_agent` | `active_sanction/<version> (+<repo url>)` | Sent on every request. Cannot be blank — OFAC 403s without one. Set it to your own contact address |
|
|
251
|
+
| `open_timeout` / `read_timeout` | `10` / `60` seconds | Read timeout is per-read, so it does not cap how long a large download may take overall |
|
|
252
|
+
| `max_redirects` | `5` | OFAC's download URLs redirect to blob storage |
|
|
253
|
+
| `max_retries` / `retry_backoff` | `2` / `1.0` s | Three attempts total for a transient failure |
|
|
254
|
+
| `cache_dir` | `$XDG_CACHE_HOME/active_sanction` | Cache validators and raw payloads. Recoverable by fetching again; safe to delete |
|
|
255
|
+
| `storage_dir` | `~/.active_sanction` | Parsed snapshots, for the filesystem store. **The system of record** — publishers overwrite their files in place, so the list version a past decision was screened against exists only here |
|
|
256
|
+
| `storage` | `Storage::FileSystem` over `storage_dir` | Any `Storage::Base` |
|
|
257
|
+
| `retain_payloads` | `3` | Raw payloads kept per source, for re-parsing and diffing a suspicious file |
|
|
258
|
+
| `stale_after` | `86_400` s | What `stale?` measures against. Does not cap how long a cached copy may be *used* — that is your policy |
|
|
259
|
+
| `sources` | `nil`, meaning every registered source | `c.sources = %i[ofac_sdn un_consolidated]` |
|
|
260
|
+
| `sync_concurrency` | `1` | How many *publishers* are fetched from at once, never how hard any one of them is asked |
|
|
261
|
+
| `doctor_tolerance` | `0.10` | How far one of the doctor's measurements may move from the last sync before it says so. Overridden per run with `tolerance:` |
|
|
262
|
+
| `xml_backend` | `:rexml` | `:nokogiri` for a host already parsing OFAC's 126 MB advanced XML. The default is stdlib so that every installation parses identically — a checksum has to mean the same thing everywhere |
|
|
263
|
+
| `candidate_limit` | `200` | Names the index hands the scorer per query |
|
|
264
|
+
| `screening_threshold` | `75.0` | See [Reading a score](#reading-a-score). Overridden per query with `threshold:` |
|
|
265
|
+
| `screening_limit` | `10` | Results returned, highest first. A review queue, not a report |
|
|
266
|
+
| `normalizer_dictionary` | The four shipped token lists | See [Normalizing a name](https://babystep.tech/active_sanction/explanation/how-matching-works/#normalizing-a-name) |
|
|
267
|
+
| `scorer_weights` | `Scorer::Weights.default` | What each signal is worth. Changing one changes what every past decision would score today, which is why `weights` travels on every `MatchResult` |
|
|
268
|
+
| `logger` | `nil` | Anything Logger-shaped |
|
|
269
|
+
|
|
270
|
+
A bad value raises `ConfigurationError` at the point it is set, rather than producing a puzzling failure during a sync three hours later. So does a setting that does not exist: a misspelled one is refused and the message names the settings there are, because a silently dropped `user_agnet` is an installation running on a default somebody thinks they changed.
|
|
271
|
+
|
|
272
|
+
`ActiveSanction.configure` populates a default `Client` and freezes the configuration into it, so `ActiveSanction.config` is a value object rather than global mutable state — reading it is safe from anywhere, and changing it means configuring again. Every setting in the table above is also a keyword argument to `Client.new`, which is what a process holding several configurations at once uses instead; see [Holding a client, and screening from many threads](#holding-a-client-and-screening-from-many-threads).
|
|
273
|
+
|
|
274
|
+
```ruby
|
|
275
|
+
ActiveSanction.reset! # drops the default client and starts from the defaults again — what a test suite runs between examples
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
## Handling errors
|
|
279
|
+
|
|
280
|
+
`rescue ActiveSanction::Error` catches everything this library raises from a public method, and under it sit the answers to the only three questions a caller embedding this in a request path actually has — *retry this*, *alert somebody*, *this is a bug in my call* — none of which should be answered by matching on a message string. The hierarchy is public API within a major version. [Handle errors](https://babystep.tech/active_sanction/how-to/handling-errors/) has the full tree, the `retryable?` table, and what to log.
|
|
281
|
+
|
|
282
|
+
## Adding a source
|
|
283
|
+
|
|
284
|
+
[`docs/adding_a_source.md`](docs/adding_a_source.md) is the end-to-end walkthrough: reading the publisher's file before writing anything, choosing the format toolkit, mapping its fields onto the canonical model, deriving a stable id for a list that publishes none, trimming a fixture, wiring up the conformance spec, and registering the adapter — from inside this gem or from an application that never forks it. It ends with a complete worked adapter, its fixture and its spec. [Add a sanctions source](https://babystep.tech/active_sanction/how-to/adding-a-source/) is the shorter field guide, pointing into the file's sections rather than restating them.
|
|
285
|
+
|
|
286
|
+
A source registered from outside this gem is a first-class source: a bank's internal watchlist is screened, stored, diffed and stamped exactly as OFAC's is.
|
|
287
|
+
|
|
288
|
+
There is no scaffold generator, deliberately. Roughly eight adapters at maturity do not repay one that has to be kept in step with `Sources::Base`, the conformance spec and the parser toolkits, and that goes stale silently when it is not; the document plus the closest existing adapter to copy does the same job with none of the upkeep.
|
|
289
|
+
|
|
290
|
+
Every source adapter is held to one shared example group, `"a sanction source"` (`spec/support/shared_examples/sanction_source.rb`), which checks what everything downstream of an adapter assumes and cannot check for itself — a declared key, jurisdiction, authority and URL; `Entity` objects with unique, deterministic ids; dates as `PartialDate`; a round trip through `#to_h`; the publisher's own text kept in `remarks`. It is the floor and not the ceiling, so every adapter still writes its own spec on top.
|
|
291
|
+
|
|
292
|
+
## How it works
|
|
293
|
+
|
|
294
|
+
### Reading OFAC's free text
|
|
295
|
+
|
|
296
|
+
The US SDN list publishes no date of birth, place of birth, nationality or passport column. All of it — 88,827 semicolon-delimited segments, measured against the September 2026 list — is prose in one `Remarks` field, written for a person reading a page:
|
|
297
|
+
|
|
298
|
+
DOB 10 Dec 1948; POB Egypt; nationality Egypt; Passport 123456 (Egypt) expires 12 Dec 2015
|
|
299
|
+
|
|
300
|
+
`Sources::Ofac::RemarksParser` reads it — both OFAC lists, since they publish the same prose in the same field — which is what gives the largest list in the world secondary identifiers to match on rather than names alone. Extraction is **additive**: `Entity#remarks` keeps the publisher's whole string whether the parser understood it or not, so a pattern that goes stale costs structure and never content. Silent data loss is the worst failure a compliance tool has, and a segment nobody could parse is still in front of the user in the government's own words.
|
|
301
|
+
|
|
302
|
+
Because these are heuristics against text that changes without notice, every sync reports how much of it was read:
|
|
303
|
+
|
|
304
|
+
```ruby
|
|
305
|
+
source = ActiveSanction::Sources[:ofac_sdn].new
|
|
306
|
+
source.sync
|
|
307
|
+
source.remarks_coverage.to_s
|
|
308
|
+
# => "recognized 86428 of 88827 segments (97.3%), 53429 extracted"
|
|
309
|
+
source.remarks_coverage.top(3)
|
|
310
|
+
# => [["Member of the", 615], ["ICTY indictee.", 45], ["all offices worldwide.", 43]]
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
`recognized` counts segments matched as prose carrying no fields — statutory citations, `Linked To:` notes — as well as those that produced a value, because "this carries nothing" and "we have never seen this" are different states and only the second is work. `top` ranks the shapes nobody has taught it yet, digits masked, by how many records they cost; that ranking is how the label table in `remarks_parser/vocabulary.rb` gets extended.
|
|
314
|
+
|
|
315
|
+
### The two OFAC lists
|
|
316
|
+
|
|
317
|
+
OFAC publishes the SDN list and the Consolidated (non-SDN) list in the same shape: three headerless Windows-1252 CSVs joined on `ent_num`, `-0- ` for null, everything the canonical model has no column for written into `Remarks`. `Sources::Ofac` reads that shape once, and `OfacSdn` and `OfacConsolidated` declare only which list they are and where their files live.
|
|
318
|
+
|
|
319
|
+
They are separate sources, so a caller can screen either or both:
|
|
320
|
+
|
|
321
|
+
```ruby
|
|
322
|
+
ActiveSanction.configure { |c| c.sources = %i[ofac_sdn ofac_consolidated] }
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
The consolidated file is six lists in one — SSI, CMIC, NS-PLC, NS-MBS, CAPTA, FSE — and a hit on CMIC is a different finding from a hit on NS-PLC, so which one a record is on has to survive the parse. There is no list column: the only thing in the CSV that names a list is the program code, so `OfacConsolidated` maps it.
|
|
326
|
+
|
|
327
|
+
```ruby
|
|
328
|
+
entity.programs # => ["CMIC-EO13959"]
|
|
329
|
+
ActiveSanction::Sources::OfacConsolidated.lists(entity) # => [:cmic]
|
|
330
|
+
ActiveSanction::Sources::OfacConsolidated.names(entity) # => ["Non-SDN CMIC List"]
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
It reads off `Entity#programs`, which is a canonical member, so it keeps working on a record that has been stored and read back rather than needing a column of its own downstream. The names are also appended to `remarks` behind the `[source fields]` marker, which is where a report reads them from.
|
|
334
|
+
|
|
335
|
+
Checked against OFAC's own attribution in `CONS_ADVANCED.XML`, the mapping is exact for 478 of the 481 published records. The three it is not are all `RUSSIA-EO14024`, the one program OFAC uses for both SSI and NS-MBS: Gazprom, Transneft and Rosselkhozbank are on both lists and come out marked SSI only, and nothing in the CSVs separates them from the 89 rows carrying the identical program pair that are on SSI alone. A program the table has never seen puts a record on no list and raises a parse warning, which is how a new authority surfaces.
|
|
336
|
+
|
|
337
|
+
### Canada's consolidated list
|
|
338
|
+
|
|
339
|
+
`Sources::CanadaSema` reads the one file Global Affairs publishes for the Special Economic Measures Act and the Justice for Victims of Corrupt Foreign Officials Act together — 5,690 records, flat, every element optional.
|
|
340
|
+
|
|
341
|
+
Two things about it are worth knowing before a screening policy leans on it.
|
|
342
|
+
|
|
343
|
+
**A Canadian record carries much less than an OFAC one.** There is no nationality, no address, no place of birth and no document number anywhere in the file. An individual is a surname, given names, a date of birth about a third of the time, and a free-text alias field. Nothing here can make a name match decisive the way an OFAC passport number usually does, so a clean Canadian result is weaker evidence than a clean OFAC one.
|
|
344
|
+
|
|
345
|
+
**The ids are ours, not the publisher's.** Canada publishes no identifier at all. What it publishes is a citation — which regulation, which schedule, which item — and item numbers are positions in a list that get renumbered when a schedule is amended. `CanadaSema::SourceRef` hashes the citation *and the name*, so an amendment that shifts every item up one reports as removals and additions rather than quietly handing one person's id to the next person along. The cost is that correcting a typo in a name re-ids that record; churn in a diff is a nuisance, one id covering two different people is a screening failure.
|
|
346
|
+
|
|
347
|
+
Two smaller judgment calls, both visible in the parsed record:
|
|
348
|
+
|
|
349
|
+
```ruby
|
|
350
|
+
entity.programs # => ["Belarus"] the English half of "Belarus / Bélarus"
|
|
351
|
+
entity.remarks # => "[source fields] Country: Belarus / Bélarus; Schedule: 1, Part 1; Item: 2"
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
`Country-Pays` is not a nationality and is not read as one — a Ukrainian official listed under the Special Economic Measures (Russia) Regulations is published under `Russia / Russie`, and 80 records name a statute rather than a country. It names the regulation, so it is a program. The French half, and the citation the id came from, stay in `remarks`.
|
|
355
|
+
|
|
356
|
+
Aliases are split on semicolons and never on commas. A comma is genuinely ambiguous in this field: `Завод "Дагдизель", АО` would yield a bare Russian legal form as an alias, and `Министерство образования, науки и молодежи Республики Крым` is one ministry rather than two. That costs recall on roughly 300 records whose primary name is published anyway, which is the cheaper of the two mistakes.
|
|
357
|
+
|
|
358
|
+
### The EU consolidated list
|
|
359
|
+
|
|
360
|
+
`Sources::EuFsf` reads the Financial Sanctions Files export the European Commission publishes for the EU Consolidated Financial Sanctions List — 6,234 records in one 25.7 MB document, the largest list this gem reads by an order of magnitude and the first one that actually exercises the streaming XML interface. It parses in a single pass; nothing ever holds two records at once.
|
|
361
|
+
|
|
362
|
+
**The token in the URL is not a credential.** The public endpoint answers 403 without a `token` parameter and 500 with a wrong one, but `dG9rZW4tMjAxNw` is base64 for `token-2017`, it has been the value on the Commission's own public download page since that year, and it is the same string for every caller. It is declared inline the way any other part of a URL is. If it ever rotates, no release of this gem is needed:
|
|
363
|
+
|
|
364
|
+
```ruby
|
|
365
|
+
ActiveSanction::Sources::EuFsf.token = "..." # or
|
|
366
|
+
ActiveSanction::Sources::EuFsf.url :main, "https://..."
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
**Conditional GET does not work here.** The endpoint serves `Last-Modified` but answers `If-Modified-Since` with 200 and the whole file, sends no `ETag`, and sets `Cache-Control: no-store`. So this is the one list that downloads 25.7 MB on every sync where the other five usually download nothing. The request is still made conditionally — it costs nothing, and the day the Commission honours it, it works — and the parsed content still hashes to the same snapshot checksum, so a re-download of an unchanged list is reported unchanged and diffs to nothing.
|
|
370
|
+
|
|
371
|
+
Three things about the data are worth knowing before a screening policy leans on it.
|
|
372
|
+
|
|
373
|
+
**No name is marked as the official one, so this adapter picks one.** All 31,053 `<nameAlias>` elements carry `strong="true"` and none is flagged primary, and every candidate signal in the file is wrong somewhere: document order files Qusay Hussein's French transliteration ahead of his English name, `nameLanguage` files a Cyrillic spelling of Anatoliy Sidorov's name under `EN`, and ordering by `logicalId` picks a non-Latin name for 3,203 of the 5,502 multi-name records. The rule is **the first name the Commission published that the Commission did not itself annotate as an alias** — which passes over the 24 records that lead with a name their own remark calls a low-quality alias or a former name. "Primary" therefore means less here than it does on the OFAC lists, and it costs nothing in score: an entity's score is the best of its names, and the kind reaches only the reason line.
|
|
374
|
+
|
|
375
|
+
**Alias quality and alias kind are prose in a `<remark>`, and are read as such.** There is no grade column. 499 names carry a grading the Commission wrote out — "low quality alias", "Good quality a.k.a.", "formerly known as", "Maiden name: Al Akhras" — and those become `quality` and `kind` on the `Name`. The phrase has to *begin* an annotation rather than merely appear in the remark, because it appears in prose about other entities: Zadna International's own remark says the company is "99 % owned by the Special Fund ..., formerly known as the Charity Organisation for the Support of the Armed Forces", and a loose match files Zadna's published English name as a former name on the strength of a sentence about its owner.
|
|
376
|
+
|
|
377
|
+
**Four birth dates are not in the Gregorian calendar.**
|
|
378
|
+
|
|
379
|
+
```ruby
|
|
380
|
+
# <birthdate calendarType="ISLAMIC" year="1343" city="Farsan" .../>
|
|
381
|
+
entity.dates_of_birth # => []
|
|
382
|
+
entity.remarks # => "... Date of birth as published: 1343 (islamic calendar); Place of birth: Farsan, ..."
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
`year`, `monthOfYear` and `dayOfMonth` hold a Hijri date on those records. One of the four also carries a converted `birthdate` attribute and is read from that; the other three produce **no date of birth**, and the published date goes to `remarks`. Reading `1343` as published would not merely fail to match a real date of birth — it would *conflict* with one, and the scorer penalizes a conflicting date, so a true hit would be pushed below the threshold by the very field that should have confirmed it.
|
|
386
|
+
|
|
387
|
+
Two smaller judgment calls, both visible in the parsed record:
|
|
388
|
+
|
|
389
|
+
```ruby
|
|
390
|
+
entity.nationalities # => ["IQ"] citizenship as ISO 3166 alpha-2, not "IRAQ"
|
|
391
|
+
entity.type # => :organization for a shipping company holding an IMO number
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
`countryIso2Code="00"` is the Commission's sentinel for "not stated" — it is on 1,743 of the birth dates and 1,352 of the documents — and is dropped rather than filed as a country called `00`. And `subjectType` publishes only `person` and `enterprise`: the 41 records carrying an `imo` document are shipping *companies* holding IMO company numbers, not ships, so none of them is retyped as a vessel on the strength of the document kind.
|
|
395
|
+
|
|
396
|
+
### The UK Sanctions List
|
|
397
|
+
|
|
398
|
+
`Sources::UkSanctionsList` reads the UK Sanctions List the Foreign, Commonwealth and Development Office publishes under the Sanctions and Anti-Money Laundering Act 2018 — 6,334 designations in one 21.8 MB XML document, against a published XSD.
|
|
399
|
+
|
|
400
|
+
**It is not the OFSI Consolidated List, and the difference matters more than a rename.** The UK ran two lists until 28 January 2026: the UKSL, which carried every designation, and OFSI's Consolidated List of Asset Freeze Targets, which carried the financial ones. On that date the second was retired. The FCDO's own [transition guidance](https://www.gov.uk/guidance/moving-to-a-single-list-for-uk-sanctions-designations-28-january-2026) says the Consolidated List "is no longer being updated", and its gov.uk publication page is marked `[Withdrawn]`.
|
|
401
|
+
|
|
402
|
+
The file did not go away. `ConList.csv` still answers `200 text/csv` with 16.6 MB of real designations, so an integration built against it does not break — it quietly stops being current, which is the failure mode this library is least able to survive and least able to detect. Nothing in a sync report distinguishes a fresh list from a frozen one that is still being served.
|
|
403
|
+
|
|
404
|
+
`OFSI Group ID` is retired with it: designations made since 28 January 2026 do not get one. The historic Group ID is still carried on every earlier designation and stays valid for a licence application or a breach report, so it is kept in `remarks`, and `source_ref` is built from the `Unique ID` that every record has.
|
|
405
|
+
|
|
406
|
+
```ruby
|
|
407
|
+
entity.source_ref # => "AFG0001"
|
|
408
|
+
entity.remarks # => "... [source fields] OFSI group id: 12703; UN reference: TAe.010; ..."
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
**The XML rather than the CSV, because the CSV is a cartesian product.** The FCDO publishes seven formats, two of them machine-readable at this size. The CSV was added in January to match the shape OFSI's readers were built for, and it is 49.9 MB and 58,424 rows for the same 6,334 designations, because it emits one row per *combination* of every repeating group. One record, `INU0075`, occupies 3,780 rows — 10 names × 7 addresses × 3 phone numbers × 2 websites × 9 subsidiaries. Reading it means grouping the rows and then de-duplicating each dimension back out of the product, which is reconstruction rather than parsing. The XML states the same structure directly, in 44% of the bytes, and carries a field the CSV has no column for.
|
|
412
|
+
|
|
413
|
+
**Conditional GET works here**, unlike the EU's endpoint. The FCDO serves both an `ETag` and a `Last-Modified` and honours both, so a sync against an unchanged list answers 304 and downloads nothing:
|
|
414
|
+
|
|
415
|
+
```
|
|
416
|
+
run 1: 6334 entities in 14.4s
|
|
417
|
+
run 2: unchanged (304) in 0.1s
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
The URLs have been static since January — the FCDO made them so precisely to stop a screening system re-discovering the link on every refresh — which is why this adapter declares one and does not scrape a publication page for it.
|
|
421
|
+
|
|
422
|
+
**Every judgment this adapter makes, the FCDO published a field for.** That is unusual, and it is what makes a clean UK result worth more than a clean EU one.
|
|
423
|
+
|
|
424
|
+
```ruby
|
|
425
|
+
entity.primary_name.value # => "MUHAMMAD JAMAL ABD-AL RAHIM AHMAD AL-KASHIF"
|
|
426
|
+
entity.names.map(&:quality) # => [nil, :good, :low, :low, :low, ...]
|
|
427
|
+
entity.programs # => ["The Russia (Sanctions) (EU Exit) Regulations 2019"]
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
Every one of the 6,334 records carries a `NameType` of `Primary Name` — six carry two, none carries none — so which name to call primary is the publisher's decision here rather than this adapter's, which is exactly the judgment the EU adapter is stuck making. Alias grading is a field (`AliasStrength`, on 2,073 names) rather than prose. The regime is the statutory instrument itself, and maps to `programs`; the measures actually imposed are a separate field and go to `remarks`.
|
|
431
|
+
|
|
432
|
+
Two mappings are worth stating because they are not the only defensible ones. A name is six numbered parts and is joined in numeric order — `Name1` to `Name6`, given names ascending with the family name last — which is *not* the order the CSV lists its columns in; the CSV files `Name 6` first because it is presenting a surname to a reader, and following that would produce `JAN ABDUL KABIR MUHAMMAD`. And `Primary Name Variation`, which is 5,513 of the 15,677 published names, is an alternative *spelling* of the designated name rather than a second designation, so it is filed as an alias — which is what leaves `Entity#primary_name` answering with the name the FCDO actually designated.
|
|
433
|
+
|
|
434
|
+
### The Australian Consolidated List
|
|
435
|
+
|
|
436
|
+
`Sources::AustraliaDfat` reads the Consolidated List the Australian Sanctions Office publishes — every person, entity and vessel designated or declared by the Foreign Minister under the Autonomous Sanctions Regulations 2011, plus every UN Security Council listing Australia gives effect to. 3,906 records, in one Excel workbook.
|
|
437
|
+
|
|
438
|
+
**It is published as a spreadsheet, and as nothing else.** DFAT's Consolidated List page offers exactly one download — `Australian_Sanctions_Consolidated_List.xlsx`, 1.3 MB, one sheet, 11,163 rows, 19 columns. There is no CSV, no XML and no JSON. So reading a spreadsheet is not a convenience here; it is the price of screening against Australian sanctions at all.
|
|
439
|
+
|
|
440
|
+
**No spreadsheet gem was added for it.** `Parsers::Spreadsheet` reads the workbook with `zlib` and the XML toolkit the other five adapters already use, because an `.xlsx` is a ZIP of XML parts and the only thing actually missing was a ZIP header unpacker. It reads one sheet of cell values as strings, resolves the shared string table, and renders a date cell as ISO 8601 at the precision its own number format displays. It does not evaluate formulas — a formula cell reads as the value cached in it — and it ignores everything a spreadsheet can hold that a sanctions list does not put data in. The older binary `.xls` is a different format and is not read.
|
|
441
|
+
|
|
442
|
+
```ruby
|
|
443
|
+
row[:date_of_birth] # => "1962-08-24" the cell holds 22882, formatted m/d/yyyy
|
|
444
|
+
row[:date_of_birth] # => "1958" the cell holds 1958, formatted General
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
That distinction is the whole reason the styles are read. `18798` is the 19th of June 1951 if the cell is formatted as a date and the year 18798 if it is not, and only `xl/styles.xml` says which — 4,183 of these birth dates are serials and 2,709 are years, in the same column.
|
|
448
|
+
|
|
449
|
+
**The URL this gem was scoped against is gone, and what replaced it still answers 200.** `regulation8_consolidated.xlsx` now redirects to `regulation8_consolidated_2.xls` — a real file, served successfully, in the old binary format, last modified in March 2022. An adapter pointed at it would download a list four years stale on every sync and never once look unhealthy. This adapter declares the URL DFAT's own page links today.
|
|
450
|
+
|
|
451
|
+
**DFAT's edge rejects this gem's User-Agent.** Verified against the live endpoint: `active_sanction/x.y.z (+https://…)` gets no response at all — not a 403, a dropped connection — while `curl/8.7.1`, `Wget/1.21` and `python-requests/2.31.0` are served. The filter is on the leading product token, and an unrecognised one is dropped, so identifying ourselves honestly is what gets us blocked. This source sends the configured agent inside the form written for exactly this case:
|
|
452
|
+
|
|
453
|
+
```
|
|
454
|
+
Mozilla/5.0 (compatible; active_sanction/1.0.0 (+https://github.com/Babystep-Technologies/active_sanction))
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
which is how a well-behaved crawler has identified itself since Googlebot. The agent, its version and whatever contact URL you configured are all still in the string; DFAT can still see who we are and block us on purpose. It is the same identification in a shape the edge parses. It is the only place any source departs from `Sources::Base`, and `AustraliaDfat#fetch_file` is the whole of it.
|
|
458
|
+
|
|
459
|
+
**Conditional GET works.** The endpoint serves both an `ETag` and a `Last-Modified` and honours both, so a sync against an unchanged list answers 304 and downloads nothing. The workbook also stamps its own save time inside `docProps/core.xml`, which is what `source_version` reports — `2026-09-04T05:37:12Z`, the same date DFAT prints on its download page — rather than an HTTP header.
|
|
460
|
+
|
|
461
|
+
### Storage
|
|
462
|
+
|
|
463
|
+
Storage is five methods (`Storage::Base`), and the rule they exist to enforce is that **nothing on the query path may name a concrete store**: a source nobody has synced reads back as `nil`, never as an empty snapshot, and nothing partial is ever returned — a truncated file, an edited record or a dropped row raises `Storage::CorruptSnapshot` rather than screening against a list that is quietly missing people. [Choose a storage backend](https://babystep.tech/active_sanction/how-to/choosing-a-store/) has `FileSystem`, `ActiveRecord` and `Memory` in full, plus the conformance group — `"a storage adapter"` — every one of them and every one a host writes is held to.
|
|
464
|
+
|
|
465
|
+
A store is where *this* machine keeps its lists. Moving one to another machine — an air-gapped host, or a mirror for the afternoon a publisher is down — is a signed bundle: one file, a signature an auditor can check, and the same bundle for the same snapshot every time. [`docs/bundle_format.md`](docs/bundle_format.md) is the byte-level format, specified in enough detail to be implemented outside Ruby; [Publish and verify a signed bundle](https://babystep.tech/active_sanction/how-to/publishing-a-signed-bundle/) is the two commands and the decisions around them.
|
|
466
|
+
|
|
467
|
+
### Syncing every list, and what happens when one is down
|
|
468
|
+
|
|
469
|
+
`ActiveSanction.sync!` fetches, parses and stores every configured source, isolating each behind its own rescue so a UN outage does not stop OFAC from syncing. **A failed source keeps its previous snapshot** — screening against yesterday's list with a visible age on it is safe; screening against an empty one is not, which is why the thing to alert on is the age of what is being screened against, not just whether the last run reported a failure. [Sync on a schedule, and handle a source that is down](https://babystep.tech/active_sanction/how-to/syncing-on-a-schedule/) has the rake task, the exit code, and what to alert on.
|
|
470
|
+
|
|
471
|
+
### Noticing what changed between two syncs, and rescreening a book against it
|
|
472
|
+
|
|
473
|
+
Screening is not a one-time event. A customer cleared last month may be listed today, and re-running a whole book against a whole list every night is why most services that do it the naive way do it weekly instead. `ActiveSanction.diff` computes what changed between two snapshots of one source; `ActiveSanction.rescreen` applies that diff to a book of business and reports who it affects, at a cost proportional to what changed rather than to the size of the book — **1.2 s for 10,000 subjects against a typical daily diff, versus 54 s to screen the same book against the whole list.**
|
|
474
|
+
|
|
475
|
+
A few things are easy to get wrong here, so they are worth stating rather than leaving to be discovered: **delistings matter as much as listings** — `diff.removed` is what lets a customer back through the door, and a service that only re-screens against new records never notices one; **an amendment is not a delisting plus a listing** — the two snapshots are joined by entity id, so a corrected passport number reports as one `Diff::Change` rather than a removal and an addition that would put a false delisting in front of an analyst; **a first sync is a baseline, not nineteen thousand new listings** — a diff with no `from` reports `baseline?` and nothing to re-screen, because the right response to a first sync is a deliberate full screening run; and **order is never a change**, including a reordered alias, so a publisher re-emitting the same file in a different row order diffs to nothing.
|
|
476
|
+
|
|
477
|
+
[Rescreen a book of business against a diff](https://babystep.tech/active_sanction/how-to/rescreening-a-book/) has the full mechanics — `Subject`, what `:newly_listed` / `:delisted` / `:details_changed` mean, streaming a large book past a small diff, and what this deliberately does not remember (see [What it does not do](#what-it-does-not-do)).
|
|
478
|
+
|
|
479
|
+
### Noticing when a publisher has changed its format
|
|
480
|
+
|
|
481
|
+
**The dangerous change is the one where the file still parses cleanly and means something different** — 19,321 entities carrying zero passports looks exactly as healthy as 19,321 carrying 23,429 if the only thing anyone counts is records, and a sync would not notice that for months. `ActiveSanction.doctor` measures fill rates and free-text coverage against the last stored snapshot and reports a severity per source. [Detect when a publisher changes its format](https://babystep.tech/active_sanction/how-to/detecting-format-drift/) has the full mechanics, how to read `warn` versus `error`, and the upstream canary that runs the same checks on a schedule against the real endpoints.
|
|
482
|
+
|
|
483
|
+
### Normalizing, scoring and the identifiers that make it a screening tool
|
|
484
|
+
|
|
485
|
+
`ActiveSanction::Normalizer` folds a name before anything is compared — Unicode NFKD, casefold, punctuation to spaces — and, told what kind of entity a name belongs to, drops the parts every entity of its kind shares: legal forms and function words from an organization, honorifics from an individual, never a structural particle like `bin` or `al`. There is one code path for both the index and the query, which is what keeps a stoplist change from silently stopping a match on one side only. Non-Latin script is not transliterated: `Путин` matches a Cyrillic query and nothing else, survivable only because these lists publish a romanized alias alongside the original.
|
|
486
|
+
|
|
487
|
+
`ActiveSanction::Scorer` blends four string algorithms and a phonetic pass into one name similarity — a weighted mean rather than the weighted maximum the well-known Python ratio uses, deliberately, because a maximum would put `Mohammed` against `MOHAMMED AL-ZAWAHIRI` in the nineties on a corpus where a quarter of the individuals share a handful of given names. What actually separates a coincidence from a corroborated match is the identifiers a compliance officer already has: an exact passport match is worth **+40**, a full date of birth **+15**, a nationality agreement **+6** — and every adjustment fires only when *both* sides carry the field, because treating a missing field as disagreement would under-score exactly the jurisdictions that publish least. One name transliterated two different ways — `QADHAFI, Muammar` against `Muammar Gaddafi` — is the residual case this does not solve; the mitigation is the identifier fields, not a bigger phonetic weight.
|
|
488
|
+
|
|
489
|
+
[How matching works](https://babystep.tech/active_sanction/explanation/how-matching-works/) is the full argument, end to end: the five-stage pipeline, the adjustments table, why 75 is the default threshold, and the reproducibility fields a `MatchResult` carries.
|
|
490
|
+
|
|
491
|
+
### Screening a name
|
|
492
|
+
|
|
493
|
+
`ActiveSanction.screen` is the whole pipeline behind one call: fold the query once, retrieve the names worth comparing, score each with reasons, then filter, rank, cap and stamp.
|
|
494
|
+
|
|
495
|
+
```ruby
|
|
496
|
+
results = ActiveSanction.screen(
|
|
497
|
+
name: "Bosco Ntaganda",
|
|
498
|
+
type: :individual,
|
|
499
|
+
date_of_birth: "1973",
|
|
500
|
+
countries: %w[CD],
|
|
501
|
+
sources: %i[ofac_sdn un_consolidated], # default: every synced list
|
|
502
|
+
threshold: 75,
|
|
503
|
+
limit: 10
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
hit = results.first
|
|
507
|
+
hit.score # => 100.0
|
|
508
|
+
hit.entity # => Entity
|
|
509
|
+
hit.matched_name # => the specific Name that produced the score
|
|
510
|
+
hit.source # => :un_consolidated
|
|
511
|
+
hit.explanation # => [Reason, ...], summing to the score
|
|
512
|
+
hit.snapshot_id # => "sha256:9f86d081884c7d65..."
|
|
513
|
+
hit.matcher_version # => "1"
|
|
514
|
+
hit.screened_at # => 2026-09-06 11:04:02 UTC
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
An empty array is the ordinary answer — most customers are not on a sanctions list — and everything that could make it a lie rather than a fact raises instead. A store nobody has synced raises `Matcher::NotSynced`; a query naming a list the matcher does not hold raises `Storage::MissingSnapshot` rather than quietly covering two of the three lists it was asked for. Screening against a list that is not there returns a clean report, and a clean report is the most expensive thing this library can get wrong.
|
|
518
|
+
|
|
519
|
+
`date_of_birth:` and `dates_of_birth:`, `country:`, `countries:` and `nationalities:`, `identifier:` and `identifiers:` all mean the same thing. A caller with one date writes the singular and a caller with three writes the plural, and neither should have to remember which this library prefers.
|
|
520
|
+
|
|
521
|
+
**One result per entity, in the alias that won.** An entity is retrieved once for every one of its names the query looks like, and its score is the best of those names, so each is scored once and reported once. Results are ordered by score descending and ties by entity id — ties are not a corner case on these lists, and which of two identically scored records is listed first has to be the same answer in a year's time.
|
|
522
|
+
|
|
523
|
+
#### Every result is a reproducibility stamp
|
|
524
|
+
|
|
525
|
+
`MatchResult` is the most permanent object in the gem — it is what ends up in a customer's audit record, read by people who have neither this process nor this version of the gem — so it serializes to a documented shape and `MatchResult.from_h` rebuilds it losslessly. [Reproducibility](https://babystep.tech/active_sanction/explanation/how-matching-works/#reproducibility) has the four fields that make a past decision re-derivable. `backend` is a fifth, not there: a hosted backend answers the same call against data somebody else keeps fresh, and an audit record has to say which one answered.
|
|
526
|
+
|
|
527
|
+
#### Holding a client, and screening from many threads
|
|
528
|
+
|
|
529
|
+
`ActiveSanction.screen` is sugar over a default `Client` built from the configuration on first use, and `ActiveSanction.configure` is what populates it. A server that needs more than one configuration alive at once holds its own clients instead, which is a thing a process-global cannot express at all:
|
|
530
|
+
|
|
531
|
+
```ruby
|
|
532
|
+
CLIENT = ActiveSanction::Client.new(
|
|
533
|
+
storage: ActiveSanction::Storage::ActiveRecord.new,
|
|
534
|
+
sources: %i[ofac_sdn un_consolidated],
|
|
535
|
+
user_agent: "acme-bank/1.0 (compliance@acme.example)"
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
CLIENT.sync!
|
|
539
|
+
CLIENT.screen(name: "Bosco Ntaganda")
|
|
540
|
+
CLIENT.screen_all(customers.map { |c| { name: c.name, dob: c.born_on } }) # one array of results per query
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
Every setting `ActiveSanction.configure` takes is a keyword argument here, held to the same rule and failing with the same message. A client holds them frozen — the store, the source list, the weights, the candidate cap, the thresholds a query defaults to, the User-Agent every request carries — plus one memo, the matcher it builds from its store on first use. A client's settings cannot be edited after it is built; deriving a neighbour is `#with`:
|
|
544
|
+
|
|
545
|
+
```ruby
|
|
546
|
+
AUDIT = CLIENT.with(storage: januarys_snapshots) # a pinned list version, beside the live one
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
Two clients share nothing: each indexes its own store, screens only the lists it names, and identifies itself to publishers under its own User-Agent. That is what makes a pinned snapshot for an audit re-run, a source set per tenant, and one warm index shared across every request thread all true at the same time.
|
|
550
|
+
|
|
551
|
+
##### What is safe to do concurrently, and what is not
|
|
552
|
+
|
|
553
|
+
| | |
|
|
554
|
+
|---|---|
|
|
555
|
+
| Screening a built client from many threads | **Safe**, and the reason it exists. The matcher is frozen at build, and `screen` allocates locals and touches nothing shared |
|
|
556
|
+
| Building the matcher | **Safe.** It happens once, under the client's lock, so eight threads racing at boot produce one index rather than eight |
|
|
557
|
+
| `sync!` while other threads screen | **Safe.** A store publishes a list whole, so a thread mid-screen finishes against the version it started with. The next call moves onto the new list — `sync!` calls `reload!` itself when anything changed |
|
|
558
|
+
| `sync!(concurrency: 3)` | **Safe.** It bounds how many *publishers* one run fetches from at once; each worker runs its own sources under the client's own settings |
|
|
559
|
+
| Two `sync!` runs over one store | **Not supported**, from this process or another. The last writer wins per source, and the losers' downloads are discarded. Put a lock around the run, not a bigger `concurrency:` |
|
|
560
|
+
| `ActiveSanction.configure` | At boot. It replaces the default client, which drops a matcher built over the old store — correct, and not something to do while requests are in flight |
|
|
561
|
+
|
|
562
|
+
Nothing here makes a store thread-safe that is not. Both shipped adapters are: `Memory` guards its hash, and `FileSystem` publishes a list by renaming one file over another.
|
|
563
|
+
|
|
564
|
+
A matcher holds an index, the checksum of every list in it, the weights it scores with and the candidate cap it retrieves with. All of it is fixed at construction and the object is frozen. **Nothing on the query path reads configuration**, which is a stronger statement than thread safety and the one that matters for an audit: a threshold, a weight or a candidate cap changed halfway through a batch cannot produce a run that is half one set of numbers and half another, because the numbers were read once — into the `Query`, and into the matcher.
|
|
565
|
+
|
|
566
|
+
A sync does not update a matcher. It builds a new one and the reference is swapped, so requests in flight finish against one consistent list version:
|
|
567
|
+
|
|
568
|
+
```ruby
|
|
569
|
+
ActiveSanction.reload! # after a sync run by something else, for the default client
|
|
570
|
+
CLIENT.reload! # the same, for one the application holds
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
Batch screening stamps the whole call with one `screened_at`, because a rescreening of a customer book against a new list version is one event in an audit trail rather than ten thousand a microsecond apart. Results come back index-aligned rather than keyed by name — a book of customers contains the same name twice often enough, and a Hash would silently screen one of them and report both.
|
|
574
|
+
|
|
575
|
+
## What is public, and what may change
|
|
576
|
+
|
|
577
|
+
**The public surface is enumerated, not inferred.** Nearly 500 constants are
|
|
578
|
+
reachable from `ActiveSanction`; 137 of them are promised. Everything else is
|
|
579
|
+
marked `@api private`, is hidden from the rendered documentation, and may be
|
|
580
|
+
renamed or removed in a patch release. [`docs/api_stability.md`](docs/api_stability.md)
|
|
581
|
+
is the list and the policy, and [`spec/api_surface_spec.rb`](spec/api_surface_spec.rb)
|
|
582
|
+
fails the build when the code and that list stop agreeing in either direction.
|
|
583
|
+
|
|
584
|
+
**The first release is 1.0.0, and there was no 0.x.** A leading zero says a minor
|
|
585
|
+
version may remove what the last one promised, and that is not what an enumerated,
|
|
586
|
+
test-enforced surface is doing. So the deprecation path is in force from the first
|
|
587
|
+
release: one full minor release of overlap, a warning through Ruby's own
|
|
588
|
+
`Warning[:deprecated]` switch, and a changelog entry in both the release that
|
|
589
|
+
deprecates and the release that removes. A breaking change waits for 2.0.0.
|
|
590
|
+
|
|
591
|
+
**`Sources::Base`, `Storage::Base` and `ValidatorStore` carry the strongest
|
|
592
|
+
guarantee**, because breaking one of them forks every adapter written outside this
|
|
593
|
+
repository at once — and those authors are not reading these release notes. The two
|
|
594
|
+
conformance groups are the executable statement of what each requires.
|
|
595
|
+
|
|
596
|
+
## Development and contributing
|
|
597
|
+
|
|
598
|
+
Bug reports and pull requests are welcome at https://github.com/Babystep-Technologies/active_sanction. After checking out the repo, `bin/setup` installs dependencies and `bin/console` gives a prompt with the library loaded; `bundle exec rake` runs RSpec, RuboCop and `srb tc`, all three hermetic and all three required.
|
|
599
|
+
|
|
600
|
+
[`CONTRIBUTING.md`](CONTRIBUTING.md) has the whole of what a contributor needs: the two most useful contributions (a new list, or a name this version gets wrong), the release process, the full benchmark suite, the upstream canary, generating the API documentation, what static typing does and does not check, and the one flag that signs a commit.
|
|
601
|
+
|
|
602
|
+
## Security
|
|
603
|
+
|
|
604
|
+
**A false negative here is a security bug, not merely an inaccuracy** — somebody may be relying on an empty result to clear a payment. So is anything that makes the gem accept a modified list as authentic, or that lets a screened name leave the host process.
|
|
605
|
+
|
|
606
|
+
Report one privately, never in a public issue: [**Report a vulnerability**](https://github.com/Babystep-Technologies/active_sanction/security/advisories/new) on the Security tab. [`SECURITY.md`](SECURITY.md) says what is in scope, what is match quality and belongs in a public issue instead, and what response to expect.
|
|
607
|
+
|
|
608
|
+
## License
|
|
609
|
+
|
|
610
|
+
Available as open source under the terms of the [MIT License](LICENSE.txt).
|
|
611
|
+
|
|
612
|
+
**The licence covers the software and not the name.** ActiveSanction, the project name and any associated branding are not granted by it. A fork or a derivative may say that it is built on, compatible with, or derived from ActiveSanction; it may not use the name in a way that suggests it is this project or is endorsed by it. This is the ordinary position under MIT, which grants no trademark rights either way, and it is written down only so nobody has to guess. [The bundle format](docs/bundle_format.md) is open and unencumbered under the same rule: anyone can produce one.
|
|
613
|
+
|
|
614
|
+
## Code of Conduct
|
|
615
|
+
|
|
616
|
+
Everyone interacting in the ActiveSanction project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](CODE_OF_CONDUCT.md).
|