active_sanction 1.1.0 → 1.1.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ffb2d77bf3207e3ef97a16269dcf91737e20d48871cc2aec9e747d4185793e52
4
- data.tar.gz: eeca2cf2bb69972d83e15e982c2daea80969a636e4c88e86cbd29b670420098c
3
+ metadata.gz: ecdae0214290d15cba0bf24838c8e4d2d2e004eaecb344c29aaa8c07d54da36b
4
+ data.tar.gz: c72d3f5baa535d081aae672de2013585e38c85c1cceb5c2a697d6cecc7d5f98a
5
5
  SHA512:
6
- metadata.gz: 748d59d2d06bb502eefd684a71c1abc74beb6b03d677eaf9a85c4563945c03fb7c9d69474988319d0ef1f03d4a68c328efd7d63ad14a62d33fa74ef4989e361b
7
- data.tar.gz: dc9c4247f9e3ce24772a8045c4e7e0ea992ae7fbeb17c8f2f123f8a9ccf39fd741d7617a98cd67eef5968a16811c2cfc1b42a0c433df3da94d3b8e4f3f624f40
6
+ metadata.gz: 5fc3e4e17279f0220f903792d952d4aa1adfa1068504512f51cbacb2247cc4ae71710b38c4de6374810e0c48bcc03052a7149eff9a69dda9ebc02389e27c8f05
7
+ data.tar.gz: 34df5f7d1103073a4bd50956abc77e1e87a9a3c5a213be9c18a46a6e41ccbdd5db64fcf0c7b7437d4c52e33f6876ea581ba57e04a44baa95d3ecec1666a8e315
data/CHANGELOG.md CHANGED
@@ -17,6 +17,61 @@ screening decision would come out as today.
17
17
 
18
18
  Nothing yet.
19
19
 
