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.
Files changed (143) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +538 -0
  3. data/CODE_OF_CONDUCT.md +74 -0
  4. data/CONTRIBUTING.md +312 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +616 -0
  7. data/SECURITY.md +97 -0
  8. data/active_sanction.gemspec +98 -0
  9. data/docs/adding_a_source.md +1083 -0
  10. data/docs/api_stability.md +357 -0
  11. data/docs/bundle_format.md +407 -0
  12. data/lib/active_sanction/address.rb +170 -0
  13. data/lib/active_sanction/client.rb +341 -0
  14. data/lib/active_sanction/configuration.rb +721 -0
  15. data/lib/active_sanction/countries.txt +292 -0
  16. data/lib/active_sanction/country.rb +164 -0
  17. data/lib/active_sanction/deprecation.rb +168 -0
  18. data/lib/active_sanction/diff/change.rb +208 -0
  19. data/lib/active_sanction/diff.rb +348 -0
  20. data/lib/active_sanction/doctor/checkup.rb +356 -0
  21. data/lib/active_sanction/doctor/diagnosis.rb +245 -0
  22. data/lib/active_sanction/doctor/finding.rb +183 -0
  23. data/lib/active_sanction/doctor/profile.rb +384 -0
  24. data/lib/active_sanction/doctor/report.rb +227 -0
  25. data/lib/active_sanction/doctor.rb +347 -0
  26. data/lib/active_sanction/entity.rb +293 -0
  27. data/lib/active_sanction/error.rb +332 -0
  28. data/lib/active_sanction/fetcher/result.rb +116 -0
  29. data/lib/active_sanction/fetcher.rb +242 -0
  30. data/lib/active_sanction/http_client/errors.rb +94 -0
  31. data/lib/active_sanction/http_client/response.rb +129 -0
  32. data/lib/active_sanction/http_client.rb +324 -0
  33. data/lib/active_sanction/identifier.rb +212 -0
  34. data/lib/active_sanction/index/builder.rb +89 -0
  35. data/lib/active_sanction/index/candidate.rb +63 -0
  36. data/lib/active_sanction/index/entry.rb +66 -0
  37. data/lib/active_sanction/index/features.rb +112 -0
  38. data/lib/active_sanction/index.rb +381 -0
  39. data/lib/active_sanction/match_result.rb +345 -0
  40. data/lib/active_sanction/matcher.rb +388 -0
  41. data/lib/active_sanction/name.rb +183 -0
  42. data/lib/active_sanction/normalizer/cache.rb +82 -0
  43. data/lib/active_sanction/normalizer/dictionaries/honorifics.txt +30 -0
  44. data/lib/active_sanction/normalizer/dictionaries/legal_forms.txt +72 -0
  45. data/lib/active_sanction/normalizer/dictionaries/organization_stopwords.txt +20 -0
  46. data/lib/active_sanction/normalizer/dictionaries/particles.txt +35 -0
  47. data/lib/active_sanction/normalizer/dictionary/stoplist.rb +121 -0
  48. data/lib/active_sanction/normalizer/dictionary.rb +243 -0
  49. data/lib/active_sanction/normalizer/form.rb +279 -0
  50. data/lib/active_sanction/normalizer.rb +137 -0
  51. data/lib/active_sanction/parsers/column_shape.rb +274 -0
  52. data/lib/active_sanction/parsers/delimited_table/reader.rb +194 -0
  53. data/lib/active_sanction/parsers/delimited_table/row.rb +81 -0
  54. data/lib/active_sanction/parsers/delimited_table.rb +151 -0
  55. data/lib/active_sanction/parsers/format.rb +107 -0
  56. data/lib/active_sanction/parsers/join.rb +149 -0
  57. data/lib/active_sanction/parsers/spreadsheet/archive.rb +258 -0
  58. data/lib/active_sanction/parsers/spreadsheet/reader.rb +213 -0
  59. data/lib/active_sanction/parsers/spreadsheet/row.rb +88 -0
  60. data/lib/active_sanction/parsers/spreadsheet/workbook.rb +314 -0
  61. data/lib/active_sanction/parsers/spreadsheet.rb +168 -0
  62. data/lib/active_sanction/parsers/xml_records/backends/nokogiri.rb +134 -0
  63. data/lib/active_sanction/parsers/xml_records/backends/rexml.rb +157 -0
  64. data/lib/active_sanction/parsers/xml_records/backends.rb +103 -0
  65. data/lib/active_sanction/parsers/xml_records/builder.rb +90 -0
  66. data/lib/active_sanction/parsers/xml_records/reader.rb +134 -0
  67. data/lib/active_sanction/parsers/xml_records/record.rb +174 -0
  68. data/lib/active_sanction/parsers/xml_records.rb +140 -0
  69. data/lib/active_sanction/parsers.rb +94 -0
  70. data/lib/active_sanction/partial_date/parser.rb +124 -0
  71. data/lib/active_sanction/partial_date.rb +303 -0
  72. data/lib/active_sanction/payload_cache/checksum.rb +66 -0
  73. data/lib/active_sanction/payload_cache/entry.rb +304 -0
  74. data/lib/active_sanction/payload_cache.rb +366 -0
  75. data/lib/active_sanction/phonetics/double_metaphone.rb +662 -0
  76. data/lib/active_sanction/phonetics.rb +40 -0
  77. data/lib/active_sanction/query.rb +290 -0
  78. data/lib/active_sanction/rescreen/alert.rb +314 -0
  79. data/lib/active_sanction/rescreen.rb +332 -0
  80. data/lib/active_sanction/scorer/adjustments.rb +241 -0
  81. data/lib/active_sanction/scorer/name_score.rb +279 -0
  82. data/lib/active_sanction/scorer/reason.rb +136 -0
  83. data/lib/active_sanction/scorer/result.rb +129 -0
  84. data/lib/active_sanction/scorer/subject.rb +235 -0
  85. data/lib/active_sanction/scorer/weights.rb +306 -0
  86. data/lib/active_sanction/scorer.rb +303 -0
  87. data/lib/active_sanction/similarity/jaro_winkler.rb +210 -0
  88. data/lib/active_sanction/similarity/levenshtein.rb +179 -0
  89. data/lib/active_sanction/similarity/token_set.rb +159 -0
  90. data/lib/active_sanction/similarity/token_sort.rb +120 -0
  91. data/lib/active_sanction/similarity.rb +183 -0
  92. data/lib/active_sanction/snapshot/bundle/header.rb +302 -0
  93. data/lib/active_sanction/snapshot/bundle/payload.rb +185 -0
  94. data/lib/active_sanction/snapshot/bundle/signature.rb +201 -0
  95. data/lib/active_sanction/snapshot/bundle.rb +381 -0
  96. data/lib/active_sanction/snapshot.rb +305 -0
  97. data/lib/active_sanction/sources/australia_dfat/published_date.rb +166 -0
  98. data/lib/active_sanction/sources/australia_dfat/record.rb +319 -0
  99. data/lib/active_sanction/sources/australia_dfat.rb +291 -0
  100. data/lib/active_sanction/sources/base.rb +295 -0
  101. data/lib/active_sanction/sources/canada_sema/record.rb +282 -0
  102. data/lib/active_sanction/sources/canada_sema/source_ref.rb +89 -0
  103. data/lib/active_sanction/sources/canada_sema.rb +153 -0
  104. data/lib/active_sanction/sources/definition.rb +360 -0
  105. data/lib/active_sanction/sources/eu_fsf/record.rb +467 -0
  106. data/lib/active_sanction/sources/eu_fsf.rb +203 -0
  107. data/lib/active_sanction/sources/ofac/record.rb +251 -0
  108. data/lib/active_sanction/sources/ofac/remarks_parser/coverage.rb +120 -0
  109. data/lib/active_sanction/sources/ofac/remarks_parser/vocabulary.rb +121 -0
  110. data/lib/active_sanction/sources/ofac/remarks_parser.rb +312 -0
  111. data/lib/active_sanction/sources/ofac.rb +248 -0
  112. data/lib/active_sanction/sources/ofac_consolidated/record.rb +37 -0
  113. data/lib/active_sanction/sources/ofac_consolidated.rb +203 -0
  114. data/lib/active_sanction/sources/ofac_sdn.rb +43 -0
  115. data/lib/active_sanction/sources/remarks.rb +84 -0
  116. data/lib/active_sanction/sources/uk_sanctions_list/published_date.rb +104 -0
  117. data/lib/active_sanction/sources/uk_sanctions_list/record.rb +412 -0
  118. data/lib/active_sanction/sources/uk_sanctions_list.rb +214 -0
  119. data/lib/active_sanction/sources/un_consolidated/record.rb +279 -0
  120. data/lib/active_sanction/sources/un_consolidated.rb +140 -0
  121. data/lib/active_sanction/sources.rb +196 -0
  122. data/lib/active_sanction/storage/active_record/reader.rb +124 -0
  123. data/lib/active_sanction/storage/active_record/row.rb +175 -0
  124. data/lib/active_sanction/storage/active_record/writer.rb +141 -0
  125. data/lib/active_sanction/storage/active_record.rb +277 -0
  126. data/lib/active_sanction/storage/base.rb +216 -0
  127. data/lib/active_sanction/storage/file_system.rb +373 -0
  128. data/lib/active_sanction/storage/memory.rb +67 -0
  129. data/lib/active_sanction/storage/meta.rb +188 -0
  130. data/lib/active_sanction/storage.rb +74 -0
  131. data/lib/active_sanction/subject.rb +271 -0
  132. data/lib/active_sanction/sync/report.rb +245 -0
  133. data/lib/active_sanction/sync/result.rb +296 -0
  134. data/lib/active_sanction/sync.rb +390 -0
  135. data/lib/active_sanction/validator_store/file_system.rb +103 -0
  136. data/lib/active_sanction/validator_store/memory.rb +41 -0
  137. data/lib/active_sanction/validator_store.rb +119 -0
  138. data/lib/active_sanction/validators.rb +195 -0
  139. data/lib/active_sanction/version.rb +23 -0
  140. data/lib/active_sanction.rb +331 -0
  141. data/lib/generators/active_sanction/install/install_generator.rb +60 -0
  142. data/lib/generators/active_sanction/install/templates/create_active_sanction_tables.rb.tt +142 -0
  143. metadata +236 -0
