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,282 @@
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/identifier"
9
+ require "active_sanction/partial_date"
10
+ require "active_sanction/parsers"
11
+ require "active_sanction/sources/remarks"
12
+ require "active_sanction/sources/canada_sema/source_ref"
13
+
14
+ module ActiveSanction
15
+ module Sources
16
+ class CanadaSema < Base
17
+ # One `<record>` turned into an Entity.
18
+ #
19
+ # Separate from the adapter because they are two jobs: the adapter says
20
+ # what the list is and where it lives, and this says what Global Affairs'
21
+ # elements mean. The mapping is where all the judgment sits, so it is
22
+ # worth being able to read it on its own.
23
+ #
24
+ # Every element is optional and the file has no nesting, so which of the
25
+ # three record shapes a `<record>` is has to be read off which elements
26
+ # it happens to carry. See #type.
27
+ #
28
+ # @api private
29
+ class Record
30
+ extend T::Sig
31
+
32
+ COUNTRY = T.let("Country-Pays", String)
33
+ LAST_NAME = T.let("LastName-NomDeFamille", String)
34
+ GIVEN_NAME = T.let("GivenName-Prenom", String)
35
+ ENTITY_OR_SHIP = T.let("EntityOrShip-EntiteOuNavire", String)
36
+ ALIASES = T.let("Aliases-Alias", String)
37
+ TITLE_OR_SHIP_TYPE = T.let("TitleOrShipType-TitreOuTypeDeNavire", String)
38
+ IMO = T.let("ShipIMONumber-NumeroOMIDuNavire", String)
39
+ BORN_OR_BUILT = T.let("DateOfBirthOrShipBuildDate-DateDeNaissanceOuDateDeConstructionDuNavire", String)
40
+ SCHEDULE = T.let("Schedule-Annexe", String)
41
+ ITEM = T.let("Item-NumeroDarticle", String)
42
+ LISTED_ON = T.let("DateOfListing-DateDinscription", String)
43
+
44
+ # The bilingual separator, which is not one string. Countries pair on
45
+ # ` / ` and vessel types and titles pair on `|`, which the publisher
46
+ # writes with a space on neither, either or both sides and sometimes
47
+ # with a line break inside the half either side of it. The English half
48
+ # is the one before the first separator; whichever separator comes
49
+ # first in the string is the one that split it.
50
+ BILINGUAL = T.let(%r{\s*\|\s*|\s+/\s+}, Regexp)
51
+
52
+ # The one separator in the alias field that means what it looks like.
53
+ # See CanadaSema's class comment for why the comma is left alone.
54
+ ALIAS_SEPARATOR = T.let(";", String)
55
+
56
+ # Nine of the 2,598 published dates are two candidate dates rather than
57
+ # one -- `1972-08-26 or 1974-05-31`, `1987/1988` -- which is exactly
58
+ # what `Entity#dates_of_birth` is plural for. Only consulted after the
59
+ # whole string has failed to parse, so `1963-1964` stays the span the
60
+ # publisher wrote and no ISO date is ever split on its own separator.
61
+ ALTERNATIVES = T.let(%r{\s+or\s+|\s*/\s*}i, Regexp)
62
+
63
+ # Every element carrying a name or a name-like string arrives padded
64
+ # with whitespace the publisher did not mean, and 764 of those pads are
65
+ # U+00A0 rather than a space -- which `String#strip` does not touch, so
66
+ # `Premier ` would reach the matcher as a name nothing types.
67
+ WHITESPACE = T.let(/[[:space:]]+/, Regexp)
68
+
69
+ # For comparing two names the publisher wrote with different
70
+ # punctuation, which is the only thing that makes an alias a duplicate
71
+ # of the primary name rather than a second name.
72
+ INSIGNIFICANT = T.let(/[^[:alnum:]]+/, Regexp)
73
+
74
+ sig { returns(Parsers::XmlRecords::Record) }
75
+ attr_reader :node
76
+
77
+ # Fields of this record that could not be read. Collected by the
78
+ # adapter after #entity -- see CanadaSema#build.
79
+ sig { returns(T::Array[Parsers::Warning]) }
80
+ attr_reader :warnings
81
+
82
+ sig { params(node: Parsers::XmlRecords::Record).void }
83
+ def initialize(node)
84
+ @node = T.let(node, Parsers::XmlRecords::Record)
85
+ @warnings = T.let([], T::Array[Parsers::Warning])
86
+ @source_ref = T.let(nil, T.nilable(String))
87
+ @names = T.let(nil, T.nilable(T::Array[Name]))
88
+ @primary_name = T.let(nil, T.nilable(String))
89
+ @dates_of_birth = T.let(nil, T.nilable(T::Array[PartialDate]))
90
+ end
91
+
92
+ # The entity, or nil for a record with no name in any of its three name
93
+ # elements -- which cannot be screened against and is never what Global
94
+ # Affairs meant to publish.
95
+ sig { returns(T.nilable(Entity)) }
96
+ def entity
97
+ return nil if names.empty?
98
+
99
+ Entity.new(source: :canada_sema, source_ref: source_ref, type: type, names: names,
100
+ identifiers: identifiers, dates_of_birth: dates_of_birth,
101
+ programs: programs, listed_on: listed_on, remarks: remarks)
102
+ end
103
+
104
+ # An IMO number is only ever published for a ship, and a ship's name is
105
+ # published in the same element an organization's is, so the number is
106
+ # what separates them. The 1,445 records naming an entity with no IMO
107
+ # number are called organizations: some of them may be ships the
108
+ # publisher gave no number for, and nothing in the file says which.
109
+ sig { returns(Symbol) }
110
+ def type
111
+ return :vessel unless node.null?(IMO)
112
+
113
+ node.null?(ENTITY_OR_SHIP) ? :individual : :organization
114
+ end
115
+
116
+ # Derived, because Canada publishes no id at all. See SourceRef.
117
+ sig { returns(String) }
118
+ def source_ref
119
+ @source_ref ||= SourceRef.for(country: node[COUNTRY], schedule: node[SCHEDULE],
120
+ item: node[ITEM], name: primary_name)
121
+ end
122
+
123
+ # The primary name, then every alias the publisher separated
124
+ # unambiguously, minus any that is only the primary name repunctuated.
125
+ sig { returns(T::Array[Name]) }
126
+ def names
127
+ @names ||= build_names
128
+ end
129
+
130
+ # `Country-Pays` is not a nationality and must never be read as one:
131
+ # a Ukrainian official listed under the Special Economic Measures
132
+ # (Russia) Regulations is published under `Russia / Russie`. What the
133
+ # element names is the regulation the person is listed by, which is a
134
+ # sanctions program -- and for 80 of the records it says so outright,
135
+ # naming the Justice for Victims of Corrupt Foreign Officials
136
+ # Regulations rather than a country at all.
137
+ sig { returns(T::Array[String]) }
138
+ def programs = [english(node[COUNTRY])].compact
139
+
140
+ sig { returns(T.nilable(PartialDate)) }
141
+ def listed_on = PartialDate.parse(node[LISTED_ON])
142
+
143
+ # Empty for a vessel: the element it would come from is the ship's
144
+ # build date, and a hull laid down in 1980 has not got a date of birth.
145
+ sig { returns(T::Array[PartialDate]) }
146
+ def dates_of_birth
147
+ return [] if type == :vessel
148
+
149
+ @dates_of_birth ||= published_dates
150
+ end
151
+
152
+ # A ship's IMO number, which is permanent, unique and assigned by the
153
+ # IMO rather than by an owner -- so it is by far the most decisive
154
+ # thing this list publishes about anything. Filed as a registration
155
+ # number, which is what it is, with the note saying whose.
156
+ sig { returns(T::Array[Identifier]) }
157
+ def identifiers
158
+ return [] if node.null?(IMO)
159
+
160
+ [Identifier.new(kind: :registration_number, value: collapse(node[IMO]), note: "IMO number")]
161
+ rescue ArgumentError
162
+ []
163
+ end
164
+
165
+ # Canada publishes no free text of its own -- there is no comment or
166
+ # remarks element anywhere in the file -- so everything here is behind
167
+ # the marker, and `Remarks.published` on a Canadian record is correctly
168
+ # nil. What is kept is the citation the id was derived from, which is
169
+ # what an examiner needs to look a listing up in the Gazette, and both
170
+ # halves of every bilingual value verbatim.
171
+ sig { returns(T.nilable(String)) }
172
+ def remarks = Remarks.build(nil, extras)
173
+
174
+ private
175
+
176
+ sig { returns(T::Array[Name]) }
177
+ def build_names
178
+ primary = primary_name
179
+ return [] if primary.nil?
180
+
181
+ names = [Name.new(value: primary, kind: :primary)]
182
+ alias_values.each do |value|
183
+ next if names.any? { |name| comparable(name.value) == comparable(value) }
184
+
185
+ names << Name.new(value: value, kind: :aka)
186
+ end
187
+ names
188
+ end
189
+
190
+ # Canada files a person under surname then given names, which joined in
191
+ # that order reads "Balaba Dmitry Vladimirovich" -- not how anyone types
192
+ # a name into a screening form. The parts are joined the way they are
193
+ # spoken instead; the publisher's filing order is not lost, because the
194
+ # two elements are what the remark records the citation against.
195
+ sig { returns(T.nilable(String)) }
196
+ def primary_name
197
+ @primary_name ||= collapse(node[ENTITY_OR_SHIP]) ||
198
+ collapse([node[GIVEN_NAME], node[LAST_NAME]].compact.join(" "))
199
+ end
200
+
201
+ sig { returns(T::Array[String]) }
202
+ def alias_values
203
+ collapse(node[ALIASES]).to_s.split(ALIAS_SEPARATOR).filter_map { |value| collapse(value) }
204
+ end
205
+
206
+ sig { params(value: String).returns(String) }
207
+ def comparable(value) = value.gsub(INSIGNIFICANT, "").downcase
208
+
209
+ # A date the whole string reads as, or the two candidates it reads as
210
+ # when split. Anything else is kept verbatim in the remark and warned
211
+ # about rather than dropped: `born in the early 1970s` is real signal
212
+ # that this class has no shape for.
213
+ sig { returns(T::Array[PartialDate]) }
214
+ def published_dates
215
+ raw = collapse(node[BORN_OR_BUILT])
216
+ return [] if raw.nil?
217
+
218
+ whole = PartialDate.parse(raw)
219
+ return [whole] unless whole.nil?
220
+
221
+ alternatives(raw)
222
+ end
223
+
224
+ sig { params(raw: String).returns(T::Array[PartialDate]) }
225
+ def alternatives(raw)
226
+ dates = raw.split(ALTERNATIVES).filter_map { |part| PartialDate.parse(part) }
227
+ note_unreadable(raw) if dates.empty?
228
+ dates
229
+ end
230
+
231
+ sig { params(raw: String).void }
232
+ def note_unreadable(raw)
233
+ @warnings << Parsers::Warning.new(
234
+ line: node.line,
235
+ message: "#{primary_name.inspect} has a date this parser does not read (#{raw.inspect}); " \
236
+ "it was kept in remarks instead"
237
+ )
238
+ end
239
+
240
+ # Label/value pairs for Remarks.build, which drops the ones the record
241
+ # left blank. The bilingual values go in whole: the English half is
242
+ # what the canonical fields carry, and the remark is where the French
243
+ # the publisher wrote survives. Only the publisher's own line wrapping
244
+ # is taken out -- sixteen vessel types are published with a newline
245
+ # mid-phrase, and a remark is a line in a report.
246
+ sig { returns(T::Array[T.untyped]) }
247
+ def extras
248
+ [["Country", node[COUNTRY]], ["Schedule", node[SCHEDULE]], ["Item", node[ITEM]],
249
+ [title_label, node[TITLE_OR_SHIP_TYPE]], *date_extras]
250
+ .map { |label, value| [label, collapse(value)] }
251
+ end
252
+
253
+ # One element, two meanings, split by what the record is -- the same
254
+ # split the date element needs, for the same reason.
255
+ sig { returns(String) }
256
+ def title_label = type == :vessel ? "Vessel type" : "Title"
257
+
258
+ # A build year for a ship, and for anyone else the date string only if
259
+ # nothing could be read from it, so a date already in `dates_of_birth`
260
+ # is not also printed here.
261
+ sig { returns(T::Array[T.untyped]) }
262
+ def date_extras
263
+ return [["Built", node[BORN_OR_BUILT]]] if type == :vessel
264
+ return [] if dates_of_birth.any?
265
+
266
+ [["Date of birth", node[BORN_OR_BUILT]]]
267
+ end
268
+
269
+ # The English half of a value the publisher wrote in both languages,
270
+ # and the whole of one it wrote in only one.
271
+ sig { params(value: T.untyped).returns(T.nilable(String)) }
272
+ def english(value) = collapse(value.to_s.split(BILINGUAL, 2).first)
273
+
274
+ sig { params(value: T.untyped).returns(T.nilable(String)) }
275
+ def collapse(value)
276
+ string = value.to_s.split(WHITESPACE).join(" ")
277
+ string.empty? ? nil : -string
278
+ end
279
+ end
280
+ end
281
+ end
282
+ end
@@ -0,0 +1,89 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "digest"
7
+
8
+ module ActiveSanction
9
+ module Sources
10
+ class CanadaSema < Base
11
+ # The identity Canada does not publish, derived so that the same record
12
+ # in two syncs is the same record.
13
+ #
14
+ # SourceRef.for(country: "Russia / Russie", schedule: "1, Part 1",
15
+ # item: "1114", name: "Kirill Alekseevich MORDASHOV")
16
+ #
17
+ # ### Why anything has to be derived at all
18
+ #
19
+ # Global Affairs publishes no id. What it publishes is a citation --
20
+ # which regulation, which schedule, which item -- and #35 diffs two syncs
21
+ # by comparing records under their ids, so without one every sync reports
22
+ # the entire list as removed and re-added, and a diff that says
23
+ # everything changed says nothing at all.
24
+ #
25
+ # ### What goes into it, and the one part that is not obvious
26
+ #
27
+ # The citation alone -- country, schedule, item -- is already unique
28
+ # across all 5,690 published records, so on the face of it the name is
29
+ # redundant. It is in the hash anyway, because of what happens when a
30
+ # schedule is amended.
31
+ #
32
+ # Item numbers are positions in a list, not identifiers: delete item 5
33
+ # from a schedule and everything after it moves up one. Hashing the
34
+ # citation alone would then hand item 6's old id to the person who used
35
+ # to be item 7, and the diff would report that one person quietly changed
36
+ # their name -- which is the same shape as a correction and reads as one.
37
+ # With the name in the hash, that amendment reports as a removal and an
38
+ # addition, which is noisier and true.
39
+ #
40
+ # The cost runs the other way: correcting a typo in a published name
41
+ # re-ids that record, so a spelling fix reads as one person leaving and
42
+ # another arriving. Churn in a diff is a nuisance; one id covering two
43
+ # different people is a screening failure, so the trade goes this way.
44
+ #
45
+ # ### Stability
46
+ #
47
+ # The digest is taken over the normalized parts joined by a separator
48
+ # that cannot occur in any of them, so an id depends on nothing but the
49
+ # record's own bytes -- not on iteration order, not on position in the
50
+ # file, not on the run. Changing NORMALIZE, SEPARATOR or LENGTH re-ids
51
+ # every Canadian record ever stored, which makes each of them a versioned
52
+ # decision rather than a cleanup.
53
+ #
54
+ # @api private
55
+ module SourceRef
56
+ extend T::Sig
57
+
58
+ # Case and the publisher's stray padding are noise: `Venezuela ` and
59
+ # `1, Part 1 ` are published both with and without their trailing
60
+ # space, and a record must not change id when a space does. Nothing
61
+ # further is folded -- not punctuation, not diacritics -- because every
62
+ # additional fold is another way for two genuinely different records to
63
+ # collide into one id.
64
+ NORMALIZE = T.let(/[[:space:]]+/, Regexp)
65
+
66
+ # A NUL cannot appear in XML character data at all, so no two different
67
+ # sets of field values can be re-parenthesized into each other:
68
+ # ("a", "bc") and ("ab", "c") hash apart.
69
+ SEPARATOR = T.let("\u0000", String)
70
+
71
+ # 64 bits of SHA-256. Across 5,690 records the chance of any collision
72
+ # at all is about one in a trillion, and an id this length stays
73
+ # readable in a log line and in the report that quotes it.
74
+ LENGTH = T.let(16, Integer)
75
+
76
+ module_function
77
+
78
+ sig { params(country: T.untyped, schedule: T.untyped, item: T.untyped, name: T.untyped).returns(String) }
79
+ def for(country:, schedule:, item:, name:)
80
+ parts = [country, schedule, item, name].map { |part| normalize(part) }
81
+ -T.must(Digest::SHA256.hexdigest(parts.join(SEPARATOR))[0, LENGTH])
82
+ end
83
+
84
+ sig { params(value: T.untyped).returns(String) }
85
+ def normalize(value) = value.to_s.split(NORMALIZE).join(" ").downcase
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,153 @@
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
+ # Canada's consolidated sanctions list: everyone named in a schedule to a
13
+ # regulation made under the Special Economic Measures Act, plus the people
14
+ # listed under the Justice for Victims of Corrupt Foreign Officials Act.
15
+ #
16
+ # snapshot = ActiveSanction::Sources[:canada_sema].new.sync
17
+ #
18
+ # ### One file, one flat record shape
19
+ #
20
+ # https://www.international.gc.ca/.../sanctions/sema-lmes.xml 2.9 MB
21
+ #
22
+ # <data-set><record> 5,690 records
23
+ #
24
+ # Every record is the same eleven optional elements with no nesting, which
25
+ # makes this the simplest list the gem reads and the hardest one to give a
26
+ # stable identity to.
27
+ #
28
+ # ### The three things that make this source awkward
29
+ #
30
+ # **No stable identifier.** Global Affairs publishes no id of any kind.
31
+ # What it publishes is where in the law a person appears -- the schedule
32
+ # and the item number within it -- which is scoped per regulation and is
33
+ # renumbered whenever a schedule is amended. Record derives a deterministic
34
+ # synthetic id from it; see Record::SOURCE_REF_PARTS for what goes in and
35
+ # why the name is part of it.
36
+ #
37
+ # **Bilingual tags and values.** Element names pair English and French
38
+ # (`Country-Pays`), and so do the values in three of them. The separator is
39
+ # not one string: countries use ` / ` (`Belarus / Bélarus`) and vessel
40
+ # types and titles use `|` (`Oil Tanker | Navire-citerne`), sometimes with
41
+ # no space and sometimes wrapped across a line. Only those three elements
42
+ # are split; a name is never split, because `Islamic Revolutionary Guard
43
+ # Corps/Corps des Gardiens de la Révolution islamique` and `Victory/Pobeda
44
+ # Political Bloc` are the same punctuation meaning two different things and
45
+ # nothing in the file separates them.
46
+ #
47
+ # **The date element doubles as a ship's build date.** It is named
48
+ # `DateOfBirthOrShipBuildDate-...` and means whichever the record is. A
49
+ # build year is not a date of birth, so a vessel's goes to remarks and its
50
+ # `dates_of_birth` stays empty.
51
+ #
52
+ # ### What a clean Canadian result is worth
53
+ #
54
+ # Less than a clean OFAC one, and a screening policy should know it. Canada
55
+ # publishes no nationality, no address, no place of birth and no document
56
+ # number for any of the 5,690 records: an individual is a surname, given
57
+ # names, a date of birth roughly half the time, and a free-text alias
58
+ # field. There is nothing here to make a name match decisive with, which is
59
+ # the opposite of OFAC, where a passport number usually settles it.
60
+ #
61
+ # ### Aliases, and the comma this adapter refuses to split on
62
+ #
63
+ # 3,195 records carry an `Aliases-Alias` element, which is free text with
64
+ # no declared separator. Semicolons are unambiguous and are split on. Commas
65
+ # are not, and splitting on them manufactures names that match far too much:
66
+ # `Завод "Дагдизель", АО` and `М Инвест, ООО` would each yield a bare
67
+ # Russian legal form as an alias, and `Министерство образования, науки и
68
+ # молодежи Республики Крым` is one ministry, not two. So a comma-joined
69
+ # alias field stays one alias. That costs recall on roughly 300 records
70
+ # whose primary name is published anyway, and it is the cheaper of the two
71
+ # mistakes.
72
+ class CanadaSema < Base
73
+ extend T::Sig
74
+
75
+ key :canada_sema
76
+ jurisdiction :ca
77
+ authority "Global Affairs Canada"
78
+ format :xml
79
+
80
+ # The one that most often surprises people, and the reason this field
81
+ # exists at all. Global Affairs permits reproduction for
82
+ # non-commercial purposes without charge or further permission, and
83
+ # requires prior written permission for commercial redistribution --
84
+ # so a hosted service reselling this list is in a different position
85
+ # from an application screening against it in its own process.
86
+ licence_notice "Reproducible for non-commercial purposes without " \
87
+ "charge or further permission, with the source " \
88
+ "identified. Commercial redistribution requires prior " \
89
+ "written permission from Global Affairs Canada. " \
90
+ "Verified 2026-09-11."
91
+ licence_url "https://www.international.gc.ca/gac-amc/notices-avis.aspx?lang=eng"
92
+
93
+ url :main,
94
+ "https://www.international.gc.ca/world-monde/assets/office_docs/" \
95
+ "international_relations-relations_internationales/sanctions/sema-lmes.xml"
96
+
97
+ # @api private
98
+ RECORD = T.let("record", String)
99
+
100
+ # @api private
101
+ LIST = T.let(Parsers::XmlRecords.new(records: RECORD), Parsers::XmlRecords)
102
+
103
+ # Records that could not be used, and fields that could not be read.
104
+ # Read after #parse; sync orchestration (#34) reports them.
105
+ sig { returns(T::Array[Parsers::Warning]) }
106
+ attr_reader :warnings
107
+
108
+ sig { params(args: T.untyped, options: T.untyped).void }
109
+ def initialize(*args, **options)
110
+ super
111
+ @warnings = T.let([], T::Array[Parsers::Warning])
112
+ @unmapped = T.let([], T::Array[Parsers::Warning])
113
+ end
114
+
115
+ sig { override.params(raw: T.untyped).returns(T::Array[Entity]) }
116
+ def parse(raw)
117
+ reader = LIST.read(raw)
118
+ entities = build(reader)
119
+ @warnings = reader.warnings + @unmapped
120
+ entities
121
+ end
122
+
123
+ private
124
+
125
+ sig { params(reader: Parsers::XmlRecords::Reader).returns(T::Array[Entity]) }
126
+ def build(reader)
127
+ @unmapped = []
128
+ reader.filter_map do |node|
129
+ record = Record.new(node)
130
+ entity = record.entity
131
+ @unmapped.concat(record.warnings)
132
+ entity.nil? ? note_nameless(node) : entity
133
+ end
134
+ end
135
+
136
+ # A record with no name in any of its three name elements cannot be
137
+ # screened against. None of the 5,690 published today is nameless; the
138
+ # warning exists so that the day one is, it is visible rather than absent.
139
+ sig { params(node: Parsers::XmlRecords::Record).returns(NilClass) }
140
+ def note_nameless(node)
141
+ @unmapped << Parsers::Warning.new(
142
+ line: node.line,
143
+ message: "<#{node.name}> at item #{node[Record::ITEM].inspect} has no name and was skipped"
144
+ )
145
+ nil
146
+ end
147
+ end
148
+ end
149
+ end
150
+
151
+ require "active_sanction/sources/canada_sema/record"
152
+
153
+ ActiveSanction::Sources.register(ActiveSanction::Sources::CanadaSema)