active_sanction 1.0.1 → 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.
@@ -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.0.1"
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
@@ -6,6 +6,7 @@ require "sorbet-runtime"
6
6
  require "active_sanction/error"
7
7
  require "active_sanction/version"
8
8
  require "active_sanction/deprecation"
9
+ require "active_sanction/instrumentation"
9
10
  require "active_sanction/configuration"
10
11
  require "active_sanction/name"
11
12
  require "active_sanction/address"
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.0.1
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
@@ -102,6 +102,9 @@ files:
102
102
  - lib/active_sanction/index/candidate.rb
103
103
  - lib/active_sanction/index/entry.rb
104
104
  - lib/active_sanction/index/features.rb
105
+ - lib/active_sanction/instrumentation.rb
106
+ - lib/active_sanction/instrumentation/event.rb
107
+ - lib/active_sanction/instrumentation/notifications.rb
105
108
  - lib/active_sanction/match_result.rb
106
109
  - lib/active_sanction/matcher.rb
107
110
  - lib/active_sanction/name.rb
@@ -198,6 +201,10 @@ files:
198
201
  - lib/active_sanction/sync.rb
199
202
  - lib/active_sanction/sync/report.rb
200
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
201
208
  - lib/active_sanction/validator_store.rb
202
209
  - lib/active_sanction/validator_store/file_system.rb
203
210
  - lib/active_sanction/validator_store/memory.rb