@@ -0,0 +1,721 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "active_sanction/error"
7
+ require "active_sanction/normalizer"
8
+ require "active_sanction/scorer/weights"
9
+ require "active_sanction/version"
10
+
11
+ module ActiveSanction
12
+ # Library-wide settings, set once at boot:
13
+ #
14
+ # ActiveSanction.configure do |c|
15
+ # c.user_agent = "my-app/1.0 (compliance@example.com)"
16
+ # end
17
+ #
18
+ # The fetch layer's settings live here, plus which sources a sync runs,
19
+ # where they are stored, and the thresholds a screening call defaults to.
20
+ # Every value has a working default, so an application that configures
21
+ # nothing still runs -- the point of `configure` is that a caller *can*
22
+ # identify itself, not that it must recite the whole schema.
23
+ class Configuration
24
+ extend T::Sig
25
+
26
+ # OFAC returns 403 to a request with no User-Agent, so this cannot be nil.
27
+ # The default identifies the library and points at its source, which is
28
+ # what a publisher watching its logs actually wants; applications should
29
+ # still override it with their own contact address, because a publisher
30
+ # that needs to reach whoever is hammering an endpoint can only reach the
31
+ # gem's repository otherwise.
32
+ DEFAULT_USER_AGENT = T.let(
33
+ -"active_sanction/#{VERSION} (+https://github.com/Babystep-Technologies/active_sanction)", String
34
+ )
35
+
36
+ # Generous by list-download standards: these are government file servers
37
+ # redirecting to blob storage, and a 126 MB XML body arrives in bursts with
38
+ # real gaps between them. The read timeout is per-read, not per-request, so
39
+ # it does not cap how long a large download may take overall.
40
+ DEFAULT_OPEN_TIMEOUT = T.let(10, Numeric)
41
+
42
+ # Seconds to wait for the next chunk of a body, not for the whole of one.
43
+ DEFAULT_READ_TIMEOUT = T.let(60, Numeric)
44
+
45
+ # OFAC's download URLs 302 to blob storage, one hop. Five leaves room for
46
+ # a publisher to add a vanity domain or a region redirect without a release
47
+ # of this gem, and still stops a redirect chain from becoming a crawl.
48
+ DEFAULT_MAX_REDIRECTS = T.let(5, Integer)
49
+
50
+ # Three attempts total for a transient failure. Sanctions syncs are batch
51
+ # work with no user waiting on them, but they are also not worth an hour of
52
+ # a government server's patience.
53
+ DEFAULT_MAX_RETRIES = T.let(2, Integer)
54
+
55
+ # Seconds before the first retry, doubling on each one after it.
56
+ DEFAULT_RETRY_BACKOFF = T.let(1.0, Numeric)
57
+
58
+ # Cache validators and raw payloads live under here. The XDG base directory
59
+ # is the right default for data a user is entitled to delete: everything
60
+ # here is recoverable by fetching again, with one honest caveat -- once a
61
+ # publisher overwrites its file the previous payload is gone, whether or
62
+ # not this directory still holds a copy. PayloadCache is a bounded aid to
63
+ # re-parsing and auditing, not the system of record; storage (#24) is.
64
+ #
65
+ # @api private
66
+ XDG_CACHE_HOME = T.let("XDG_CACHE_HOME", String)
67
+
68
+ # The directory this gem takes for itself under whichever cache root wins.
69
+ DEFAULT_CACHE_DIRNAME = T.let("active_sanction", String)
70
+
71
+ # Where Storage::FileSystem (#24) keeps parsed snapshots. Deliberately not
72
+ # under `cache_dir`, and the difference is the whole distinction between
73
+ # the two directories: everything under `~/.cache` is recoverable by
74
+ # fetching again and a user is entitled to delete it, while a stored
75
+ # snapshot is the system of record -- once a publisher overwrites its file,
76
+ # the list version a past decision was screened against exists only here.
77
+ DEFAULT_STORAGE_DIRNAME = T.let(".active_sanction", String)
78
+
79
+ # How many raw payloads PayloadCache keeps per source. Three is enough to
80
+ # diff a suspicious list against the two that came before it, and small
81
+ # enough that a cache directory does not quietly grow by 126 MB a day. An
82
+ # installation that must keep every version it ever screened against wants
83
+ # its own retention storage, not a bigger number here.
84
+ DEFAULT_RETAIN_PAYLOADS = T.let(3, Integer)
85
+
86
+ # How many publishers a sync fetches from at once. One, because these are
87
+ # government file servers with nobody waiting on the result, and a library
88
+ # that opens four connections to Treasury by default is a library that gets
89
+ # a jurisdiction's operators asking who we are. Raising it fetches from
90
+ # more publishers at a time and never harder from any one of them -- Sync
91
+ # groups sources by the host they download from and runs each group in
92
+ # order -- so the number is a bound on how many governments are being asked
93
+ # at once, not on how fast any one of them is asked.
94
+ DEFAULT_SYNC_CONCURRENCY = T.let(1, Integer)
95
+
96
+ # How far one of Doctor's measurements may move from what it was at the
97
+ # last sync before the run says so. A tenth, because these lists move by
98
+ # single-digit percentages between syncs -- a designation round is dozens
99
+ # of records against tens of thousands -- while the changes this is looking
100
+ # for halve a fill rate. Tightening it finds drift sooner and reports more
101
+ # of the movement that is just the list changing; loosening it does the
102
+ # reverse, and a diagnostic nobody reads because it always says something
103
+ # is worse than one that says slightly less.
104
+ DEFAULT_DOCTOR_TOLERANCE = T.let(0.10, Float)
105
+
106
+ # The launch lists change roughly daily at most, so a source last confirmed
107
+ # within a day is not worth asking about again when a caller is only trying
108
+ # to decide whether a sync is due. This is what #stale? measures against;
109
+ # it does not cap how long a cached copy may be used, which is the calling
110
+ # application's policy to set.
111
+ DEFAULT_STALE_AFTER = T.let(86_400, T.nilable(Numeric))
112
+
113
+ # Which XML library the XML toolkit parses with. REXML is stdlib, needs no
114
+ # build step, and -- the part that decides it -- gives every installation
115
+ # the same answer. Snapshot checksums a list's parsed content and a
116
+ # screening decision has to be re-derivable months later, so the parser
117
+ # must not be chosen by whether the host app happened to load Nokogiri.
118
+ #
119
+ # c.xml_backend = :nokogiri # libxml2, for a host parsing OFAC's 126 MB XML
120
+ #
121
+ # See Parsers::XmlRecords::Backends for the contract a backend implements.
122
+ DEFAULT_XML_BACKEND = T.let(:rexml, Symbol)
123
+
124
+ # How many names the index (#31) hands the scorer per query.
125
+ #
126
+ # 200 is where the recall curve flattens on this corpus, and a
127
+ # deliberately damaged query still finds its own name inside the first
128
+ # handful. Raising it buys precision nothing -- the scorer already sees
129
+ # everything that could clear a threshold -- and spends milliseconds a
130
+ # service does not have.
131
+ #
132
+ # What it costs is the scorer's cost, and that depends on the threshold
133
+ # rather than on this number alone: 200 candidates is roughly 16 ms of
134
+ # comparison under YJIT at a threshold of 75 and roughly 46 ms with no
135
+ # threshold at all, because the early exits are what stop the expensive
136
+ # comparisons running on candidates that cannot clear. See
137
+ # Scorer::NameScore, and `rake benchmark:scorer`.
138
+ DEFAULT_CANDIDATE_LIMIT = T.let(200, Integer)
139
+
140
+ # The lowest score a screening call reports, on the scorer's 0..100 scale.
141
+ #
142
+ # 75 is where the scorer's own table separates the two things it has to
143
+ # separate. An inverted name blends to 90.4 and a company named by half
144
+ # its words to 84.2 -- both true matches, both reported. `kim jong un`
145
+ # against `kim yong chol` blends to 54.8, and a query of one common given
146
+ # name against a full listed name lands in the high seventies with nothing
147
+ # but the name agreeing, which is why the identifiers exist and why this
148
+ # is a floor rather than a verdict.
149
+ #
150
+ # It is also most of what a screening call costs. Everything a threshold
151
+ # turns off is a comparison that could not have changed the answer -- see
152
+ # Scorer::NameScore -- so 75 is roughly a third of the work of screening
153
+ # with no threshold at all, and returns the same scores.
154
+ DEFAULT_SCREENING_THRESHOLD = T.let(75.0, Float)
155
+
156
+ # How many results a screening call returns, highest score first.
157
+ #
158
+ # Ten is a review queue rather than a report: a human clears alerts one at
159
+ # a time, and a call that returned every name over the threshold would
160
+ # bury the one that matters under the fifty that share a given name. A
161
+ # caller writing an investigation tool rather than an onboarding check
162
+ # raises it per query.
163
+ DEFAULT_SCREENING_LIMIT = T.let(10, Integer)
164
+
165
+ # Which lists a sync runs, by key. nil means every registered source,
166
+ # which is what an application that has not thought about it should get:
167
+ # requiring an explicit list would mean a gem adding a jurisdiction had no
168
+ # way to take effect without an edit to the host app's initializer.
169
+ DEFAULT_SOURCES = T.let(nil, T.nilable(T::Array[Symbol]))
170
+
171
+ sig { returns(String) }
172
+ attr_reader :user_agent
173
+
174
+ # Seconds. Numeric rather than Integer because the writers run every value
175
+ # through Float(), so a timeout set to 2.5 stays 2.5.
176
+ sig { returns(Numeric) }
177
+ attr_reader :open_timeout
178
+
179
+ sig { returns(Numeric) }
180
+ attr_reader :read_timeout
181
+
182
+ sig { returns(Integer) }
183
+ attr_reader :max_redirects
184
+
185
+ sig { returns(Integer) }
186
+ attr_reader :max_retries
187
+
188
+ sig { returns(Numeric) }
189
+ attr_reader :retry_backoff
190
+
191
+ sig { returns(String) }
192
+ attr_reader :cache_dir
193
+
194
+ sig { returns(String) }
195
+ attr_reader :storage_dir
196
+
197
+ sig { returns(Integer) }
198
+ attr_reader :retain_payloads
199
+
200
+ # nil disables the staleness clock entirely -- see #stale_after=.
201
+ sig { returns(T.nilable(Numeric)) }
202
+ attr_reader :stale_after
203
+
204
+ # nil means every registered source. Keys are not resolved here; see
205
+ # #sources=.
206
+ sig { returns(T.nilable(T::Array[Symbol])) }
207
+ attr_reader :sources
208
+
209
+ sig { returns(Symbol) }
210
+ attr_reader :xml_backend
211
+
212
+ # See DEFAULT_DOCTOR_TOLERANCE. A per-run `tolerance:` overrides it.
213
+ sig { returns(Float) }
214
+ attr_reader :doctor_tolerance
215
+
216
+ # See DEFAULT_SYNC_CONCURRENCY. A per-run `concurrency:` overrides it.
217
+ sig { returns(Integer) }
218
+ attr_reader :sync_concurrency
219
+
220
+ # See DEFAULT_CANDIDATE_LIMIT. A per-query `limit:` overrides it.
221
+ sig { returns(Integer) }
222
+ attr_reader :candidate_limit
223
+
224
+ # See DEFAULT_SCREENING_THRESHOLD. A per-query `threshold:` overrides it.
225
+ sig { returns(Float) }
226
+ attr_reader :screening_threshold
227
+
228
+ # See DEFAULT_SCREENING_LIMIT. A per-query `limit:` overrides it.
229
+ sig { returns(Integer) }
230
+ attr_reader :screening_limit
231
+
232
+ # The token lists the normalizer strips per entity type. Defaults to the
233
+ # shipped ones; see #normalizer_dictionary= and Normalizer::Dictionary.
234
+ sig { returns(Normalizer::Dictionary) }
235
+ attr_reader :normalizer_dictionary
236
+
237
+ # What each signal the scorer reads is worth. See #scorer_weights=.
238
+ sig { returns(Scorer::Weights) }
239
+ attr_reader :scorer_weights
240
+
241
+ # Anything Logger-shaped, which is what #logger= checks for and all this
242
+ # library ever asks of it. Declaring `::Logger` would make a host's
243
+ # wrapper, a Rails logger broadcast or a test spy a type error rather than
244
+ # the perfectly good logger each of them is.
245
+ sig { returns(T.untyped) }
246
+ attr_reader :logger
247
+
248
+ sig { void }
249
+ def initialize
250
+ @user_agent = T.let(DEFAULT_USER_AGENT, String)
251
+ @open_timeout = T.let(DEFAULT_OPEN_TIMEOUT, Numeric)
252
+ @read_timeout = T.let(DEFAULT_READ_TIMEOUT, Numeric)
253
+ @max_redirects = T.let(DEFAULT_MAX_REDIRECTS, Integer)
254
+ @max_retries = T.let(DEFAULT_MAX_RETRIES, Integer)
255
+ @retry_backoff = T.let(DEFAULT_RETRY_BACKOFF, Numeric)
256
+ @cache_dir = T.let(self.class.default_cache_dir, String)
257
+ @storage_dir = T.let(self.class.default_storage_dir, String)
258
+ @retain_payloads = T.let(DEFAULT_RETAIN_PAYLOADS, Integer)
259
+ @stale_after = T.let(DEFAULT_STALE_AFTER, T.nilable(Numeric))
260
+ @sources = T.let(DEFAULT_SOURCES, T.nilable(T::Array[Symbol]))
261
+ @xml_backend = T.let(DEFAULT_XML_BACKEND, Symbol)
262
+ @sync_concurrency = T.let(DEFAULT_SYNC_CONCURRENCY, Integer)
263
+ @doctor_tolerance = T.let(DEFAULT_DOCTOR_TOLERANCE, Float)
264
+ @candidate_limit = T.let(DEFAULT_CANDIDATE_LIMIT, Integer)
265
+ @screening_threshold = T.let(DEFAULT_SCREENING_THRESHOLD, Float)
266
+ @screening_limit = T.let(DEFAULT_SCREENING_LIMIT, Integer)
267
+ @normalizer_dictionary = T.let(Normalizer::Dictionary.default, Normalizer::Dictionary)
268
+ @scorer_weights = T.let(Scorer::Weights.default, Scorer::Weights)
269
+ @logger = T.let(nil, T.untyped)
270
+ @storage = T.let(nil, T.nilable(Storage::Base))
271
+ @default_storage = T.let(nil, T.nilable(Storage::Base))
272
+ end
273
+
274
+ # A copy starts unfrozen -- that is Ruby's rule for `dup`, and the reason
275
+ # `with` can derive a mutable configuration from a frozen one -- and drops
276
+ # the store that was derived from `storage_dir`, so a copy that moves the
277
+ # directory reads the directory it names. A store the caller assigned is
278
+ # not derived and is carried over.
279
+ sig { params(other: Configuration).void }
280
+ def initialize_copy(other)
281
+ super
282
+ @default_storage = nil
283
+ end
284
+
285
+ sig { params(value: T.untyped).void }
286
+ def user_agent=(value)
287
+ @user_agent = self.class.user_agent!(value)
288
+ end
289
+
290
+ sig { params(value: T.untyped).void }
291
+ def open_timeout=(value)
292
+ @open_timeout = positive_number!(:open_timeout, value)
293
+ end
294
+
295
+ sig { params(value: T.untyped).void }
296
+ def read_timeout=(value)
297
+ @read_timeout = positive_number!(:read_timeout, value)
298
+ end
299
+
300
+ sig { params(value: T.untyped).void }
301
+ def max_redirects=(value)
302
+ @max_redirects = non_negative_integer!(:max_redirects, value)
303
+ end
304
+
305
+ sig { params(value: T.untyped).void }
306
+ def max_retries=(value)
307
+ @max_retries = non_negative_integer!(:max_retries, value)
308
+ end
309
+
310
+ sig { params(value: T.untyped).void }
311
+ def retry_backoff=(value)
312
+ @retry_backoff = positive_number!(:retry_backoff, value)
313
+ end
314
+
315
+ sig { params(value: T.untyped).void }
316
+ def cache_dir=(value)
317
+ @cache_dir = -File.expand_path(directory!(:cache_dir, value))
318
+ end
319
+
320
+ sig { params(value: T.untyped).void }
321
+ def storage_dir=(value)
322
+ @storage_dir = -File.expand_path(directory!(:storage_dir, value))
323
+ end
324
+
325
+ sig { params(value: T.untyped).void }
326
+ def retain_payloads=(value)
327
+ @retain_payloads = self.class.retain_payloads!(value)
328
+ end
329
+
330
+ # `nil` disables the staleness clock entirely: a source with stored
331
+ # validators is then never stale, and the publisher's 304 is the only thing
332
+ # that decides whether a sync did any work.
333
+ sig { params(value: T.untyped).void }
334
+ def stale_after=(value)
335
+ @stale_after = value.nil? ? nil : positive_number!(:stale_after, value)
336
+ end
337
+
338
+ # The lists to sync, named by the keys their adapters declare:
339
+ #
340
+ # c.sources = %i[ofac_sdn my_internal_watchlist]
341
+ #
342
+ # Keys are not resolved here. An initializer runs before a gem that
343
+ # registers a source may have been required, and rejecting a key at
344
+ # assignment would make the order of an application's requires decide
345
+ # whether its configuration is valid. Sources.enabled resolves them at the
346
+ # start of a run instead, where an unknown key is an error about a typo
347
+ # rather than about load order.
348
+ sig { params(value: T.untyped).void }
349
+ def sources=(value)
350
+ @sources = value.nil? ? nil : source_keys!(value)
351
+ end
352
+
353
+ # Not resolved here, for the reason `sources=` is not: an initializer runs
354
+ # before a gem that registers a backend may have been required, and load
355
+ # order should not decide whether a configuration is valid. XmlRecords
356
+ # resolves the name when it parses, where an unknown one is an error about
357
+ # a typo and lists what is registered.
358
+ sig { params(value: T.untyped).void }
359
+ def xml_backend=(value)
360
+ name = value.to_s.strip
361
+ raise ConfigurationError, "xml_backend cannot be blank" if name.empty?
362
+
363
+ @xml_backend = name.to_sym
364
+ end
365
+
366
+ sig { params(value: T.untyped).void }
367
+ def sync_concurrency=(value)
368
+ @sync_concurrency = self.class.sync_concurrency!(value)
369
+ end
370
+
371
+ sig { params(value: T.untyped).void }
372
+ def doctor_tolerance=(value)
373
+ @doctor_tolerance = self.class.doctor_tolerance!(value)
374
+ end
375
+
376
+ # Raising this trades milliseconds for recall and lowering it does the
377
+ # reverse, which is why it is a number a host can set rather than a
378
+ # constant. Zero is refused: an index that returns nothing screens nobody,
379
+ # and a configuration that turns screening off has to be a typo.
380
+ sig { params(value: T.untyped).void }
381
+ def candidate_limit=(value)
382
+ integer = begin
383
+ Integer(value)
384
+ rescue TypeError, ArgumentError
385
+ raise ConfigurationError, "candidate_limit must be a whole number of names, got #{value.inspect}"
386
+ end
387
+ raise ConfigurationError, "candidate_limit must be at least 1, got #{integer}" unless integer.positive?
388
+
389
+ @candidate_limit = integer
390
+ end
391
+
392
+ # A Hash adds to the shipped lists, which is what a host almost always
393
+ # wants -- one more legal form its market uses, one more particle its names
394
+ # carry:
395
+ #
396
+ # c.normalizer_dictionary = { legal_forms: %w[OYJ TBK] }
397
+ #
398
+ # A Normalizer::Dictionary replaces them outright, and has to spell out all
399
+ # four lists including the particles. Replacing is the rarer and more
400
+ # dangerous operation -- a set of lists that forgot to carry the preserve
401
+ # list over would strip `al` out of several hundred SDN names -- so it is
402
+ # the one that has to be written out in full.
403
+ #
404
+ # Set this at boot, before anything is folded. A dictionary changed later
405
+ # is honoured from the next call, but names folded under the old one are
406
+ # already in an index (#31) and were already screened against, and the two
407
+ # folds do not compare.
408
+ sig { params(value: T.untyped).void }
409
+ def normalizer_dictionary=(value)
410
+ @normalizer_dictionary = normalizer_dictionary!(value)
411
+ end
412
+
413
+ # A Hash replaces the numbers it names and leaves the rest, which is what
414
+ # a host tuning one signal wants:
415
+ #
416
+ # c.scorer_weights = { dob_conflict: -20.0, identifier_match: 50.0 }
417
+ #
418
+ # A Scorer::Weights is taken as it stands. Unlike the normalizer's
419
+ # dictionaries there is no partial-replacement hazard here -- a number
420
+ # left out is the shipped one, and the five name shares are checked to sum
421
+ # to 1 whichever way they arrived.
422
+ #
423
+ # Set this at boot. Changing it later is honoured from the next call, and
424
+ # every score already recorded was made under the old numbers; a stored
425
+ # screening decision has to say which set it used, which is what #33's
426
+ # reproducibility stamp is for.
427
+ sig { params(value: T.untyped).void }
428
+ def scorer_weights=(value)
429
+ @scorer_weights = begin
430
+ Scorer::Weights.build(value)
431
+ rescue ArgumentError => e
432
+ raise ConfigurationError, "scorer_weights: #{e.message}"
433
+ end
434
+ end
435
+
436
+ # The lowest score a screening call reports, unless a query names its own.
437
+ # Refused outside 0..100 by the scorer's own check, which is what catches
438
+ # a similarity on the 0..1 scale arriving where a percentage was meant.
439
+ sig { params(value: T.untyped).void }
440
+ def screening_threshold=(value)
441
+ number = begin
442
+ Float(value)
443
+ rescue TypeError, ArgumentError
444
+ raise ConfigurationError, "screening_threshold must be a number between 0 and 100, got #{value.inspect}"
445
+ end
446
+ unless number.between?(0.0, 100.0)
447
+ raise ConfigurationError,
448
+ "screening_threshold must be between 0 and 100, got #{value.inspect} -- " \
449
+ "a screening score is a percentage, not a similarity on a 0..1 scale"
450
+ end
451
+
452
+ @screening_threshold = number
453
+ end
454
+
455
+ # How many results a screening call returns. Zero is refused for the
456
+ # reason `candidate_limit` refuses it: a screening call that can return
457
+ # nothing reports every customer clear.
458
+ sig { params(value: T.untyped).void }
459
+ def screening_limit=(value)
460
+ integer = begin
461
+ Integer(value)
462
+ rescue TypeError, ArgumentError
463
+ raise ConfigurationError, "screening_limit must be a whole number of results, got #{value.inspect}"
464
+ end
465
+ raise ConfigurationError, "screening_limit must be at least 1, got #{integer}" unless integer.positive?
466
+
467
+ @screening_limit = integer
468
+ end
469
+
470
+ # Where synced lists are read from and written to. Defaults to gzipped
471
+ # JSON under `storage_dir`, which is what makes this library screen a name
472
+ # without an application having provisioned anything first.
473
+ #
474
+ # c.storage = ActiveSanction::Storage::Memory.new
475
+ #
476
+ # Built on first use rather than at boot, because constructing it touches
477
+ # the filesystem and a process that never screens should not pay for a
478
+ # directory it will not read. `Client.new` reads it once on the way to
479
+ # freezing, so a client's store is settled before any thread can race for
480
+ # it -- see #freeze.
481
+ sig { returns(Storage::Base) }
482
+ def storage
483
+ @storage || default_storage
484
+ end
485
+
486
+ # A Storage::Base subclass, which is the contract the whole query path is
487
+ # written against -- see Storage::Base, and the conformance group an
488
+ # adapter that is not this is held to.
489
+ sig { params(value: T.untyped).void }
490
+ def storage=(value)
491
+ unless value.is_a?(Storage::Base)
492
+ raise ConfigurationError,
493
+ "storage must be an ActiveSanction::Storage::Base subclass, got #{value.class}"
494
+ end
495
+
496
+ @storage = value
497
+ end
498
+
499
+ # Every setting a caller may name, which is what `Client.new` and
500
+ # `Configuration#with` accept as keyword arguments and what an unknown one
501
+ # is reported against. Derived from the writers rather than listed, so a
502
+ # setting added below is accepted here without anything remembering to say
503
+ # so twice.
504
+ sig { returns(T::Array[Symbol]) }
505
+ def self.settings
506
+ @settings ||= T.let(
507
+ public_instance_methods(false).grep(/=\z/).map { |name| name.to_s.chomp("=").to_sym }.sort.freeze,
508
+ T.nilable(T::Array[Symbol])
509
+ )
510
+ end
511
+
512
+ # A copy of these settings with some of them changed:
513
+ #
514
+ # audit = ActiveSanction.config.with(storage: pinned_store, sources: %i[ofac_sdn])
515
+ #
516
+ # The copy is mutable and unfrozen whatever this one is, which is what
517
+ # makes a frozen configuration a value object rather than a dead end: a
518
+ # client derives its neighbour from it instead of rebuilding the schema.
519
+ sig { params(overrides: T.untyped).returns(Configuration) }
520
+ def with(**overrides)
521
+ dup.tap { |copy| copy.apply(**overrides) }
522
+ end
523
+
524
+ # Assigns through the writers, so a value given to `Client.new` is held to
525
+ # exactly the rule the same value set in a `configure` block is held to,
526
+ # and fails with the same message.
527
+ sig { params(overrides: T.untyped).returns(T.self_type) }
528
+ def apply(**overrides)
529
+ unknown = overrides.keys - self.class.settings
530
+ if unknown.any?
531
+ raise ConfigurationError,
532
+ "unknown setting(s): #{unknown.join(", ")}. Expected any of #{self.class.settings.join(", ")}"
533
+ end
534
+
535
+ overrides.each { |name, value| public_send(:"#{name}=", value) }
536
+ self
537
+ end
538
+
539
+ # A built configuration is frozen, and a client freezes the one it holds.
540
+ #
541
+ # The default store is resolved on the way through, because it is the one
542
+ # thing here that is built lazily and a frozen object cannot memoize. That
543
+ # is also the point: a store settled at build time is a store no two
544
+ # threads can race to construct, and a client that never screens pays for
545
+ # a `File.expand_path` rather than for a directory.
546
+ #
547
+ # The dictionary and the weights are already frozen value objects, and the
548
+ # source list is frozen here so that a caller holding the array it passed
549
+ # in cannot edit the lists a running client syncs.
550
+ sig { returns(T.self_type) }
551
+ def freeze
552
+ return self if frozen?
553
+
554
+ storage
555
+ @sources = T.let(@sources&.dup&.freeze, T.nilable(T::Array[Symbol]))
556
+ super
557
+ end
558
+
559
+ # Anything Logger-shaped. The fetch layer says what it did at `info` --
560
+ # which list was downloaded, which came back 304 -- because a sync that
561
+ # transfers nothing looks identical to a sync that did not run, and an
562
+ # operator needs to tell those apart.
563
+ sig { params(value: T.untyped).void }
564
+ def logger=(value)
565
+ unless value.nil? || value.respond_to?(:info)
566
+ raise ConfigurationError, "logger must respond to #info, got #{value.class}"
567
+ end
568
+
569
+ @logger = value
570
+ end
571
+
572
+ sig { returns(String) }
573
+ def self.default_storage_dir
574
+ -File.expand_path(File.join(Dir.home, DEFAULT_STORAGE_DIRNAME))
575
+ end
576
+
577
+ sig { returns(String) }
578
+ def self.default_cache_dir
579
+ home = ENV.fetch(XDG_CACHE_HOME, nil)
580
+ home = File.join(Dir.home, ".cache") if home.nil? || home.strip.empty?
581
+ -File.expand_path(File.join(home, DEFAULT_CACHE_DIRNAME))
582
+ end
583
+
584
+ # Shared by PayloadCache, so a cache built with an explicit `retain:` fails
585
+ # the same way as a misconfigured global. Zero is not allowed: a cache that
586
+ # keeps nothing still writes every payload to disk before deleting it, and
587
+ # an installation that wants no payload cache should not build one.
588
+ sig { params(value: T.untyped).returns(Integer) }
589
+ def self.retain_payloads!(value)
590
+ integer = begin
591
+ Integer(value)
592
+ rescue TypeError, ArgumentError
593
+ raise ConfigurationError, "retain_payloads must be a whole number of payloads, got #{value.inspect}"
594
+ end
595
+ raise ConfigurationError, "retain_payloads must be at least 1, got #{integer}" unless integer.positive?
596
+
597
+ integer
598
+ end
599
+
600
+ # Shared by Sync, so a run given an explicit `concurrency:` fails the same
601
+ # way as a misconfigured global. Zero is refused rather than read as "no
602
+ # parallelism": a sync that runs no sources is a typo, and one is what
603
+ # sequential is spelled as.
604
+ sig { params(value: T.untyped).returns(Integer) }
605
+ def self.sync_concurrency!(value)
606
+ integer = begin
607
+ Integer(value)
608
+ rescue TypeError, ArgumentError
609
+ raise ConfigurationError, "sync_concurrency must be a whole number of sources, got #{value.inspect}"
610
+ end
611
+ raise ConfigurationError, "sync_concurrency must be at least 1, got #{integer}" unless integer.positive?
612
+
613
+ integer
614
+ end
615
+
616
+ # Shared with Doctor, so a per-run `tolerance:` is held to the same rule as
617
+ # the configured default. A share of what a measurement was, so 1.0 is
618
+ # "report nothing short of a doubling or a disappearance" and 0.0 is
619
+ # "report every movement at all", both of which are legitimate settings for
620
+ # somebody and neither of which is a default.
621
+ sig { params(value: T.untyped).returns(Float) }
622
+ def self.doctor_tolerance!(value)
623
+ ratio = begin
624
+ Float(value)
625
+ rescue TypeError, ArgumentError
626
+ raise ConfigurationError, "doctor_tolerance must be a share between 0 and 1, got #{value.inspect}"
627
+ end
628
+ return ratio if ratio.between?(0.0, 1.0)
629
+
630
+ raise ConfigurationError, "doctor_tolerance must be a share between 0 and 1, got #{value.inspect}"
631
+ end
632
+
633
+ # Shared by HttpClient, so a client built with an explicit `user_agent:`
634
+ # fails the same way and with the same message as a misconfigured global.
635
+ # Whitespace counts as blank: a header of `" "` is what a publisher sees as
636
+ # no header at all, and it would earn the same 403.
637
+ sig { params(value: T.untyped).returns(String) }
638
+ def self.user_agent!(value)
639
+ string = value.to_s.strip
640
+ if string.empty?
641
+ raise ConfigurationError,
642
+ "user_agent is required -- OFAC and other publishers reject requests without one. " \
643
+ 'Set ActiveSanction.configure { |c| c.user_agent = "my-app/1.0 (you@example.com)" }'
644
+ end
645
+
646
+ -string
647
+ end
648
+
649
+ private
650
+
651
+ # The store `storage_dir` names, built once. Memoized rather than built on
652
+ # every read because a FileSystem store is a directory and a checksum
653
+ # pattern, and two of them would be two objects saying the same thing.
654
+ sig { returns(Storage::Base) }
655
+ def default_storage
656
+ @default_storage ||= T.let(Storage::FileSystem.new(root: storage_dir), T.nilable(Storage::Base))
657
+ end
658
+
659
+ sig { params(value: T.untyped).returns(Normalizer::Dictionary) }
660
+ def normalizer_dictionary!(value)
661
+ return value if value.is_a?(Normalizer::Dictionary)
662
+
663
+ unless value.is_a?(Hash)
664
+ raise ConfigurationError,
665
+ "normalizer_dictionary must be an ActiveSanction::Normalizer::Dictionary, or a Hash of " \
666
+ "lists to add to the shipped ones, got #{value.class}"
667
+ end
668
+
669
+ lists = value.to_h { |list, entries| [list.to_s.to_sym, entries] }
670
+ unknown = lists.keys - Normalizer::Dictionary::LISTS
671
+ if unknown.any?
672
+ raise ConfigurationError,
673
+ "unknown normalizer dictionary list(s): #{unknown.join(", ")}. " \
674
+ "Expected any of #{Normalizer::Dictionary::LISTS.join(", ")}"
675
+ end
676
+
677
+ T.unsafe(Normalizer::Dictionary.default).merge(**lists)
678
+ end
679
+
680
+ sig { params(name: Symbol, value: T.untyped).returns(String) }
681
+ def directory!(name, value)
682
+ path = value.to_s.strip
683
+ raise ConfigurationError, "#{name} cannot be blank" if path.empty?
684
+
685
+ path
686
+ end
687
+
688
+ sig { params(value: T.untyped).returns(T::Array[Symbol]) }
689
+ def source_keys!(value)
690
+ keys = Array(value).map { |key| key.to_s.strip }
691
+ raise ConfigurationError, "sources cannot be empty -- use nil to mean every registered source" if keys.empty?
692
+ raise ConfigurationError, "sources cannot contain a blank key, got #{value.inspect}" if keys.any?(&:empty?)
693
+
694
+ keys.map(&:to_sym).uniq
695
+ end
696
+
697
+ sig { params(name: Symbol, value: T.untyped).returns(Numeric) }
698
+ def positive_number!(name, value)
699
+ number = begin
700
+ Float(value)
701
+ rescue TypeError, ArgumentError
702
+ raise ConfigurationError, "#{name} must be a number of seconds, got #{value.inspect}"
703
+ end
704
+ raise ConfigurationError, "#{name} must be greater than zero, got #{value.inspect}" unless number.positive?
705
+
706
+ number
707
+ end
708
+
709
+ sig { params(name: Symbol, value: T.untyped).returns(Integer) }
710
+ def non_negative_integer!(name, value)
711
+ integer = begin
712
+ Integer(value)
713
+ rescue TypeError, ArgumentError
714
+ raise ConfigurationError, "#{name} must be a whole number, got #{value.inspect}"
715
+ end
716
+ raise ConfigurationError, "#{name} cannot be negative, got #{value.inspect}" if integer.negative?
717
+
718
+ integer
719
+ end
720
+ end
721
+ end