20
+ ## [1.1.1] - 2026-09-14
21
+
22
+ ### Added
23
+
24
+ - **The conformance groups ship** (#144). `require "active_sanction/testing"` loads the two
25
+ shared example groups that define what a source adapter and a storage adapter must do, so
26
+ an adapter written in your own application is held to the same contract the built-in ones
27
+ are:
28
+
29
+ ```ruby
30
+ # spec/spec_helper.rb
31
+ require "active_sanction/testing"
32
+
33
+ # spec/internal_watchlist_spec.rb
34
+ RSpec.describe MyCompany::InternalWatchlist do
35
+ it_behaves_like "a sanction source", fixture: "internal_watchlist/list.csv"
36
+ end
37
+ ```
38
+
39
+ They were always the stated contract — `docs/api_stability.md` calls them "the executable
40
+ statement of what [the extension points] require" — and they lived under `spec/`, which is
41
+ excluded from the packaged gem. The audience for the promise was the one group of people
42
+ who could not run it. Fixture paths resolve under `spec/fixtures` unless
43
+ `ActiveSanction::Testing.fixture_root` says otherwise, and an absolute path is taken as it
44
+ stands.
45
+
46
+ **`require "active_sanction"` does not load any of it**, so nothing reaches a production
47
+ process; requiring it without RSpec says so rather than failing somewhere stranger. The
48
+ group names and the options they take are public API from here, and a group gaining an
49
+ example is a minor with a note here naming it.
50
+
51
+ - **Preparing a release is a button** (#146). `Prepare release` in the Actions tab takes a
52
+ version, bumps `VERSION`, moves the accrued `Unreleased` section under a dated heading,
53
+ fixes the changelog's link definitions, regenerates the site data that carries the
54
+ version, and opens a pull request. `release.yml` verifies all of that and writes none of
55
+ it, so until now it was a hand-edit somebody had to remember — and hand-edits to this
56
+ particular shape have already gone wrong silently once, when two open pull requests
57
+ merged cleanly in sequence and filed a feature under a patch release that did not contain
58
+ it.
59
+
60
+ The edits are [`bin/prepare_release`](bin/prepare_release), with a spec over them, so the
61
+ workflow is reviewable as Ruby and `bin/prepare_release 1.2.3` does the same thing on a
62
+ laptop. It refuses a version that does not come after the current one, and refuses to
63
+ prepare a release out of an empty `Unreleased` — which `release.yml` also refuses, but
64
+ only after the gem is published. Nothing is tagged or pushed to `main`: the output is a
65
+ pull request, and the version number stays a judgment a person makes.
66
+
67
+ - **`Client#supports?`** (#144), so a caller can ask what an implementation does rather than
68
+ find out by rescuing `NoMethodError`. This client supports all of
69
+ `Client::CAPABILITIES`; the method is for the implementations that are not this class —
70
+ one answering screening questions against data somebody else keeps fresh has no `sync!`
71
+ to offer. An unrecognised capability is `false` rather than an error, deliberately unlike
72
+ `Configuration`: the caller asking is usually written against a newer version than the one
73
+ answering, and wants a fallback path rather than an exception.
74
+
20
75
  ## [1.1.0] - 2026-09-14
21
76
 
22
77
  **A minor, because it adds.** Nothing that existed changed: no behaviour under `lib/`
@@ -652,7 +707,8 @@ summarized here because they are what a reader of a first release most needs:
652
707
  - Recall at the default threshold is 0.939 overall on the labeled set, and every record this
653
708
  version misses is named in the committed accuracy report.
654
709
 
655
- [Unreleased]: https://github.com/Babystep-Technologies/active_sanction/compare/v1.1.0...main
710
+ [Unreleased]: https://github.com/Babystep-Technologies/active_sanction/compare/v1.1.1...main
711
+ [1.1.1]: https://github.com/Babystep-Technologies/active_sanction/compare/v1.1.0...v1.1.1
656
712
  [1.1.0]: https://github.com/Babystep-Technologies/active_sanction/compare/v1.0.1...v1.1.0
657
713
  [1.0.1]: https://github.com/Babystep-Technologies/active_sanction/compare/v1.0.0...v1.0.1
658
714
  [1.0.0]: https://github.com/Babystep-Technologies/active_sanction/releases/tag/v1.0.0
data/CONTRIBUTING.md CHANGED
@@ -234,10 +234,27 @@ comment restating the name would be noise.
234
234
 
235
235
  `bundle exec rake install` installs the gem locally.
236
236
 
237
- A release is a tag. Bump `VERSION` in
238
- [`lib/active_sanction/version.rb`](lib/active_sanction/version.rb), move the
239
- `Unreleased` section of [`CHANGELOG.md`](CHANGELOG.md) under the new version
240
- with its date, and merge that.
237
+ A release is a tag, and two facts have to be true on `main` before one can
238
+ exist: `VERSION` says the version, and `CHANGELOG.md` has a section for it.
239
+ `release.yml` checks both and writes neither.
240
+
241
+ **Putting them there is a button too.** Open
242
+ [`Prepare release`](https://github.com/Babystep-Technologies/active_sanction/actions/workflows/prepare-release.yml),
243
+ press **Run workflow**, enter `1.2.3`. It bumps `VERSION`, moves the accrued
244
+ `Unreleased` section under a dated `## [1.2.3]` heading, leaves a fresh empty
245
+ `Unreleased` behind, fixes the link definitions at the foot of the changelog,
246
+ regenerates the one site data file that carries the version, and opens a pull
247
+ request. CI runs on it like any other.
248
+
249
+ The edits are [`bin/prepare_release`](bin/prepare_release), so
250
+ `bin/prepare_release 1.2.3` does exactly the same thing on your machine if you
251
+ would rather commit it yourself. It refuses a version that does not come after
252
+ the current one, and refuses to prepare a release out of an empty `Unreleased`
253
+ — which `release.yml` also refuses, but only in the job that writes the GitHub
254
+ release, long after the gem is published.
255
+
256
+ **Read the moved changelog section before merging.** It becomes the release
257
+ note verbatim.
241
258
 
242
259
  Then the tag, which you can let the workflow write or write yourself.
243
260
 
data/README.md CHANGED
@@ -288,7 +288,7 @@ A source registered from outside this gem is a first-class source: a bank's inte
288
288
 
289
289
  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.
290
290
 
291
- 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
+ Every source adapter is held to one shared example group, `"a sanction source"` — shipped, so an adapter written outside this repository is held to it too: `require "active_sanction/testing"`. It 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.
292
292
 
293
293
  ## How it works
294
294
 
@@ -691,7 +691,7 @@ There is one option: `remarks: false`, for a list that publishes no free text of
691
691
  its own anywhere — Canada is the only launch source that qualifies. It has to be
692
692
  asked for, so that dropping a remark by accident stays a failure.
693
693
 
694
- The group lives in `spec/support/shared_examples/sanction_source.rb` and checks
694
+ The group lives in `lib/active_sanction/testing/sanction_source.rb`, reaches a suite through `require "active_sanction/testing"`, and checks
695
695
  what everything downstream assumes and cannot check for itself: that the adapter
696
696
  declares a key, a jurisdiction, an authority and a URL and registers itself; that
697
697
  `#parse` returns Entities with unique, deterministic ids, a canonical type and at
@@ -96,10 +96,11 @@ watchlist, a store backed by somebody's own database — and those authors are
96
96
  not reading this repository's release notes. So the required methods of each,
97
97
  their arguments and what they must return do not change within a major version,
98
98
  and the shared conformance groups
99
- ([`spec/active_sanction/sources/conformance_spec.rb`](../spec/active_sanction/sources/conformance_spec.rb),
100
- [`spec/active_sanction/storage/conformance_spec.rb`](../spec/active_sanction/storage/conformance_spec.rb))
99
+ ([`lib/active_sanction/testing/sanction_source.rb`](../lib/active_sanction/testing/sanction_source.rb),
100
+ [`lib/active_sanction/testing/storage_adapter.rb`](../lib/active_sanction/testing/storage_adapter.rb))
101
101
  are the executable statement of what they require. An adapter that passes them
102
- today passes them for the life of the major version.
102
+ today passes them for the life of the major version — and **they ship**, so an
103
+ adapter outside this repository can run them: `require "active_sanction/testing"`.
103
104
 
104
105
  New *optional* hooks may be added — a method with a default implementation on
105
106
  the base class is not a break, because an adapter that does not define it goes
@@ -193,8 +194,10 @@ oversight:
193
194
  themselves are public — they are how an adapter is written — but the classes
194
195
  that do the reading underneath them are not.
195
196
  - **Anything under `spec/`, `benchmark/`, `canary/` or `bin/`.** None of it
196
- ships in the gem. The one exception in spirit is the two conformance groups,
197
- which do not ship either but which an adapter author is expected to run.
197
+ ships in the gem. The two conformance groups used to be the exception in
198
+ spirit expected to be run by adapter authors who had no way to get them —
199
+ and they are now under `lib/active_sanction/testing/` and public, which is
200
+ what the section above is about.
198
201
 
199
202
  ## The enumerated public surface
200
203
 
@@ -207,6 +210,7 @@ ActiveSanction
207
210
  ActiveSanction::VERSION
208
211
  ActiveSanction::MATCHER_VERSION
209
212
  ActiveSanction::Client
213
+ ActiveSanction::Client::CAPABILITIES
210
214
  ActiveSanction::Configuration
211
215
  ActiveSanction::Deprecation
212
216
  ActiveSanction::Configuration::DEFAULT_CACHE_DIRNAME
@@ -300,6 +304,29 @@ ActiveSanction::Parsers::Warning
300
304
  ActiveSanction::Parsers::ColumnShape
301
305
  ```
302
306
 
307
+ ### Testing your own adapter
308
+
309
+ ```
310
+ ActiveSanction::Testing
311
+ ActiveSanction::Testing::DEFAULT_FIXTURE_ROOT
312
+ ActiveSanction::Testing::StorageAdapterDefaults
313
+ ```
314
+
315
+ Loaded by `require "active_sanction/testing"`, never by `require
316
+ "active_sanction"`. The two shared example group **names** — `"a sanction
317
+ source"` and `"a storage adapter"` — are public on the same terms as the
318
+ constants: a group is not renamed or removed within a major version, and the
319
+ options it accepts do not change meaning under an adapter that passes it
320
+ today.
321
+
322
+ New examples may be **added** to a group, and that is not a breaking change
323
+ even though it can turn a passing adapter red. It is the same promise the
324
+ extension points make read from the other side: what the group checks is what
325
+ `Sources::Base` and `Storage::Base` required all along, and an adapter that
326
+ fails a newly added example was always violating the contract — the group
327
+ merely started saying so. Additions land in a minor, with a `CHANGELOG.md`
328
+ entry naming them.
329
+
303
330
  ### Storage, and what a fetch remembers
304
331
 
305
332
  ```
@@ -79,6 +79,15 @@ module ActiveSanction
79
79
  class Client
80
80
  extend T::Sig
81
81
 
82
+ # What a client may be asked to do. Every one of them is a public method
83
+ # on this class, and this class supports all of them; see #supports?, and
84
+ # the note there on why an unknown name is false rather than an error.
85
+ #
86
+ # A name is not removed from this list within a major version, and a new
87
+ # one may be added -- which is the same promise the enumerated public
88
+ # surface makes, for the same reason.
89
+ CAPABILITIES = T.let(%i[screen screen_all sync diff rescreen doctor export import].freeze, T::Array[Symbol])
90
+
82
91
  # The settings this client answers from, frozen. Reading one is how a host
83
92
  # asks what a client is: `client.configuration.user_agent`.
84
93
  sig { returns(Configuration) }
@@ -330,6 +339,34 @@ module ActiveSanction
330
339
  sig { returns(T::Boolean) }
331
340
  def loaded? = !@lock.synchronize { @matcher }.nil?
332
341
 
342
+ # Whether this client does a thing, asked rather than assumed:
343
+ #
344
+ # client.supports?(:sync) # => true
345
+ # client.supports?(:diff) # => true
346
+ #
347
+ # This client supports all of CAPABILITIES -- it fetches, parses, stores
348
+ # and screens on the machine it runs on, and there is nothing in the list
349
+ # it cannot do. The method exists for the ones that are not this class.
350
+ # #56 was closed because `Client` already is the interface an out-of-tree
351
+ # implementation duck-types, and the one thing a duck-typed interface
352
+ # cannot express is *absence*: an implementation that answers screening
353
+ # questions against data somebody else keeps fresh has no `sync!` to
354
+ # offer, and a caller that cannot ask ends up finding out by rescuing
355
+ # NoMethodError.
356
+ #
357
+ # ### An unknown capability is false rather than an error
358
+ #
359
+ # Deliberately unlike Configuration, where an unrecognised setting raises
360
+ # because a silently dropped one is a client running on a default somebody
361
+ # thinks they changed. The direction here is the opposite: this is a
362
+ # forward-compatibility question, and the caller asking it is usually
363
+ # written against a newer version than the one answering. A host asking
364
+ # `supports?(:something_1_4_added)` of a 1.2 client wants `false` and a
365
+ # fallback path, not an exception -- raising would make the method useless
366
+ # for the one job it has.
367
+ sig { params(capability: T.untyped).returns(T::Boolean) }
368
+ def supports?(capability) = CAPABILITIES.include?(capability.to_sym)
369
+
333
370
  sig { returns(String) }
334
371
  def inspect
335
372
  named = sources ? T.must(sources).join(", ") : "every registered source"
@@ -0,0 +1,234 @@
1
+ # typed: ignore
2
+ # frozen_string_literal: true
3
+
4
+ # The contract every source adapter must satisfy, written once.
5
+ #
6
+ # Loaded by `require "active_sanction/testing"` -- see ActiveSanction::Testing,
7
+ # which is where the entry point and the fixture root are documented.
8
+ #
9
+ # RSpec.describe ActiveSanction::Sources::CanadaSema do
10
+ # it_behaves_like "a sanction source", fixture: "canada_sema/sema.xml"
11
+ # end
12
+ #
13
+ # A list its publisher splits across several files names each one, under the
14
+ # keys the adapter declared its URLs with:
15
+ #
16
+ # it_behaves_like "a sanction source",
17
+ # fixture: { sdn: "ofac_sdn/SDN.CSV", alt: "ofac_sdn/ALT.CSV", add: "ofac_sdn/ADD.CSV" }
18
+ #
19
+ # Paths are relative to `spec/fixtures` -- or to whatever
20
+ # `ActiveSanction::Testing.fixture_root` names, and an absolute path is taken
21
+ # as it stands. The bytes reach #parse exactly as they were committed --
22
+ # undecoded, so an adapter reading the Windows-1252 OFAC serves is held to
23
+ # doing its own decoding rather than to being handed a String somebody already
24
+ # fixed up.
25
+ #
26
+ # ### What this is for
27
+ #
28
+ # Everything downstream of an adapter -- storage, the diff between two syncs,
29
+ # the index, the matcher, the report an examiner reads -- is written against
30
+ # Entity and never against a list. That only holds if every adapter really
31
+ # does produce the same shape, and "the same shape" is otherwise a paragraph
32
+ # in a design document that each new adapter re-interprets. Here it is
33
+ # executable, so adding a jurisdiction is a checklist rather than an open
34
+ # research question.
35
+ #
36
+ # ### What it does not do
37
+ #
38
+ # It does not check that the adapter read its list *correctly*. Nothing here
39
+ # knows that OFAC writes `-0-` for null, that the UN means two different
40
+ # things by QUALITY, or which of the fixture's records is a vessel. Only a
41
+ # spec that knows what is in the fixture can check that, so every adapter
42
+ # still writes its own; this group is the floor, not the ceiling.
43
+ #
44
+ # ### Options
45
+ #
46
+ # fixture: required. A path under the fixture root, or a Hash of one path
47
+ # per declared URL. Real published records, not invented ones --
48
+ # a conformance run against a fixture somebody wrote to pass it
49
+ # proves nothing about the list.
50
+ #
51
+ # remarks: pass `remarks: false` for a list that publishes no free text of
52
+ # its own at all, so the check that the publisher's own words
53
+ # survive into `remarks` is skipped. Every list at launch does
54
+ # publish some, which is why the default is to require it: a
55
+ # remark quietly dropped is how a place of birth or a passport
56
+ # number stops reaching #19.
57
+ RSpec.shared_examples "a sanction source" do |options = {}|
58
+ publishes_remarks = options.fetch(:remarks, true)
59
+
60
+ # Read through a `let` rather than closed over directly, so that a group
61
+ # that forgot to name a fixture says so when an example asks for one rather
62
+ # than while the suite is still loading.
63
+ let(:fixture) do
64
+ options.fetch(:fixture) do
65
+ raise ArgumentError, %(pass the fixture to parse: it_behaves_like "a sanction source", fixture: "list.xml")
66
+ end
67
+ end
68
+
69
+ let(:source) { described_class.new }
70
+ let(:payload) { fixture_payload(fixture) { |bytes| bytes } }
71
+ let(:records) { source.parse(payload) }
72
+
73
+ # Fixture bytes in the shape this adapter's #parse takes: the bytes
74
+ # themselves for a source declaring one file, a Hash keyed by declaration
75
+ # name for one declaring several. The same rule Base applies before calling
76
+ # #parse, so an adapter is exercised here exactly as `sync` exercises it.
77
+ def fixture_payload(paths)
78
+ bytes = named_fixtures(paths).to_h { |name, path| [name, yield(File.binread(fixture_path(path)))] }
79
+ described_class.multi_url? ? bytes : bytes.values.first
80
+ end
81
+
82
+ # One fixture path per declared file. A source declaring one file may name it
83
+ # as a bare path, which is what reading a fixture off disk looks like.
84
+ def named_fixtures(paths)
85
+ files = paths.is_a?(Hash) ? paths : { described_class.urls.keys.first => paths }
86
+ missing = described_class.urls.keys - files.keys
87
+ raise ArgumentError, "no fixture given for the #{missing.join(", ")} file(s) this source declares" if missing.any?
88
+
89
+ files
90
+ end
91
+
92
+ # Resolved through Testing rather than relative to this file, which is what
93
+ # lets the group work for a project whose fixtures are its own. See
94
+ # ActiveSanction::Testing.fixture_root.
95
+ def fixture_path(path) = ActiveSanction::Testing.fixture_path(path)
96
+
97
+ # Every date any entity carries, wherever the model puts them.
98
+ def entity_dates(entities)
99
+ entities.flat_map do |entity|
100
+ [entity.listed_on, *entity.dates_of_birth,
101
+ *entity.identifiers.flat_map { |identifier| [identifier.issued_on, identifier.expires_on] }]
102
+ end.compact
103
+ end
104
+
105
+ # The serialized records half the bytes produce, or the marker that reading
106
+ # them raised -- which is the other acceptable answer, and never equal to a
107
+ # complete parse.
108
+ def parsed_truncated(paths)
109
+ truncated = fixture_payload(paths) { |bytes| bytes[0, bytes.bytesize / 2] }
110
+ described_class.new.parse(truncated).map(&:to_h)
111
+ rescue ActiveSanction::Error
112
+ :raised
113
+ end
114
+
115
+ describe "what it declares" do
116
+ it "declares a key, which is the name the list answers to everywhere" do
117
+ expect(described_class.key.to_s).to match(ActiveSanction::Sources::Definition::KEY_PATTERN)
118
+ end
119
+
120
+ it "declares the jurisdiction behind the list" do
121
+ expect(described_class.jurisdiction).to be_a(Symbol)
122
+ end
123
+
124
+ it "declares the authority a compliance report has to print beside a hit" do
125
+ expect(described_class.authority).to be_a(String)
126
+ end
127
+
128
+ it "declares at least one URL to fetch the list from" do
129
+ expect(described_class.urls).not_to be_empty
130
+ end
131
+
132
+ # An adapter whose file is required but which never registers is invisible:
133
+ # `config.sources` cannot name it and `sync` will not run it, and nothing
134
+ # says so out loud.
135
+ it "registers itself, so requiring its file is enough to reach it" do
136
+ expect(ActiveSanction::Sources[described_class.key]).to eq(described_class)
137
+ end
138
+ end
139
+
140
+ describe "what #parse returns" do
141
+ it "builds at least one record from the fixture" do
142
+ expect(records).not_to be_empty
143
+ end
144
+
145
+ it "returns Entities and nothing else" do
146
+ expect(records).to all(be_an(ActiveSanction::Entity))
147
+ end
148
+
149
+ it "stamps every entity with this adapter's own key" do
150
+ expect(records.map(&:source).uniq).to eq([described_class.key])
151
+ end
152
+
153
+ it "gives every entity an id" do
154
+ expect(records.map(&:id)).to all(match(/\S/))
155
+ end
156
+
157
+ # A record with no name cannot be screened against, so it is not a record.
158
+ it "gives every entity at least one name" do
159
+ expect(records.reject { |record| record.names.any? }).to be_empty
160
+ end
161
+
162
+ # Types are the matcher's coarsest filter: a search for a person that can
163
+ # rank a ship is the failure this closes. Entity refuses anything else on
164
+ # construction, so this fails only for something that is not an Entity.
165
+ it "types every entity as one of the four canonical types" do
166
+ expect(records.map(&:type).uniq - ActiveSanction::Entity::TYPES).to be_empty
167
+ end
168
+
169
+ # Two records under one id are one record to storage, and the second
170
+ # silently replaces the first.
171
+ it "gives no two entities the same id" do
172
+ expect(records.map(&:id).tally.select { |_id, count| count > 1 }).to be_empty
173
+ end
174
+ end
175
+
176
+ # #35 diffs two syncs by comparing records under their ids. An id that moves
177
+ # because a Hash iterated differently, or because a counter was involved,
178
+ # reports the whole list as removed and re-added -- and a diff that says
179
+ # everything changed says nothing at all.
180
+ describe "reading the same bytes twice" do
181
+ it "produces the same ids" do
182
+ expect(described_class.new.parse(payload).map(&:id)).to eq(records.map(&:id))
183
+ end
184
+
185
+ it "produces the same records, in the same order" do
186
+ expect(described_class.new.parse(payload).map(&:to_h)).to eq(records.map(&:to_h))
187
+ end
188
+ end
189
+
190
+ describe "the canonical model it produces" do
191
+ # A date left as the publisher's string cannot be compared with one from
192
+ # another list, and PartialDate exists because none of these lists agree on
193
+ # how precise a date of birth is. Entity does not coerce, so an adapter
194
+ # that forgets is caught here rather than in the scorer.
195
+ it "publishes every date as a PartialDate, never as the string it was written as" do
196
+ expect(entity_dates(records)).to all(be_a(ActiveSanction::PartialDate))
197
+ end
198
+
199
+ # What storage (#24) does to every record on the way to disk and back.
200
+ it "round-trips every entity through the serialized form" do
201
+ expect(records.map { |record| ActiveSanction::Entity.from_h(record.to_h) }).to eq(records)
202
+ end
203
+
204
+ if publishes_remarks
205
+ # The publisher's own prose is where the fields the canonical model has
206
+ # no home for still live -- OFAC's dates of birth and passport numbers
207
+ # are nowhere else -- so an adapter that drops it loses data no later
208
+ # issue can get back.
209
+ it "keeps the publisher's own text in remarks" do
210
+ expect(records.filter_map { |record| ActiveSanction::Sources::Remarks.published(record.remarks) })
211
+ .not_to be_empty
212
+ end
213
+ end
214
+ end
215
+
216
+ describe "a payload that is not the list" do
217
+ # No sanctions list has ever been empty, so an empty payload is a failed
218
+ # download, a moved URL or an outage -- never a day on which nobody is
219
+ # sanctioned. Returning [] lets a sync succeed at screening against
220
+ # nothing, which is the most expensive way this library can fail.
221
+ it "refuses an empty payload rather than reporting a list with nobody on it" do
222
+ expect { described_class.new.parse(fixture_payload(fixture) { "" }) }
223
+ .to raise_error(ActiveSanction::Error)
224
+ end
225
+
226
+ # A truncated download may still be worth salvaging -- the XML toolkit
227
+ # keeps the records it read before the break and warns -- so what is
228
+ # required is not that it raises, only that half a list never comes back
229
+ # looking exactly like the whole one.
230
+ it "does not report a truncated document as though it were the whole list" do
231
+ expect(parsed_truncated(fixture)).not_to eq(records.map(&:to_h))
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,319 @@
1
+ # typed: ignore
2
+ # frozen_string_literal: true
3
+
4
+ # The contract every storage adapter must satisfy, written once.
5
+ #
6
+ # Loaded by `require "active_sanction/testing"` -- see ActiveSanction::Testing,
7
+ # which is where the entry point and the fixture root are documented.
8
+ #
9
+ # RSpec.describe ActiveSanction::Storage::Memory do
10
+ # it_behaves_like "a storage adapter"
11
+ # end
12
+ #
13
+ # An adapter that needs something to be constructed -- a root directory, a
14
+ # connection -- says how in the customization block, which is evaluated after
15
+ # this group and so wins. It is a method rather than a `let` because a couple
16
+ # of the examples need a second, independent store:
17
+ #
18
+ # RSpec.describe ActiveSanction::Storage::FileSystem do
19
+ # it_behaves_like "a storage adapter" do
20
+ # def build_store = described_class.new(root: Dir.mktmpdir)
21
+ # end
22
+ # end
23
+ #
24
+ # ### What this is for
25
+ #
26
+ # The matcher (#33) is written against Storage::Base and never against a
27
+ # concrete store, which is the whole point of the interface: an installation
28
+ # swaps gzipped JSON for Postgres without touching the code that decides
29
+ # whether two names are the same person. That only holds if every adapter
30
+ # really does mean the same thing by these five methods, and "the same thing"
31
+ # is otherwise a paragraph in a design document that each new adapter
32
+ # re-interprets. Here it is executable.
33
+ #
34
+ # Most of what it checks is a way of losing records quietly. A store that
35
+ # returns an empty snapshot for a source nobody ever synced, one that drops the
36
+ # third of four entities on the way back, one that reorders them, one that
37
+ # accumulates two writes of a list instead of replacing it -- none of those
38
+ # raise, all of them return something that looks like a sanctions list, and the
39
+ # report they produce says the name you screened is clear.
40
+ #
41
+ # ### What it does not do
42
+ #
43
+ # It says nothing about durability, concurrency or performance, which are the
44
+ # things the adapters genuinely differ on: that a FileSystem write is atomic
45
+ # (#24), that an ActiveRecord write is one transaction (#25), that a Memory
46
+ # store is safe to screen from on many threads. Those are properties of one
47
+ # implementation and each adapter's own spec has to make them.
48
+ RSpec.shared_examples "a storage adapter" do
49
+ include ActiveSanction::Testing::StorageAdapterDefaults
50
+
51
+ let(:store) { build_store }
52
+ let(:ofac) { snapshot(:ofac_sdn, %w[2674 1234]) }
53
+ let(:un) { snapshot(:un_consolidated, %w[QDi.001]) }
54
+
55
+ # A record carrying every kind of member the canonical model has, because
56
+ # what a store loses is usually nested: a store that persists names and drops
57
+ # identifiers passes a contract written against bare strings.
58
+ def entity(source, ref)
59
+ ActiveSanction::Entity.new(
60
+ source: source, source_ref: ref, type: :individual,
61
+ names: [ActiveSanction::Name.new(value: "AL ZAWAHIRI #{ref}, Aiman", kind: :primary)],
62
+ addresses: [ActiveSanction::Address.new(country: "EG", city: "Cairo")],
63
+ identifiers: [ActiveSanction::Identifier.new(kind: :passport, value: ref, country: "EG")],
64
+ dates_of_birth: [ActiveSanction::PartialDate.parse("1951-06-19")],
65
+ nationalities: ["EG"], programs: ["SDGT"],
66
+ listed_on: ActiveSanction::PartialDate.parse("2001-09-23"),
67
+ remarks: "DOB 19 Jun 1951; POB Egypt"
68
+ )
69
+ end
70
+
71
+ def snapshot(source, refs)
72
+ ActiveSanction::Snapshot.new(
73
+ source: source, entities: refs.map { |ref| entity(source, ref) },
74
+ fetched_at: Time.utc(2026, 8, 28, 9, 30, 0), source_version: "Thu, 28 Aug 2026 09:00:00 GMT"
75
+ )
76
+ end
77
+
78
+ def entity_refs(entities) = entities.map(&:source_ref)
79
+
80
+ describe "a source that has never been synced" do
81
+ # Nil says "we have never fetched this". An empty snapshot says "this list
82
+ # has nobody on it", which no sanctions list has ever said, and a caller
83
+ # that cannot tell them apart screens against nothing and reports clear.
84
+ it "reads back as nil rather than as a list with nobody on it" do
85
+ expect(store.read_snapshot(:ofac_sdn)).to be_nil
86
+ end
87
+
88
+ it "has no metadata to report" do
89
+ expect(store.snapshot_meta(:ofac_sdn)).to be_nil
90
+ end
91
+
92
+ it "is not among the stored sources" do
93
+ expect(store.sources).not_to include(:ofac_sdn)
94
+ end
95
+
96
+ it "is not reported as stored" do
97
+ expect(store).not_to be_stored(:ofac_sdn)
98
+ end
99
+
100
+ # The other half of returning nil: a caller that named the list itself gets
101
+ # an exception rather than an empty result it has to remember to check.
102
+ it "raises when a caller asks for it by name" do
103
+ expect { store.fetch_snapshot(:ofac_sdn) }.to raise_error(ActiveSanction::Storage::MissingSnapshot)
104
+ end
105
+
106
+ it "deletes without complaint, and says there was nothing to delete" do
107
+ expect(store.delete_snapshot(:ofac_sdn)).to be_falsey
108
+ end
109
+ end
110
+
111
+ describe "what comes back out" do
112
+ before { store.write_snapshot(ofac) }
113
+
114
+ it "returns the snapshot that was written" do
115
+ expect(store.read_snapshot(:ofac_sdn)).to eq(ofac)
116
+ end
117
+
118
+ # Snapshot#== compares checksums, so the example above passes for a store
119
+ # that kept the checksum and lost the list. This is the one that does not.
120
+ it "returns every record, field for field" do
121
+ expect(store.read_snapshot(:ofac_sdn).entities.map(&:to_h)).to eq(ofac.entities.map(&:to_h))
122
+ end
123
+
124
+ # A store that hands back half-deserialized records makes every caller
125
+ # downstream coerce before it can read a name.
126
+ it "returns Entities, not the hashes they serialize to" do
127
+ expect(store.read_snapshot(:ofac_sdn).entities).to all(be_an(ActiveSanction::Entity))
128
+ end
129
+
130
+ # Order is not in the checksum -- Snapshot sorts fingerprints before
131
+ # folding them -- so nothing else catches a store that returns its rows in
132
+ # whatever order the database felt like. A report whose rows move between
133
+ # two runs against an unchanged list is a report an examiner cannot cite.
134
+ it "returns the records in the order they were written" do
135
+ expect(entity_refs(store.read_snapshot(:ofac_sdn).entities)).to eq(entity_refs(ofac.entities))
136
+ end
137
+
138
+ it "returns the checksum the snapshot was written with" do
139
+ expect(store.read_snapshot(:ofac_sdn).checksum).to eq(ofac.checksum)
140
+ end
141
+
142
+ # To the second, which is what Snapshot#to_h serializes. An adapter that
143
+ # loses it cannot answer how old the list it is screening against is (#34).
144
+ it "returns the time the list was fetched" do
145
+ expect(store.read_snapshot(:ofac_sdn).fetched_at).to eq(ofac.fetched_at)
146
+ end
147
+
148
+ it "returns the publisher's own version marker" do
149
+ expect(store.read_snapshot(:ofac_sdn).source_version).to eq(ofac.source_version)
150
+ end
151
+
152
+ # Keys arrive as symbols from an adapter and as strings from anything that
153
+ # has been through JSON or a command line.
154
+ it "answers to the source name spelled as a string" do
155
+ expect(store.read_snapshot("ofac_sdn")).to eq(ofac)
156
+ end
157
+
158
+ it "lists the source it stored" do
159
+ expect(store.sources).to eq([:ofac_sdn])
160
+ end
161
+ end
162
+
163
+ describe "what it refuses" do
164
+ # A Hash of the right shape carries a checksum somebody typed; only a
165
+ # Snapshot carries one computed over its own content, and that is the whole
166
+ # basis on which a stored list can be cited months later.
167
+ it "refuses to store something that is not a Snapshot" do
168
+ expect { store.write_snapshot(ofac.to_h) }.to raise_error(ArgumentError)
169
+ end
170
+
171
+ # Source keys are typed by humans and are directory names to any adapter
172
+ # that writes files, so they are held to one rule everywhere.
173
+ it "refuses a name that is not a usable source key" do
174
+ expect { store.read_snapshot("") }.to raise_error(ActiveSanction::Error)
175
+ end
176
+ end
177
+
178
+ describe "writing" do
179
+ it "returns the snapshot it stored" do
180
+ expect(store.write_snapshot(ofac)).to eq(ofac)
181
+ end
182
+
183
+ # A sync replaces a list; it does not add to one. A store that accumulates
184
+ # holds a delisted person forever, which is a false hit on every screening
185
+ # run from then on.
186
+ it "replaces a list rather than accumulating two of them" do
187
+ store.write_snapshot(ofac)
188
+ store.write_snapshot(snapshot(:ofac_sdn, %w[9999]))
189
+
190
+ expect(entity_refs(store.read_snapshot(:ofac_sdn).entities)).to eq(%w[9999])
191
+ end
192
+
193
+ it "files a rewritten source once" do
194
+ store.write_snapshot(ofac)
195
+ store.write_snapshot(snapshot(:ofac_sdn, %w[9999]))
196
+
197
+ expect(store.sources).to eq([:ofac_sdn])
198
+ end
199
+
200
+ # Per-source isolation is the rule sync orchestration (#34) is built on: one
201
+ # list failing must leave the others exactly as they were.
202
+ it "leaves the other lists alone" do
203
+ store.write_snapshot(un)
204
+ store.write_snapshot(ofac)
205
+
206
+ expect(store.read_snapshot(:un_consolidated)).to eq(un)
207
+ end
208
+
209
+ it "sorts the sources it lists, so a summary does not reshuffle itself" do
210
+ store.write_snapshot(un)
211
+ store.write_snapshot(ofac)
212
+
213
+ expect(store.sources).to eq(%i[ofac_sdn un_consolidated])
214
+ end
215
+ end
216
+
217
+ describe "#snapshot_meta" do
218
+ before { store.write_snapshot(ofac) }
219
+
220
+ it "reports what was stored without being asked for the list" do
221
+ expect(store.snapshot_meta(:ofac_sdn)).to eq(ActiveSanction::Storage::Meta.from_snapshot(ofac))
222
+ end
223
+
224
+ it "counts the records" do
225
+ expect(store.snapshot_meta(:ofac_sdn).record_count).to eq(2)
226
+ end
227
+
228
+ # What a match result cites (#33). A meta whose checksum is not the stored
229
+ # snapshot's cannot answer which list version cleared a customer.
230
+ it "carries the stored snapshot's checksum" do
231
+ expect(store.snapshot_meta(:ofac_sdn).checksum).to eq(ofac.checksum)
232
+ end
233
+ end
234
+
235
+ describe "#each_entity" do
236
+ before do
237
+ store.write_snapshot(ofac)
238
+ store.write_snapshot(un)
239
+ end
240
+
241
+ it "yields every record of every stored list" do
242
+ expect(store.each_entity.to_a.size).to eq(3)
243
+ end
244
+
245
+ it "yields Entities" do
246
+ expect(store.each_entity.to_a).to all(be_an(ActiveSanction::Entity))
247
+ end
248
+
249
+ # The index build (#31) walks every entity of every list. Handing it an
250
+ # array of ~25,000 records built before the first one is yielded is a cost
251
+ # nothing here needs to pay.
252
+ it "returns an Enumerator rather than an array when given no block" do
253
+ expect(store.each_entity).to be_an(Enumerator)
254
+ end
255
+
256
+ it "stops when the caller stops" do
257
+ yielded = []
258
+ store.each_entity do |entity|
259
+ yielded << entity
260
+ break if yielded.any?
261
+ end
262
+
263
+ expect(yielded.size).to eq(1)
264
+ end
265
+
266
+ it "yields only the lists it was asked for" do
267
+ expect(entity_refs(store.each_entity(sources: %i[un_consolidated]).to_a)).to eq(%w[QDi.001])
268
+ end
269
+
270
+ it "takes one source named on its own" do
271
+ expect(store.each_entity(sources: :un_consolidated).to_a.size).to eq(1)
272
+ end
273
+
274
+ # Screening two of the three lists an application configured, and saying
275
+ # nothing about the third, is the failure this contract exists to prevent.
276
+ it "refuses to quietly skip a named list that was never synced" do
277
+ expect { store.each_entity(sources: %i[ofac_sdn eu_fsf]).to_a }
278
+ .to raise_error(ActiveSanction::Storage::MissingSnapshot)
279
+ end
280
+
281
+ it "yields nothing from an empty store" do
282
+ expect(build_store.each_entity.to_a).to be_empty
283
+ end
284
+ end
285
+
286
+ describe "removing a list" do
287
+ before { store.write_snapshot(ofac) }
288
+
289
+ it "reports that there was one to remove" do
290
+ expect(store.delete_snapshot(:ofac_sdn)).to be_truthy
291
+ end
292
+
293
+ it "forgets the snapshot" do
294
+ store.delete_snapshot(:ofac_sdn)
295
+
296
+ expect(store.read_snapshot(:ofac_sdn)).to be_nil
297
+ end
298
+
299
+ it "stops listing the source" do
300
+ store.delete_snapshot(:ofac_sdn)
301
+
302
+ expect(store.sources).to be_empty
303
+ end
304
+
305
+ it "leaves the other lists alone" do
306
+ store.write_snapshot(un)
307
+ store.delete_snapshot(:ofac_sdn)
308
+
309
+ expect(store.read_snapshot(:un_consolidated)).to eq(un)
310
+ end
311
+
312
+ it "empties the whole store on #clear" do
313
+ store.write_snapshot(un)
314
+ store.clear
315
+
316
+ expect(store).to be_empty
317
+ end
318
+ end
319
+ end
@@ -0,0 +1,25 @@
1
+ # typed: ignore
2
+ # frozen_string_literal: true
3
+
4
+ # How the storage conformance group builds the adapter it is testing.
5
+ #
6
+ # In a module, and in a file of its own, for two reasons that are both about
7
+ # not defining a method twice. A group is free to override `build_store` in the
8
+ # customization block it passes to `it_behaves_like` -- which is how an adapter
9
+ # that cannot be built by `.new` alone gets held to the contract -- and
10
+ # defining a method over one the group already had would warn, since this suite
11
+ # runs with Ruby warnings on. Keeping it out of the shared example group's own
12
+ # file is the same rule one level up: the conformance spec loads that file
13
+ # again inside an RSpec sandbox, and a module reopened there would redefine
14
+ # whatever it holds.
15
+ #
16
+ # Namespaced, because it ships: a bare `StorageAdapterDefaults` at the top
17
+ # level would be this gem putting a name into a host's global namespace to
18
+ # save itself two words.
19
+ module ActiveSanction
20
+ module Testing
21
+ module StorageAdapterDefaults
22
+ def build_store = described_class.new
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,119 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "pathname"
5
+ require "sorbet-runtime"
6
+
7
+ require "active_sanction"
8
+
9
+ module ActiveSanction
10
+ # The conformance groups, shipped, so that an adapter written outside this
11
+ # repository can prove it conforms.
12
+ #
13
+ # # spec/spec_helper.rb, in your application
14
+ # require "active_sanction/testing"
15
+ #
16
+ # # spec/internal_watchlist_spec.rb
17
+ # RSpec.describe MyCompany::InternalWatchlist do
18
+ # it_behaves_like "a sanction source", fixture: "internal_watchlist/list.csv"
19
+ # end
20
+ #
21
+ # RSpec.describe MyCompany::PostgresStore do
22
+ # it_behaves_like "a storage adapter" do
23
+ # def build_store = described_class.new(url: ENV.fetch("DATABASE_URL"))
24
+ # end
25
+ # end
26
+ #
27
+ # Two groups: **"a sanction source"** for a Sources::Base subclass, and
28
+ # **"a storage adapter"** for a Storage::Base implementation. Each is
29
+ # documented at the top of its own file.
30
+ #
31
+ # ### Why this is in `lib/` and not in `spec/`
32
+ #
33
+ # `docs/api_stability.md` names Sources::Base, Storage::Base and
34
+ # ValidatorStore as the three extension points carrying the strongest
35
+ # guarantee, and says of these groups that they "are the executable
36
+ # statement of what they require. An adapter that passes them today passes
37
+ # them for the life of the major version."
38
+ #
39
+ # That promise is addressed to people who install this gem, and until this
40
+ # file existed it was kept only for adapters living in this repository:
41
+ # `spec/` is excluded from the package, so the executable statement shipped
42
+ # nowhere and the guarantee was a paragraph again for everyone it was
43
+ # written for. A conformance suite that its audience cannot run is a
44
+ # conformance suite in name.
45
+ #
46
+ # ### Requiring it is opt-in, and costs a host nothing
47
+ #
48
+ # `require "active_sanction"` does not load this, and nothing under `lib/`
49
+ # requires it. It is loaded by a suite that asked for it, needs RSpec
50
+ # already loaded, and says so rather than failing somewhere stranger if it
51
+ # is not.
52
+ #
53
+ # ### What they do not do
54
+ #
55
+ # Neither group knows anything about *your* list. They are the floor: that
56
+ # an adapter declares what it must, produces Entities of the right shape,
57
+ # gives every record a stable id, and reports what it could not read. Only a
58
+ # spec that knows which of your fixture's records is a vessel can check that
59
+ # you read it correctly, and you still write that one.
60
+ module Testing
61
+ extend T::Sig
62
+
63
+ # Where "a sanction source" looks for the fixture a group names, relative
64
+ # to the working directory a suite runs from. `spec/fixtures` is where
65
+ # RSpec projects put them, which makes the default right for most callers
66
+ # and the setting below right for the rest.
67
+ DEFAULT_FIXTURE_ROOT = T.let("spec/fixtures", String)
68
+
69
+ class << self
70
+ extend T::Sig
71
+
72
+ # Where fixture paths are resolved from. Set it when a project keeps
73
+ # them somewhere other than `spec/fixtures`:
74
+ #
75
+ # ActiveSanction::Testing.fixture_root = "test/data/sanctions"
76
+ #
77
+ # Relative to the working directory, which for a suite is the project
78
+ # root. An absolute path passed to `fixture:` ignores this entirely.
79
+ sig { params(value: T.untyped).void }
80
+ def fixture_root=(value)
81
+ @fixture_root = T.let(value&.to_s, T.nilable(String))
82
+ end
83
+
84
+ sig { returns(String) }
85
+ def fixture_root
86
+ @fixture_root ||= T.let(nil, T.nilable(String))
87
+ File.expand_path(@fixture_root || DEFAULT_FIXTURE_ROOT, Dir.pwd)
88
+ end
89
+
90
+ # One fixture, as an absolute path. An absolute path is returned as it
91
+ # stands, so a project that generates a fixture into a temporary
92
+ # directory can name it directly.
93
+ #
94
+ # Missing files are refused here rather than at `File.binread`, because
95
+ # a conformance run against a fixture that is not there should say which
96
+ # fixture and where it looked -- the two facts needed to fix it.
97
+ sig { params(path: T.untyped).returns(String) }
98
+ def fixture_path(path)
99
+ name = path.to_s
100
+ resolved = Pathname.new(name).absolute? ? name : File.expand_path(name, fixture_root)
101
+ return resolved if File.file?(resolved)
102
+
103
+ raise ArgumentError,
104
+ "no fixture at #{resolved}. Paths are resolved under #{fixture_root} -- " \
105
+ "set ActiveSanction::Testing.fixture_root if yours live somewhere else."
106
+ end
107
+ end
108
+ end
109
+ end
110
+
111
+ unless defined?(RSpec)
112
+ raise LoadError,
113
+ "active_sanction/testing defines RSpec shared example groups, and RSpec is not loaded. " \
114
+ "Require it from your spec_helper, after rspec itself."
115
+ end
116
+
117
+ require "active_sanction/testing/storage_adapter_defaults"
118
+ require "active_sanction/testing/sanction_source"
119
+ require "active_sanction/testing/storage_adapter"
@@ -6,7 +6,7 @@ module ActiveSanction
6
6
  # new source adapter, a storage fix or a documentation release -- none of
7
7
  # which change what a name scores. MATCHER_VERSION, below, is the one that
8
8
  # answers that question.
9
- VERSION = "1.1.0"
9
+ VERSION = "1.1.1"
10
10
 
11
11
  # Which matching pipeline scored a decision, stamped onto every MatchResult
12
12
  # and bumped whenever a change to the normalizer, the index, the similarity
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_sanction
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Marshall Shen
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-14 00:00:00.000000000 Z
11
+ date: 2026-09-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: csv
@@ -201,6 +201,10 @@ files:
201
201
  - lib/active_sanction/sync.rb
202
202
  - lib/active_sanction/sync/report.rb
203
203
  - lib/active_sanction/sync/result.rb
204
+ - lib/active_sanction/testing.rb
205
+ - lib/active_sanction/testing/sanction_source.rb
206
+ - lib/active_sanction/testing/storage_adapter.rb
207
+ - lib/active_sanction/testing/storage_adapter_defaults.rb
204
208
  - lib/active_sanction/validator_store.rb
205
209
  - lib/active_sanction/validator_store/file_system.rb
206
210
  - lib/active_sanction/validator_store/memory.rb