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,74 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "active_sanction/error"
5
+
6
+ module ActiveSanction
7
+ # Where a synced list lives between the sync that fetched it and the
8
+ # screening run that reads it.
9
+ #
10
+ # store = ActiveSanction::Storage::Memory.new
11
+ # store.write_snapshot(ActiveSanction::Sources[:ofac_sdn].new.sync)
12
+ # store.read_snapshot(:ofac_sdn) # => Snapshot
13
+ #
14
+ # The namespace holds one interface (Storage::Base), the in-memory
15
+ # implementation of it, and the metadata object a store can answer with
16
+ # without loading a list. The adapters that persist anywhere else -- gzipped
17
+ # JSON on disk (#24), ActiveRecord (#25), whatever a host writes privately --
18
+ # are subclasses of Base and nothing here has to know they exist.
19
+ module Storage
20
+ # A source that has never been synced, or whose snapshot has been deleted,
21
+ # asked for by name. Raised rather than answered with nil wherever a caller
22
+ # named the source itself: screening against a list that turns out not to
23
+ # be there has to fail loudly, because the result of screening against
24
+ # nothing is a clean report.
25
+ class MissingSnapshot < StorageError; end
26
+
27
+ # A stored snapshot that cannot be trusted to be what it says it is: a
28
+ # truncated file, bytes that no longer hash to the checksum recorded beside
29
+ # them, a sidecar that is not JSON, a list filed under one source that
30
+ # claims to be another.
31
+ #
32
+ # Raised rather than repaired, and rather than returning whatever could
33
+ # still be read. A store that hands back the 8,000 records it managed to
34
+ # parse out of 19,015 produces a report that looks exactly like a clean
35
+ # one, which is the most expensive thing this library can get wrong. An
36
+ # operator can always delete the list and re-sync; nobody can recover a
37
+ # screening decision made against a list that was quietly half there.
38
+ class CorruptSnapshot < StorageError; end
39
+
40
+ # A stored snapshot written under a Snapshot::SCHEMA_VERSION this code does
41
+ # not know how to read -- almost always because the directory was written
42
+ # by a newer active_sanction than the one now reading it.
43
+ #
44
+ # Separate from CorruptSnapshot because the file is fine and the fix is
45
+ # different: upgrade the gem, or discard the list and re-sync under this
46
+ # one. It has to be caught before the list is parsed, because a newer
47
+ # schema will usually still deserialize -- into records missing whatever
48
+ # the new version added, with a checksum that verifies, and with no
49
+ # symptom other than names that stop matching.
50
+ class UnsupportedSchema < StorageError; end
51
+ end
52
+ end
53
+
54
+ require "active_sanction/storage/meta"
55
+ require "active_sanction/storage/base"
56
+ require "active_sanction/storage/memory"
57
+ require "active_sanction/storage/file_system"
58
+
59
+ # Storage::ActiveRecord (#25) is optional in the strong sense: ActiveRecord is
60
+ # not a dependency of this gem and must not become one, so the adapter is
61
+ # loaded only where it can be, and the gem is fully usable without it.
62
+ #
63
+ # Both orders have to work, which is why this is two clauses rather than one.
64
+ # A script that requires ActiveRecord itself has already defined the constant
65
+ # by the time this file runs, and the first clause loads the adapter now. A
66
+ # Rails application loads ActiveSupport long before ActiveRecord::Base -- the
67
+ # framework is deliberately lazy about it -- so the second clause books the
68
+ # adapter onto the hook Rails runs when Base is finally loaded. An application
69
+ # that wants it unconditionally can always require it by name.
70
+ if defined?(ActiveRecord::Base)
71
+ require "active_sanction/storage/active_record"
72
+ elsif defined?(ActiveSupport) && ActiveSupport.respond_to?(:on_load)
73
+ ActiveSupport.on_load(:active_record) { require "active_sanction/storage/active_record" }
74
+ end
@@ -0,0 +1,271 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "active_sanction/error"
7
+ require "active_sanction/query"
8
+ require "active_sanction/scorer/subject"
9
+
10
+ module ActiveSanction
11
+ # One entry in a book of business: an application's own id, and everything
12
+ # it knows about the person or company behind it.
13
+ #
14
+ # subject = ActiveSanction::Subject.new(
15
+ # id: "cust_1",
16
+ # name: "Bosco Ntaganda",
17
+ # type: :individual,
18
+ # date_of_birth: "1973",
19
+ # country: "CD"
20
+ # )
21
+ #
22
+ # subject.id # => "cust_1"
23
+ # subject.name # => "Bosco Ntaganda"
24
+ #
25
+ # ### Why an id is the whole of what this adds
26
+ #
27
+ # `screen` answers about a name. Rescreening answers about *a customer*, and
28
+ # the two are not the same question: an alert has to name the row in the
29
+ # host's database that a compliance team is going to open, hold and
30
+ # eventually dispose of. A book screened as bare names comes back as an
31
+ # array somebody has to re-join by position, which works exactly until a
32
+ # book is filtered, streamed in batches, or contains the same name twice --
33
+ # and two customers called Jane Miller is not a corner case, it is Tuesday.
34
+ #
35
+ # So the id is required, it is the caller's own, and nothing here interprets
36
+ # it. It travels onto every alert the subject produces (Rescreen::Alert), so
37
+ # a run's output joins back to the host's records without the host having
38
+ # kept the order it sent them in.
39
+ #
40
+ # ### It takes what `screen` takes
41
+ #
42
+ # Every evidence field a screening call accepts is accepted here, in every
43
+ # spelling Query accepts it in -- `date_of_birth:` and `dates_of_birth:`,
44
+ # `country:`, `countries:` and `nationalities:`, `identifier:` and
45
+ # `identifiers:`. A subject is a query about a customer, and a caller should
46
+ # not have to learn a second vocabulary to write one.
47
+ #
48
+ # The two things it refuses are the search *options* that a rescreen decides
49
+ # for itself. `sources:` is settled by the diff being applied -- a rescreen
50
+ # runs against one list version pair and nothing else -- and `limit:` would
51
+ # cap the alerts a subject can raise, which is not a thing this library is
52
+ # willing to do: an alert dropped for being eleventh is a sanctions hit
53
+ # nobody sees. Both are refused rather than ignored, because a search option
54
+ # that is silently dropped is a caller screening under a rule they think
55
+ # they set.
56
+ #
57
+ # `threshold:` is accepted, and is the one policy knob a subject carries.
58
+ # Risk-based screening is ordinary -- a correspondent bank at 70, a retail
59
+ # customer at 85 -- and a book that could not express it would force a host
60
+ # into one call per tier. Left unset, the subject is screened at whatever
61
+ # threshold the run names. See Rescreen.
62
+ #
63
+ # ### Where the evidence lives
64
+ #
65
+ # In a Scorer::Subject, built once here, which is the same object a Query
66
+ # holds and hands the scorer. The name is folded once, at construction, and
67
+ # a rescreening run compares that one folded form against every changed
68
+ # record rather than re-folding per comparison -- which is what makes
69
+ # streaming a large book past a small diff cheap.
70
+ #
71
+ # Instances are frozen on construction and compare by value.
72
+ class Subject
73
+ extend T::Sig
74
+
75
+ # The canonical spelling of every field, and the shape `#to_h` emits.
76
+ #
77
+ # @api private
78
+ MEMBERS = T.let((%i[id] + Scorer::Subject::MEMBERS + %i[threshold]).freeze, T::Array[Symbol])
79
+
80
+ # Search options a rescreen settles for itself. See the class comment.
81
+ #
82
+ # @api private
83
+ REFUSED = T.let(
84
+ {
85
+ sources: "the diff being applied names the list -- a rescreen runs against one pair of list versions",
86
+ limit: "a rescreen reports every changed record a subject matches -- an alert dropped for being " \
87
+ "eleventh is a hit nobody sees"
88
+ }.freeze,
89
+ T::Hash[Symbol, String]
90
+ )
91
+
92
+ # The caller's own id for this subject, carried onto every alert.
93
+ sig { returns(String) }
94
+ attr_reader :id
95
+
96
+ # The evidence, folded once. What the scorer compares against a record.
97
+ sig { returns(Scorer::Subject) }
98
+ attr_reader :evidence
99
+
100
+ # The lowest score worth an alert for this subject, or nil to take the
101
+ # run's. See the class comment.
102
+ sig { returns(T.nilable(Float)) }
103
+ attr_reader :threshold
104
+
105
+ class << self
106
+ extend T::Sig
107
+
108
+ # Whatever a caller had, as a Subject:
109
+ #
110
+ # Subject.build(subject) # itself
111
+ # Subject.build(id: "cust_1", name: "Bosco Ntaganda") # a Hash, string keys or symbol
112
+ #
113
+ # What a book of business is streamed through, so a host can hand this
114
+ # library the rows it already has rather than mapping them first.
115
+ sig { params(value: T.untyped).returns(Subject) }
116
+ def build(value)
117
+ case value
118
+ when Subject then value
119
+ when Hash then from_h(value)
120
+ else
121
+ raise InvalidArgument,
122
+ "a book holds ActiveSanction::Subject or Hash entries, got #{value.class}. A rescreen reports " \
123
+ "alerts against a caller's own id, so a bare name is not enough to raise one"
124
+ end
125
+ end
126
+
127
+ # Rebuilds a subject from #to_h output, accepting string keys so a book
128
+ # read out of a database or a JSON payload needs no translation.
129
+ sig { params(hash: T.untyped).returns(Subject) }
130
+ def from_h(hash)
131
+ # `new(**hash)` past a required keyword parameter is one of the few
132
+ # things Sorbet cannot check statically. #initialize validates what
133
+ # arrives, which is where a bad round-trip is caught.
134
+ T.unsafe(self).new(**hash.to_h.transform_keys { |key| key.to_s.to_sym })
135
+ end
136
+ end
137
+
138
+ # `id` and `name` are required; everything else is optional, for the
139
+ # reason Scorer::Subject gives -- most callers have a name and little
140
+ # else, and a field absent on either side is neutral rather than a
141
+ # conflict.
142
+ #
143
+ # The evidence fields are taken in any spelling Query accepts them in, and
144
+ # resolved here rather than in a separate builder: unlike a Query, which a
145
+ # screening call constructs on a caller's behalf, this is the object a
146
+ # host writes out by hand, so `.new` is the door everything comes through.
147
+ sig { params(id: T.untyped, fields: T.untyped).void }
148
+ def initialize(id:, **fields)
149
+ attributes = normalize(fields)
150
+ @id = T.let(id!(id), String)
151
+ @evidence = T.let(
152
+ Scorer::Subject.new(name: attributes[:name], type: attributes[:type],
153
+ dates_of_birth: attributes[:dates_of_birth] || [],
154
+ nationalities: attributes[:nationalities] || [],
155
+ identifiers: attributes[:identifiers] || []),
156
+ Scorer::Subject
157
+ )
158
+ @threshold = T.let(threshold!(attributes[:threshold]), T.nilable(Float))
159
+ freeze
160
+ end
161
+
162
+ # The name as the caller wrote it, which is what an alert quotes back.
163
+ sig { returns(String) }
164
+ def name = evidence.name
165
+
166
+ sig { returns(T.nilable(Symbol)) }
167
+ def type = evidence.type
168
+
169
+ sig { returns(T::Array[PartialDate]) }
170
+ def dates_of_birth = evidence.dates_of_birth
171
+
172
+ sig { returns(T::Array[String]) }
173
+ def nationalities = evidence.nationalities
174
+
175
+ sig { returns(T::Array[Identifier]) }
176
+ def identifiers = evidence.identifiers
177
+
178
+ # The folded name every comparison runs against, folded once at
179
+ # construction. What a rescreening run hands the index rather than the
180
+ # string it came from.
181
+ sig { returns(Normalizer::Form) }
182
+ def form = evidence.form
183
+
184
+ # This subject as the screening call it is, at the threshold given or its
185
+ # own -- which is what a MatchResult records as the question that was
186
+ # asked. `sources:` is the list the run covers.
187
+ sig { params(threshold: T.untyped, sources: T.untyped).returns(Query) }
188
+ def query(threshold: nil, sources: nil)
189
+ T.unsafe(Query).new(**evidence.to_h, sources: sources, threshold: threshold || self.threshold)
190
+ end
191
+
192
+ sig { returns(T::Hash[Symbol, T.untyped]) }
193
+ def to_h = { id: id }.merge(evidence.to_h).merge(threshold: threshold)
194
+
195
+ sig { params(other: T.untyped).returns(T::Boolean) }
196
+ def ==(other)
197
+ return false unless other.instance_of?(self.class)
198
+
199
+ to_h == other.to_h
200
+ end
201
+ alias eql? ==
202
+
203
+ sig { returns(Integer) }
204
+ def hash = [self.class, to_h].hash
205
+
206
+ sig { returns(String) }
207
+ def inspect = "#<#{self.class} #{id} #{name.inspect}>"
208
+
209
+ private
210
+
211
+ # Symbol keys, with every spelling Query accepts resolved to its canonical
212
+ # one -- and the two search options a rescreen settles for itself refused
213
+ # by name rather than ignored.
214
+ sig { params(fields: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
215
+ def normalize(fields)
216
+ attributes = fields.each_with_object({}) do |(key, value), resolved|
217
+ member = Query::ALIASES.fetch(key, key)
218
+ reason = REFUSED[member]
219
+ raise InvalidArgument, "a Subject does not take #{member}: #{reason}" if reason
220
+
221
+ resolved[member] = wrap(member, value)
222
+ end
223
+ unknown = attributes.keys - MEMBERS
224
+ raise InvalidArgument, "unknown Subject attribute(s): #{unknown.join(", ")}" if unknown.any?
225
+
226
+ attributes
227
+ end
228
+
229
+ # A single value where a collection is expected is a collection of one,
230
+ # for the reason Query gives: `dates_of_birth: "1973"` is what a
231
+ # caller with one date writes, and `Array(hash)` would read one identifier
232
+ # as a list of its pairs.
233
+ sig { params(member: Symbol, value: T.untyped).returns(T.untyped) }
234
+ def wrap(member, value)
235
+ return value unless Query::COLLECTIONS.include?(member)
236
+ return value if value.nil? || value.is_a?(Array)
237
+
238
+ [value]
239
+ end
240
+
241
+ # Required, and taken as the caller wrote it: an id this library edited
242
+ # would not join back to the row it came from.
243
+ sig { params(value: T.untyped).returns(String) }
244
+ def id!(value)
245
+ string = value.to_s.strip
246
+ if string.empty?
247
+ raise InvalidArgument,
248
+ "a subject needs an id -- an alert has to name the record a compliance team will open, and a " \
249
+ "book screened by position cannot survive being filtered or streamed"
250
+ end
251
+
252
+ -string
253
+ end
254
+
255
+ # nil means "whatever the run says", which is the common case and the
256
+ # reason this is not defaulted from configuration here: a subject that
257
+ # quietly carried the configured threshold could not be told apart from
258
+ # one that asked for it, and the run could never override it.
259
+ sig { params(value: T.untyped).returns(T.nilable(Float)) }
260
+ def threshold!(value)
261
+ return nil if value.nil?
262
+
263
+ number = begin
264
+ Float(value)
265
+ rescue TypeError, ArgumentError
266
+ raise InvalidArgument, "threshold must be a number between 0 and 100, got #{value.inspect}"
267
+ end
268
+ Scorer.threshold!(number)
269
+ end
270
+ end
271
+ end
@@ -0,0 +1,245 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "time"
7
+ require "active_sanction/sync/result"
8
+
9
+ module ActiveSanction
10
+ class Sync
11
+ # What a whole sync run did, one Result per source.
12
+ #
13
+ # report = ActiveSanction.sync!
14
+ #
15
+ # report.failed? # => true
16
+ # report[:un_consolidated].error # => "Net::ReadTimeout: ..."
17
+ # report.updated.map(&:source) # => [:ofac_sdn]
18
+ # puts report # => the table below
19
+ #
20
+ # 4 sources in 13.08s: 1 updated, 2 unchanged, 1 failed
21
+ # ofac_sdn updated 19015 records just fetched 12.41s
22
+ # ofac_consolidated unchanged 1203 records 2h old 0.28s
23
+ # canada_sema unchanged 684 records 2h old 0.19s
24
+ # un_consolidated failed 612 records 3d old 1.11s Net::ReadTimeout: execution expired
25
+ #
26
+ # ### It is an object, not console output
27
+ #
28
+ # This is the operational surface of a sync: it is what a host application
29
+ # alerts on, what a scheduled job exits with, and what the instrumentation
30
+ # hooks (#59) emit. So it serializes to a documented shape and `.from_h`
31
+ # rebuilds it -- a summary that only existed as printed text would mean
32
+ # every host that wants to notice a degrading source has to scrape a log.
33
+ #
34
+ # Note what the table prints beside a failure: the record count and age of
35
+ # the snapshot that source is *still* being screened against. A failed sync
36
+ # keeps its previous snapshot, which is the right call and is only safe
37
+ # while the age of what is being screened against is visible.
38
+ #
39
+ # Instances are frozen on construction and compare by value.
40
+ class Report
41
+ extend T::Sig
42
+ extend T::Generic
43
+ include Enumerable
44
+
45
+ # @api private
46
+ Elem = type_member { { fixed: Result } }
47
+
48
+ # @api private
49
+ MEMBERS = T.let(%i[results started_at duration].freeze, T::Array[Symbol])
50
+
51
+ sig { returns(T::Array[Result]) }
52
+ attr_reader :results
53
+
54
+ # When the run began, UTC.
55
+ sig { returns(Time) }
56
+ attr_reader :started_at
57
+
58
+ # Wall-clock seconds for the whole run, which is less than the sum of the
59
+ # per-source durations when sources ran in parallel.
60
+ sig { returns(Float) }
61
+ attr_reader :duration
62
+
63
+ # Rebuilds from #to_h output, accepting string keys so a report survives
64
+ # the trip through JSON.
65
+ sig { params(hash: T.untyped).returns(T.attached_class) }
66
+ def self.from_h(hash)
67
+ attributes = hash.to_h.transform_keys(&:to_sym)
68
+ unknown = attributes.keys - MEMBERS
69
+ raise InvalidArgument, "unknown Sync::Report attribute(s): #{unknown.join(", ")}" if unknown.any?
70
+
71
+ T.unsafe(self).new(**attributes)
72
+ end
73
+
74
+ sig { params(results: T.untyped, started_at: T.untyped, duration: T.untyped).void }
75
+ def initialize(results:, started_at: nil, duration: 0.0)
76
+ @results = T.let(results!(results), T::Array[Result])
77
+ @started_at = T.let(time!(started_at), Time)
78
+ @duration = T.let(duration.to_f, Float)
79
+ freeze
80
+ end
81
+
82
+ sig { override.params(block: T.nilable(T.proc.params(result: Result).void)).returns(T.untyped) }
83
+ def each(&block)
84
+ return enum_for(:each) unless block
85
+
86
+ results.each(&block)
87
+ self
88
+ end
89
+
90
+ # One source's result, or nil if the run did not cover it. A run that did
91
+ # not cover a source is not the same as one where it succeeded, which is
92
+ # why this does not raise: a caller asking about a source it did not sync
93
+ # is asking a question with an answer.
94
+ sig { params(source: T.untyped).returns(T.nilable(Result)) }
95
+ def [](source)
96
+ key = source.to_sym
97
+ results.find { |result| result.source == key }
98
+ end
99
+
100
+ sig { returns(T::Array[Symbol]) }
101
+ def sources = results.map(&:source)
102
+
103
+ sig { returns(T::Array[Result]) }
104
+ def updated = results.select(&:updated?)
105
+
106
+ sig { returns(T::Array[Result]) }
107
+ def unchanged = results.select(&:unchanged?)
108
+
109
+ sig { returns(T::Array[Result]) }
110
+ def failed = results.select(&:failed?)
111
+
112
+ # Sources that came out of this run with no snapshot stored at all, and
113
+ # so are not covered by screening. Louder than `failed` and rarer: a
114
+ # source that failed but kept its previous list is stale, one that has
115
+ # nothing stored is missing.
116
+ sig { returns(T::Array[Result]) }
117
+ def unscreenable = results.reject(&:stored?)
118
+
119
+ sig { returns(T::Boolean) }
120
+ def failed? = results.any?(&:failed?)
121
+
122
+ sig { returns(T::Boolean) }
123
+ def success? = !failed?
124
+
125
+ sig { returns(Integer) }
126
+ def size = results.size
127
+
128
+ sig { returns(T::Boolean) }
129
+ def empty? = results.empty?
130
+
131
+ # Records stored across every source the run covered, failures included:
132
+ # what is screenable now, rather than what was downloaded.
133
+ sig { returns(Integer) }
134
+ def record_count = results.sum { |result| result.record_count || 0 }
135
+
136
+ # The age of the stalest list this run left behind, in seconds. The one
137
+ # number to alert on if a host only wants one.
138
+ sig { returns(T.nilable(Integer)) }
139
+ def oldest_age = results.filter_map(&:age).max
140
+
141
+ # What a scheduled job should exit with, so that cron mails somebody and
142
+ # CI goes red when a source is failing. Deliberately here rather than
143
+ # left to each caller to derive: "one source failed" has to mean the same
144
+ # thing to every wrapper anyone writes around a sync.
145
+ #
146
+ # exit ActiveSanction.sync!.exit_code
147
+ sig { returns(Integer) }
148
+ def exit_code = failed? ? 1 : 0
149
+
150
+ # For a caller that wants any failure to be fatal, in the manner of
151
+ # Fetcher::Result#success!. Note that this is not what `sync!` does: the
152
+ # run has already finished and every other source has already been
153
+ # stored, so raising here reports a failure rather than causing one.
154
+ sig { returns(T.self_type) }
155
+ def success!
156
+ return self if success?
157
+
158
+ raise Failed, self
159
+ end
160
+
161
+ sig { returns(T::Hash[Symbol, T.untyped]) }
162
+ def to_h
163
+ {
164
+ results: results.map(&:to_h),
165
+ started_at: started_at.iso8601,
166
+ duration: duration
167
+ }
168
+ end
169
+
170
+ # The sentence a failing run should put in front of a human: which
171
+ # sources failed, out of how many, and why.
172
+ sig { params(result: Result).returns(String) }
173
+ def records(result) = result.record_count&.to_s || "-"
174
+
175
+ sig { params(block: T.proc.params(result: Result).returns(String)).returns(Integer) }
176
+ def width(&block) = results.map { |result| block.call(result).length }.max.to_i
177
+
178
+ sig { returns(String) }
179
+ def failure_message
180
+ "#{failed.size} of #{size} source(s) failed to sync: " +
181
+ failed.map { |result| "#{result.source} (#{result.error})" }.join("; ")
182
+ end
183
+
184
+ sig { returns(String) }
185
+ def summary
186
+ counts = { updated: updated.size, unchanged: unchanged.size, failed: failed.size }
187
+ .reject { |_status, count| count.zero? }
188
+ .map { |status, count| "#{count} #{status}" }
189
+ "#{size} #{size == 1 ? "source" : "sources"} in #{format("%.2f", duration)}s" \
190
+ "#{": #{counts.join(", ")}" unless counts.empty?}"
191
+ end
192
+
193
+ sig { returns(String) }
194
+ def to_s = ([summary] + rows).join("\n")
195
+
196
+ sig { params(other: T.untyped).returns(T::Boolean) }
197
+ def ==(other)
198
+ return false unless other.instance_of?(self.class)
199
+
200
+ to_h == other.to_h
201
+ end
202
+ alias eql? ==
203
+
204
+ sig { returns(Integer) }
205
+ def hash = [self.class, to_h].hash
206
+
207
+ sig { returns(String) }
208
+ def inspect = "#<#{self.class} #{summary}>"
209
+
210
+ private
211
+
212
+ # One padded line per source. Columns rather than sentences because the
213
+ # thing an operator is doing with this is scanning down it for the row
214
+ # that is not like the others.
215
+ sig { returns(T::Array[String]) }
216
+ def rows
217
+ name = width { |result| result.source.to_s }
218
+ count = width { |result| records(result) }
219
+ age = width(&:age_in_words)
220
+ results.map do |result|
221
+ " #{result.source.to_s.ljust(name)} #{result.status.to_s.ljust(9)} " \
222
+ "#{records(result).rjust(count)} records #{result.age_in_words.ljust(age)} " \
223
+ "#{format("%.2f", result.duration).rjust(6)}s#{" #{result.error}" if result.failed?}"
224
+ end
225
+ end
226
+
227
+ sig { params(value: T.untyped).returns(T::Array[Result]) }
228
+ def results!(value)
229
+ list = Array(value).map { |result| result.is_a?(Result) ? result : Result.from_h(result) }
230
+ list.freeze
231
+ end
232
+
233
+ sig { params(value: T.untyped).returns(Time) }
234
+ def time!(value)
235
+ time = case value
236
+ when nil then Time.now
237
+ when Time then value
238
+ when String then Time.parse(value)
239
+ else raise InvalidArgument, "started_at is not a time: #{value.inspect}"
240
+ end
241
+ Time.at(time.to_i).utc
242
+ end
243
+ end
244
+ end
245
+ end