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,412 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "active_sanction/entity"
7
+ require "active_sanction/name"
8
+ require "active_sanction/address"
9
+ require "active_sanction/identifier"
10
+ require "active_sanction/partial_date"
11
+ require "active_sanction/sources/remarks"
12
+
13
+ module ActiveSanction
14
+ module Sources
15
+ class UkSanctionsList < Base
16
+ # One `<Designation>` turned into an Entity.
17
+ #
18
+ # Separate from the adapter because they are two jobs: the adapter says
19
+ # what the list is and where it lives, and this says what the FCDO's
20
+ # elements mean. The mapping is where all the judgment sits, so it is
21
+ # worth being able to read it on its own.
22
+ #
23
+ # ### A name is six numbered parts, in order
24
+ #
25
+ # The FCDO splits a name across `<Name1>` to `<Name6>`: given names
26
+ # ascending, and `<Name6>` holding the family name -- or, for an
27
+ # organization or a ship, the whole name on its own, which is why
28
+ # `<Name6>` is populated on 15,647 of the 15,677 names and `<Name1>` on
29
+ # 9,691. Joined in numeric order they read the way the name is said:
30
+ # `ABDUL KABIR MUHAMMAD JAN`, `ABDUL SALAM HANAFI ALI MARDAN QUL`.
31
+ #
32
+ # Note that the CSV rendering of this same list orders its columns
33
+ # `Name 6, Name 1, Name 2, ...`, which is the FCDO filing surname-first
34
+ # for a reader and not a different name. Following that order here would
35
+ # produce `JAN ABDUL KABIR MUHAMMAD`, which is a string no query will
36
+ # match and no publisher wrote.
37
+ #
38
+ # ### `Primary Name Variation` is an alias, and 5,513 names depend on it
39
+ # not being treated as one more primary
40
+ #
41
+ # The FCDO publishes three name types. `Primary Name` is the designation
42
+ # proper. `Alias` is a different name the person is known by. `Primary
43
+ # Name Variation` is a different *spelling* of the primary name -- the
44
+ # four rows `MITHOO`, `MITHU`, `MITTO`, `MITTU` against one Pakistani
45
+ # cleric -- and it is by far the most common thing on the list after the
46
+ # aliases themselves. All of them are real screening signal and none of
47
+ # them is the official name, so a variation is filed as `:aka`, which is
48
+ # what leaves `Entity#primary_name` answering with the name the FCDO
49
+ # actually designated.
50
+ #
51
+ # @api private
52
+ class Record
53
+ extend T::Sig
54
+
55
+ TYPES = T.let(
56
+ { "individual" => :individual, "entity" => :organization, "ship" => :vessel }.freeze,
57
+ T::Hash[String, Symbol]
58
+ )
59
+
60
+ # `NameType`, case-folded. The list contains `Primary Name`, `Primary
61
+ # name`, `Primary Name Variation`, `Primary name variation`, `Alias`
62
+ # and one `ALias`, which is the whole reason this is a folded lookup
63
+ # rather than a comparison.
64
+ NAME_KINDS = T.let(
65
+ { "primary name" => :primary, "primary name variation" => :aka, "alias" => :aka }.freeze,
66
+ T::Hash[String, Symbol]
67
+ )
68
+
69
+ # `AliasStrength`, which the FCDO publishes as exactly two strings on
70
+ # 2,073 names and leaves absent on the rest. Absent is nil rather than
71
+ # good: an ungraded name must not be penalized for a field the FCDO
72
+ # did not fill in, and must not be credited for one either.
73
+ ALIAS_QUALITIES = T.let(
74
+ { "good quality a.k.a" => :good, "low quality a.k.a" => :low }.freeze,
75
+ T::Hash[String, Symbol]
76
+ )
77
+
78
+ # Name parts, in the order they are said.
79
+ NAME_PARTS = T.let((1..6).map { |part| "Name#{part}" }.freeze, T::Array[String])
80
+
81
+ # Address lines 1 to 5 are the street and the locality detail; line 6
82
+ # is the last line the FCDO writes before the country. See #address.
83
+ STREET_LINES = T.let((1..5).map { |line| "AddressLine#{line}" }.freeze, T::Array[String])
84
+
85
+ # 635 of the 670 IMO numbers are published as `IMO9562233` and the
86
+ # other 35 as `9562233`. The prefix restates the element the number is
87
+ # already inside, and Identifier compares on alphanumerics -- so left
88
+ # alone the FCDO's own two spellings of one registry number are two
89
+ # different identifiers, and a query for either finds one of them. It
90
+ # is peeled off into the note, where it is still readable and no longer
91
+ # part of what gets compared.
92
+ IMO_PREFIX = T.let(/\AIMO[[:space:]]*(?=\d)/i, Regexp)
93
+
94
+ # The FCDO's line wrapping arrives inside element text -- a statement
95
+ # of reasons opens and closes on a newline -- and a name that keeps it
96
+ # is a name nothing will ever match.
97
+ WHITESPACE = T.let(/[[:space:]]+/, Regexp)
98
+
99
+ # What separates the two prose fields once they are joined. See
100
+ # #published_prose.
101
+ PARAGRAPH = T.let("\n\n", String)
102
+
103
+ sig { returns(Parsers::XmlRecords::Record) }
104
+ attr_reader :node
105
+
106
+ sig { params(node: Parsers::XmlRecords::Record).void }
107
+ def initialize(node)
108
+ @node = T.let(node, Parsers::XmlRecords::Record)
109
+ @names = T.let(nil, T.nilable(T::Array[Name]))
110
+ @dates_of_birth = T.let(nil, T.nilable(T::Array[PartialDate]))
111
+ @unread_dates = T.let([], T::Array[String])
112
+ end
113
+
114
+ # The entity, or nil for a record with no name -- which cannot be
115
+ # screened against and is never what the FCDO meant to publish.
116
+ sig { returns(T.nilable(Entity)) }
117
+ def entity
118
+ return nil if names.empty?
119
+
120
+ Entity.new(source: :uk_sanctions_list, source_ref: source_ref, type: type, names: names,
121
+ addresses: addresses, identifiers: identifiers, dates_of_birth: dates_of_birth,
122
+ nationalities: nationalities, programs: programs, listed_on: listed_on,
123
+ remarks: remarks)
124
+ end
125
+
126
+ # `IndividualEntityShip`, which is populated on every record and is the
127
+ # only place the list says what a designation is about. A ship is a
128
+ # `:vessel`, and there are 664 of them: without the distinct type a
129
+ # search for a person can rank a tanker.
130
+ sig { returns(Symbol) }
131
+ def type = TYPES.fetch(node["IndividualEntityShip"].to_s.downcase, :organization)
132
+
133
+ # `UniqueID` -- `AFG0001`, `RUS3379` -- rather than the OFSI Group ID
134
+ # beside it. Both are unique where they are present, but the Group ID
135
+ # is being retired: designations made since 28 January 2026 do not get
136
+ # one, so an id built from it would be nil on everything the UK has
137
+ # sanctioned this year. The historic Group ID is kept in remarks.
138
+ sig { returns(T.nilable(String)) }
139
+ def source_ref = node["UniqueID"]
140
+
141
+ # Prose, the way the UN publishes it: `Russia`, `North Korea`,
142
+ # `Congo (Democratic Republic)`. Country resolves it to an ISO code at
143
+ # scoring time, and treats one it cannot resolve as absent rather than
144
+ # as a conflict -- which is the answer that matters for `Kosovo`, a
145
+ # nationality on ten of these records and a country with no ISO 3166-1
146
+ # code to resolve to.
147
+ sig { returns(T::Array[String]) }
148
+ def nationalities = node.values("IndividualDetails/Individual/Nationalities/Nationality").uniq
149
+
150
+ # The date the FCDO designated the entity, on all 6,334 records.
151
+ sig { returns(T.nilable(PartialDate)) }
152
+ def listed_on = PublishedDate.call(node["DateDesignated"])
153
+
154
+ # The regime the designation is made under, which for this list is the
155
+ # statutory instrument itself: `The Russia (Sanctions) (EU Exit)
156
+ # Regulations 2019`. 31 of them cover the whole list, and it is the
157
+ # closest thing the UK publishes to OFAC's programme codes -- the
158
+ # measures actually imposed are a separate field, and are in remarks.
159
+ sig { returns(T::Array[String]) }
160
+ def programs = node.values("RegimeName").uniq
161
+
162
+ sig { returns(T::Array[Name]) }
163
+ def names
164
+ @names ||= dedupe(published_names + non_latin_names)
165
+ end
166
+
167
+ # Memoized, because reading them is also what fills the list of dates
168
+ # this could not use, which #remarks then keeps rather than losing.
169
+ sig { returns(T::Array[PartialDate]) }
170
+ def dates_of_birth
171
+ @dates_of_birth ||= begin
172
+ @unread_dates = []
173
+ node.values("IndividualDetails/Individual/DOBs/DOB").filter_map { |born| date_of_birth(born) }.uniq
174
+ end
175
+ end
176
+
177
+ # Passports and national identity numbers for a person, business
178
+ # registration numbers for an organization, IMO numbers for a ship.
179
+ # De-duplicated through Identifier's own equality, which compares a
180
+ # number by its alphanumerics rather than by the punctuation around
181
+ # them: the FCDO's export repeats a passport once per birth date, so
182
+ # one Afghan record publishes the same number ten times.
183
+ sig { returns(T::Array[Identifier]) }
184
+ def identifiers = (passports + national_ids + registrations + imo_numbers).uniq
185
+
186
+ sig { returns(T::Array[Address]) }
187
+ def addresses
188
+ node.nodes("Addresses/Address").filter_map { |place| address(place) }
189
+ end
190
+
191
+ # The FCDO's own prose verbatim -- its note on the designation and its
192
+ # statement of reasons, which are two fields and one voice -- then the
193
+ # elements that have nowhere else to go, behind the marker that makes
194
+ # them trivial to strip again.
195
+ sig { returns(T.nilable(String)) }
196
+ def remarks
197
+ dates_of_birth # for its side effect: it is what fills @unread_dates, which #extras keeps
198
+ Remarks.build(published_prose, extras)
199
+ end
200
+
201
+ private
202
+
203
+ # The two fields the FCDO writes prose in: its note on the designation,
204
+ # and the legal case for it. Both are the publisher's own words and one
205
+ # voice, so both are kept ahead of the marker -- what a consumer asking
206
+ # what the FCDO said gets back should not depend on which of its two
207
+ # boxes an examiner happened to type in.
208
+ # nil for the 127 records the FCDO wrote neither field on, so that a
209
+ # consumer asking what the publisher said is handed nothing rather than
210
+ # a list of fields this adapter appended.
211
+ sig { returns(T.nilable(String)) }
212
+ def published_prose
213
+ prose = [node["OtherInformation"], node["UKStatementofReasons"]].compact.map(&:strip).reject(&:empty?)
214
+ prose.empty? ? nil : prose.join(PARAGRAPH)
215
+ end
216
+
217
+ # Every `<Name>` the record carries, in document order. 26 of the
218
+ # 15,677 published carry a `NameType` and no part at all, and a
219
+ # blank-valued Name is a record that matches everything.
220
+ sig { returns(T::Array[Name]) }
221
+ def published_names
222
+ node.nodes("Names/Name").filter_map do |published|
223
+ value = collapse(NAME_PARTS.filter_map { |part| published[part] }.join(" "))
224
+ next nil if value.nil?
225
+
226
+ Name.new(value: value, kind: name_kind(published), quality: alias_quality(published))
227
+ end
228
+ end
229
+
230
+ # The name in the script the FCDO's source wrote it in -- Cyrillic for
231
+ # 1,752 of them, Arabic for 179. Filed as an alias with no `script`
232
+ # declared; the adapter's class comment says why the FCDO's own label
233
+ # is not used to declare one.
234
+ sig { returns(T::Array[Name]) }
235
+ def non_latin_names
236
+ node.values("NonLatinNames/NonLatinName/NameNonLatinScript").filter_map do |published|
237
+ value = collapse(published)
238
+ value && Name.new(value: value, kind: :aka)
239
+ end
240
+ end
241
+
242
+ sig { params(published: Parsers::XmlRecords::Record).returns(Symbol) }
243
+ def name_kind(published) = NAME_KINDS.fetch(published["NameType"].to_s.strip.downcase, :aka)
244
+
245
+ sig { params(published: Parsers::XmlRecords::Record).returns(T.nilable(Symbol)) }
246
+ def alias_quality(published) = ALIAS_QUALITIES[published["AliasStrength"].to_s.strip.downcase]
247
+
248
+ # 36 names across 35 records repeat a spelling already on the record,
249
+ # usually because the same string is filed once as a variation and once
250
+ # as an alias. Two identical Names are two index entries that can only
251
+ # ever fire together. The first wins, so a name published as primary
252
+ # keeps its kind.
253
+ sig { params(published: T::Array[Name]).returns(T::Array[Name]) }
254
+ def dedupe(published)
255
+ seen = T.let({}, T::Hash[String, Name])
256
+ published.each { |name| seen[name.value] ||= name }
257
+ seen.values
258
+ end
259
+
260
+ sig { params(born: String).returns(T.nilable(PartialDate)) }
261
+ def date_of_birth(born)
262
+ PublishedDate.call(born) || note_unread(born)
263
+ end
264
+
265
+ # A birth date string that gave no usable date -- today, the one record
266
+ # published as `15/08/19yy`. Kept so that nothing the FCDO said about
267
+ # when a person was born disappears silently.
268
+ sig { params(born: String).returns(NilClass) }
269
+ def note_unread(born)
270
+ @unread_dates << born
271
+ nil
272
+ end
273
+
274
+ sig { returns(T::Array[Identifier]) }
275
+ def passports
276
+ documents("IndividualDetails/Individual/PassportDetails/Passport", :passport,
277
+ "PassportNumber", "PassportAdditionalInformation")
278
+ end
279
+
280
+ sig { returns(T::Array[Identifier]) }
281
+ def national_ids
282
+ documents("IndividualDetails/Individual/NationalIdentifierDetails/NationalIdentifier", :national_id,
283
+ "NationalIdentifierNumber", "NationalIdentifierAdditionalInformation")
284
+ end
285
+
286
+ sig { params(path: String, kind: Symbol, number: String, information: String).returns(T::Array[Identifier]) }
287
+ def documents(path, kind, number, information)
288
+ node.nodes(path).filter_map do |document|
289
+ identifier(kind: kind, value: document[number], note: document[information])
290
+ end
291
+ end
292
+
293
+ # Kept exactly as published, prefixes and all. 340 of the 721 open with
294
+ # a label -- `INN: 7710137066`, `OGRN: 1247700291200` -- and a further
295
+ # handful carry two numbers, a country and a newline in one field. A
296
+ # rule that peeled the label off `INN: 7710137066` would have to decide
297
+ # what to do with `OGRN: 1247700291200\nKPP: 770701001\nINN:
298
+ # 9707028663`, and every answer to that is a guess about free text.
299
+ sig { returns(T::Array[Identifier]) }
300
+ def registrations
301
+ node.values("EntityDetails/Entity/BusinessRegistrationNumbers/BusinessRegistrationNumber")
302
+ .filter_map { |number| identifier(kind: :registration_number, value: number) }
303
+ end
304
+
305
+ # Filed as :other because a ship's registry number is none of the
306
+ # document kinds the model names, and it is a document rather than a
307
+ # remark: an IMO number is unique, permanent and the one field on a
308
+ # vessel record that a screening query can be decisive about.
309
+ sig { returns(T::Array[Identifier]) }
310
+ def imo_numbers
311
+ node.values("ShipDetails/Ship/IMONumbers/IMONumber").filter_map do |number|
312
+ identifier(kind: :other, value: number.sub(IMO_PREFIX, ""), note: "IMO number")
313
+ end
314
+ end
315
+
316
+ # 8 of the 780 passport elements carry additional information and no
317
+ # number. Identifier refuses a value with no alphanumerics, and those
318
+ # are dropped: a document with a description and no identity is nothing
319
+ # to match on, which is the same call the UN and EU adapters make.
320
+ sig { params(kind: Symbol, value: T.untyped, note: T.untyped).returns(T.nilable(Identifier)) }
321
+ def identifier(kind:, value:, note: nil)
322
+ Identifier.new(kind: kind, value: value, note: note)
323
+ rescue ArgumentError
324
+ nil
325
+ end
326
+
327
+ # `AddressLine1` to `AddressLine5` are the street and the locality
328
+ # detail, in order, and `AddressLine6` is the last line before the
329
+ # country. Line 6 is filed under `city` whole rather than split on a
330
+ # guess: it holds `Kabul` and `Dubai` on some records and `Helmand
331
+ # Province` and `Nimroz Province` on others, and a rule that tried to
332
+ # tell those apart would be wrong on both. 450 of the 3,816 addresses
333
+ # have line 6 as their only line.
334
+ sig { params(place: Parsers::XmlRecords::Record).returns(T.nilable(Address)) }
335
+ def address(place)
336
+ Address.new(street: join(STREET_LINES.filter_map { |line| place[line] }),
337
+ city: place["AddressLine6"], postal_code: place["AddressPostalCode"],
338
+ country: place["AddressCountry"])
339
+ rescue ArgumentError
340
+ nil
341
+ end
342
+
343
+ # Label/value pairs for Remarks.build, which drops the ones the record
344
+ # left blank. A value may be several -- one person's positions are
345
+ # filed one per element -- and arrives as the Array it published.
346
+ sig { returns(T::Array[T.untyped]) }
347
+ def extras
348
+ [["OFSI group id", node["OFSIGroupID"]],
349
+ ["UN reference", node["UNReferenceNumber"]],
350
+ ["Designation source", node["DesignationSource"]],
351
+ ["Sanctions imposed", node["SanctionsImposed"]],
352
+ ["Last updated", node["LastUpdated"]],
353
+ ["Title", node.values("Titles/Title")],
354
+ ["Date of birth as published", @unread_dates]] + individual + organization + ship + contact
355
+ end
356
+
357
+ sig { returns(T::Array[T.untyped]) }
358
+ def individual
359
+ [["Gender", node.values("IndividualDetails/Individual/Genders/Gender").uniq],
360
+ ["Position", node.values("IndividualDetails/Individual/Positions/Position")],
361
+ ["Town of birth", node.values("IndividualDetails/Individual/BirthDetails/Location/TownOfBirth").uniq],
362
+ ["Country of birth", node.values("IndividualDetails/Individual/BirthDetails/Location/CountryOfBirth").uniq]]
363
+ end
364
+
365
+ sig { returns(T::Array[T.untyped]) }
366
+ def organization
367
+ [["Type of entity", node.values("EntityDetails/Entity/TypeOfEntities/TypeOfEntity").uniq],
368
+ ["Parent company", node.values("EntityDetails/Entity/ParentCompanies/ParentCompany")],
369
+ ["Subsidiary", node.values("EntityDetails/Entity/Subsidiaries/Subsidiary")]]
370
+ end
371
+
372
+ # A ship's flag, owner and dimensions are real screening signal with no
373
+ # home in the canonical model -- the same trade OFAC's adapter makes
374
+ # for a vessel's tonnage and owner -- so they are kept here rather than
375
+ # dropped to keep a schema tidy.
376
+ sig { returns(T::Array[T.untyped]) }
377
+ def ship
378
+ { "Current owner/operator" => "CurrentOwnerOperators/CurrentOwnerOperator",
379
+ "Previous owner/operator" => "PreviousOwnerOperators/PreviousOwnerOperator",
380
+ "Current believed flag" => "CurrentBelievedFlagOfShips/CurrentBelievedFlagOfShip",
381
+ "Previous flag" => "PreviousFlags/PreviousFlag",
382
+ "Type of ship" => "TypeOfShipDetails/TypeOfShip",
383
+ "Tonnage" => "TonnageOfShipDetails/TonnageOfShip",
384
+ "Length" => "LengthOfShipDetails/LengthOfShip",
385
+ "Year built" => "YearsBuilt/YearBuilt" }
386
+ .map { |label, path| [label, node.values("ShipDetails/Ship/#{path}")] }
387
+ end
388
+
389
+ # Contact details, and the FCDO's own reading of the non-Latin names.
390
+ # The script label is recorded rather than declared on the Name -- see
391
+ # the adapter's class comment for the three records that say why.
392
+ sig { returns(T::Array[T.untyped]) }
393
+ def contact
394
+ [["Phone", node.values("PhoneNumbers/PhoneNumber")],
395
+ ["Website", node.values("Websites/Website")],
396
+ ["Email", node.values("EmailAddresses/EmailAddress")],
397
+ ["Non-Latin script", node.values("NonLatinNames/NonLatinName/NonLatinScriptType").uniq],
398
+ ["Non-Latin language", node.values("NonLatinNames/NonLatinName/NonLatinScriptLanguage").uniq]]
399
+ end
400
+
401
+ sig { params(parts: T::Array[String]).returns(T.nilable(String)) }
402
+ def join(parts) = parts.empty? ? nil : parts.join(", ")
403
+
404
+ sig { params(value: T.untyped).returns(T.nilable(String)) }
405
+ def collapse(value)
406
+ string = value.to_s.split(WHITESPACE).join(" ")
407
+ string.empty? ? nil : -string
408
+ end
409
+ end
410
+ end
411
+ end
412
+ end
@@ -0,0 +1,214 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "active_sanction/parsers"
7
+ require "active_sanction/sources"
8
+ require "active_sanction/sources/base"
9
+
10
+ module ActiveSanction
11
+ module Sources
12
+ # The UK Sanctions List: every designation made under the Sanctions and
13
+ # Anti-Money Laundering Act 2018, published by the Foreign, Commonwealth
14
+ # and Development Office.
15
+ #
16
+ # snapshot = ActiveSanction::Sources[:uk_sanctions_list].new.sync
17
+ #
18
+ # ### This is not the OFSI Consolidated List, and that is deliberate
19
+ #
20
+ # The list this adapter was scoped against -- OFSI's Consolidated List of
21
+ # Asset Freeze Targets, `ConList.csv` -- was retired on 28 January 2026,
22
+ # when the UK moved every sanctions designation onto one list. Its gov.uk
23
+ # page is marked withdrawn, and the FCDO's own transition guidance says the
24
+ # Consolidated List "is no longer being updated".
25
+ #
26
+ # The blob it was served from still answers 200. That is the trap: an
27
+ # adapter reading it would look healthy on every sync, download a real
28
+ # file, produce real entities and screen a book of business against a list
29
+ # that stopped moving in January. A stale list that reports itself fresh is
30
+ # the most expensive way this library can fail, so the UK adapter reads the
31
+ # list the UK actually publishes.
32
+ #
33
+ # OFSI Group IDs are not lost in the move: the FCDO carries the historic
34
+ # one on every designation made before 28 January 2026, and this adapter
35
+ # keeps it in `remarks` so a hit can still be reconciled against an OFSI
36
+ # licence application or a suspected-breach report. Designations made since
37
+ # carry a `UniqueID` and nothing else, which is why the Unique ID is what
38
+ # `source_ref` is built from.
39
+ #
40
+ # ### Seven formats, and why this one
41
+ #
42
+ # https://sanctionslist.fcdo.gov.uk/docs/UK-Sanctions-List.xml
43
+ #
44
+ # <Designations><Designation> 6,334 records, 21.8 MB
45
+ #
46
+ # The FCDO publishes the same list seven ways, and two of them are
47
+ # machine-readable at this size: the XML above and a CSV added in January
48
+ # to match the format OFSI's readers had been built against. The CSV is
49
+ # 49.9 MB and 58,424 rows for the same 6,334 designations, because it is a
50
+ # full cartesian product of every repeating group -- one record in it,
51
+ # `INU0075`, occupies 3,780 rows, being 10 names x 7 addresses x 3 phone
52
+ # numbers x 2 websites x 9 subsidiaries. Reading it means grouping 58,424
53
+ # rows and then de-duplicating each dimension back out of the product,
54
+ # which is recoverable but is reconstruction rather than parsing. The XML
55
+ # states the same structure directly, in 44% of the bytes, against a
56
+ # published XSD, and it carries a field the CSV has no column for at all.
57
+ #
58
+ # ### Conditional GET works here
59
+ #
60
+ # Unlike the EU's endpoint, this one serves both an ETag and a
61
+ # Last-Modified and honours both: a sync against an unchanged list answers
62
+ # 304 and downloads nothing. Verified against the live endpoint. So the
63
+ # usual run of this source costs one request, and the 21.8 MB is paid only
64
+ # on the days the FCDO republishes.
65
+ #
66
+ # ### The two traps in this list
67
+ #
68
+ # **A date component the FCDO does not know is spelled out, not omitted.**
69
+ # `dd/mm/1962` is a year-only birth date, `dd/07/1978` is a month, and
70
+ # `00/00/1975` is one more spelling of the same thing. 824 of the 3,788
71
+ # published birth dates -- 22% -- carry a placeholder, and every one of
72
+ # them reads as nil through an ordinary date parser. See PublishedDate.
73
+ #
74
+ # **The publisher's own script labels disagree with its own strings.**
75
+ # `NonLatinScriptType` is stated on 2,057 of the 3,856 non-Latin names and
76
+ # agrees with the characters on 2,054 of them -- but three names labelled
77
+ # `Cyrillic` are Latin transliterations (`OAO "STUPINSKAYA
78
+ # METALLURGICHESKAYA KOMPANIYA"`), and 185 names filed under
79
+ # `<NonLatinName>` contain no non-Latin character at all. So `script` is
80
+ # left unstated on every name here, which is the same call the UN and EU
81
+ # adapters make for the same reason: which script a string is in is a
82
+ # question about its characters, and the normalizer is where that gets
83
+ # answered. The FCDO's label and language are kept in `remarks`.
84
+ #
85
+ # ### What a clean UK result is worth
86
+ #
87
+ # More than an EU one. The FCDO marks its own primary name -- every one of
88
+ # the 6,334 records carries exactly one `NameType` of `Primary Name`, six
89
+ # carry two, and none carries none -- so this adapter never has to guess
90
+ # which spelling is the official one, which is the judgment the EU adapter
91
+ # is stuck making. Alias grading is published as a field rather than as
92
+ # prose, on 2,073 names. Against that, nationality arrives as prose
93
+ # ("Russia", "North Korea"), the way the UN publishes it, and is resolved
94
+ # by Country at scoring time rather than here.
95
+ #
96
+ # ### What this adapter does not do
97
+ #
98
+ # It does not read `<CryptoWalletAddresses>` or
99
+ # `<HullIdentificationNumbers>`. The XSD defines both and the FCDO populates
100
+ # neither on any of the 6,334 records published today, and a mapping
101
+ # written against zero records is a guess that gets discovered to be wrong
102
+ # by a user rather than by a test. They are the first things to add when
103
+ # either field appears.
104
+ #
105
+ # It does not parse `PassportAdditionalInformation`, which is a sentence
106
+ # ("Afghanistan passport number P04581926, issued on 7 August 2024, issued
107
+ # in Kandahar, Afghanistan (expires 7 August 2034)") carrying an issuing
108
+ # country and two dates that Identifier has members for. Reading it is the
109
+ # same job OFAC's RemarksParser does and wants the same treatment --
110
+ # a measured coverage figure -- rather than a regex added here in passing.
111
+ # The sentence is kept verbatim on the identifier's note.
112
+ class UkSanctionsList < Base
113
+ extend T::Sig
114
+
115
+ key :uk_sanctions_list
116
+ jurisdiction :uk
117
+ authority "Foreign, Commonwealth and Development Office"
118
+ format :xml
119
+
120
+ # Crown copyright under the Open Government Licence v3.0, which is the
121
+ # most permissive of the seven: reuse for any purpose, commercial
122
+ # included, on an attribution condition.
123
+ licence_notice "Crown copyright, reusable under the Open Government " \
124
+ "Licence v3.0 with attribution. Verified 2026-09-11."
125
+ licence_url "https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/"
126
+
127
+ # Static since January 2026, and the FCDO says so out loud: the URL for
128
+ # each format stays the same however many times the list is refreshed.
129
+ url :main, "https://sanctionslist.fcdo.gov.uk/docs/UK-Sanctions-List.xml"
130
+
131
+ # @api private
132
+ DESIGNATION = T.let("Designation", String)
133
+
134
+ # The FCDO's own generation date, which it publishes as an element
135
+ # sibling of the records rather than as an attribute of the document.
136
+ # Named as a record so that the one pass over the payload reads it on the
137
+ # way past -- it is the first child of `<Designations>` -- instead of the
138
+ # adapter parsing 21.8 MB twice to answer which version this was.
139
+ #
140
+ # @api private
141
+ GENERATED_AT = T.let("DateGenerated", String)
142
+
143
+ # @api private
144
+ LIST = T.let(Parsers::XmlRecords.new(records: [DESIGNATION, GENERATED_AT]), Parsers::XmlRecords)
145
+
146
+ # Records that could not be used, and fields that could not be read.
147
+ # Read after #parse; sync orchestration (#34) reports them.
148
+ sig { returns(T::Array[Parsers::Warning]) }
149
+ attr_reader :warnings
150
+
151
+ sig { params(args: T.untyped, options: T.untyped).void }
152
+ def initialize(*args, **options)
153
+ super
154
+ @warnings = T.let([], T::Array[Parsers::Warning])
155
+ @unmapped = T.let([], T::Array[Parsers::Warning])
156
+ @generated_at = T.let(nil, T.nilable(String))
157
+ end
158
+
159
+ sig { override.params(raw: T.untyped).returns(T::Array[Entity]) }
160
+ def parse(raw)
161
+ reader = LIST.read(raw)
162
+ entities = build(reader)
163
+ @warnings = reader.warnings + @unmapped
164
+ entities
165
+ end
166
+
167
+ # `07/09/2026` -- the date the FCDO stamps on the document and shows on
168
+ # its own download page, which is the string an examiner asking which
169
+ # version a decision was made against will recognise. Falls back to
170
+ # Last-Modified for a payload handed straight to #snapshot.
171
+ sig { override.returns(T.nilable(String)) }
172
+ def source_version = @generated_at || super
173
+
174
+ private
175
+
176
+ sig { params(reader: Parsers::XmlRecords::Reader).returns(T::Array[Entity]) }
177
+ def build(reader)
178
+ @unmapped = []
179
+ @generated_at = nil
180
+ reader.filter_map do |node|
181
+ # `filter_map` keeps a truthy block value, so the generation date has
182
+ # to be captured and then explicitly not returned as a record.
183
+ if node.name == GENERATED_AT
184
+ @generated_at ||= node.text
185
+ next nil
186
+ end
187
+
188
+ record = Record.new(node)
189
+ entity = record.entity
190
+ entity.nil? ? note_nameless(node) : entity
191
+ end
192
+ end
193
+
194
+ # A record with no name cannot be screened against and is never what the
195
+ # FCDO meant to publish. Every one of the 6,334 published today carries a
196
+ # name; 26 `<Name>` elements are empty of every part, which Record drops
197
+ # on their own. The warning exists so that the day a whole designation
198
+ # arrives nameless, it is visible rather than absent.
199
+ sig { params(node: Parsers::XmlRecords::Record).returns(NilClass) }
200
+ def note_nameless(node)
201
+ @unmapped << Parsers::Warning.new(
202
+ line: node.line,
203
+ message: "<#{node.name}> #{node["UniqueID"].inspect} has no name and was skipped"
204
+ )
205
+ nil
206
+ end
207
+ end
208
+ end
209
+ end
210
+
211
+ require "active_sanction/sources/uk_sanctions_list/published_date"
212
+ require "active_sanction/sources/uk_sanctions_list/record"
213
+
214
+ ActiveSanction::Sources.register(ActiveSanction::Sources::UkSanctionsList)