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,1083 @@
1
+ # Adding a source
2
+
3
+ A source adapter is the only part of this library that knows what a particular
4
+ government publishes. It declares what the list is and where it lives, and it
5
+ turns that publisher's bytes into `ActiveSanction::Entity` objects. Everything
6
+ either side of that — conditional GET, the payload cache, checksumming the
7
+ result into a `Snapshot`, storage, and eventually the matcher — is written
8
+ against the canonical model and never against a list, so adding a jurisdiction
9
+ is a parsing problem and not a plumbing one.
10
+
11
+ ```ruby
12
+ class UnConsolidated < ActiveSanction::Sources::Base
13
+ key :un_consolidated
14
+ jurisdiction :un
15
+ authority "United Nations Security Council"
16
+ format :xml
17
+ url :main, "https://scsanctions.un.org/resources/xml/en/consolidated.xml"
18
+
19
+ def parse(raw)
20
+ ... # => [Entity, ...]
21
+ end
22
+ end
23
+
24
+ ActiveSanction::Sources.register(UnConsolidated)
25
+ ```
26
+
27
+ `#parse` is the whole of what you must write. This document is the rest: how to
28
+ decide what goes in it, and what the surrounding checklist is.
29
+
30
+ It applies whether the adapter lands in this gem or in your own application —
31
+ the registry is open and duck-typed, and [section 9](#9-register-it) covers
32
+ both.
33
+
34
+ ## Contents
35
+
36
+ 1. [Read the closest existing adapter first](#1-read-the-closest-existing-adapter-first)
37
+ 2. [Look at what the publisher actually serves](#2-look-at-what-the-publisher-actually-serves)
38
+ 3. [Declare the list](#3-declare-the-list)
39
+ 4. [Choose how to read it](#4-choose-how-to-read-it)
40
+ 5. [Map the publisher's fields onto the canonical model](#5-map-the-publishers-fields-onto-the-canonical-model)
41
+ 6. [Give every record a stable id](#6-give-every-record-a-stable-id)
42
+ 7. [Report what you could not read](#7-report-what-you-could-not-read)
43
+ 8. [Capture a fixture and wire up the conformance spec](#8-capture-a-fixture-and-wire-up-the-conformance-spec)
44
+ 9. [Register it](#9-register-it)
45
+ 10. [Typed, linted, and green](#10-typed-linted-and-green)
46
+ 11. [A worked example, end to end](#a-worked-example-end-to-end)
47
+ 12. [Checklist](#checklist)
48
+
49
+ ## 1. Read the closest existing adapter first
50
+
51
+ There is no scaffold generator, deliberately. This project will have roughly
52
+ eight adapters at maturity, and a generator maintained for that many uses has
53
+ to be kept in step with every change to `Sources::Base`, the conformance spec
54
+ and the parser toolkits — and goes stale silently when it is not. An existing
55
+ adapter to copy does the same job with none of the upkeep.
56
+
57
+ Pick the one whose *shape* matches your publisher's, not the one whose
58
+ jurisdiction is nearest:
59
+
60
+ | The publisher serves | Copy from | Its spec |
61
+ | --- | --- | --- |
62
+ | One delimited file, nothing unusual | `spec/support/contract_example_source.rb` — a whole adapter in twenty lines | `spec/active_sanction/sources/conformance_spec.rb` |
63
+ | Several delimited files that only mean something joined | `lib/active_sanction/sources/ofac.rb`, `ofac_sdn.rb` | `spec/active_sanction/sources/ofac_sdn_spec.rb` |
64
+ | One XML document, one flat record shape | `lib/active_sanction/sources/canada_sema.rb` | `spec/active_sanction/sources/canada_sema_spec.rb` |
65
+ | One XML document, several record shapes | `lib/active_sanction/sources/un_consolidated.rb` | `spec/active_sanction/sources/un_consolidated_spec.rb` |
66
+ | One XML document, tens of megabytes, data in attributes | `lib/active_sanction/sources/eu_fsf.rb` | `spec/active_sanction/sources/eu_fsf_spec.rb` |
67
+ | One XML document, deeply nested containers, data in elements | `lib/active_sanction/sources/uk_sanctions_list.rb` | `spec/active_sanction/sources/uk_sanctions_list_spec.rb` |
68
+ | One spreadsheet, one row per name, records joined on a reference | `lib/active_sanction/sources/australia_dfat.rb` | `spec/active_sanction/sources/australia_dfat_spec.rb` |
69
+ | Two lists from one publisher in one format | `lib/active_sanction/sources/ofac.rb` holds the reading; `ofac_sdn.rb` and `ofac_consolidated.rb` declare only which list they are | |
70
+
71
+ Each of those files opens with a class comment describing the list, its quirks,
72
+ and what the adapter refuses to do about them. That comment is part of the
73
+ deliverable, not decoration: it is where the next person finds out why Canadian
74
+ aliases are not split on commas without having to reconstruct the argument from
75
+ the code.
76
+
77
+ Note the two-file convention in the XML adapters. `canada_sema.rb` says what the
78
+ list is and drives the read; `canada_sema/record.rb` says what the publisher's
79
+ elements *mean*. They are separated because they are two jobs, and all the
80
+ judgment sits in the second one. A mapping small enough to read at a glance —
81
+ the contract example, a five-column CSV — does not need the split.
82
+
83
+ ## 2. Look at what the publisher actually serves
84
+
85
+ Before writing a line, get the bytes and answer eight questions about them.
86
+ Every one of them changes the adapter, and every one of them is cheaper to
87
+ answer now than after a fixture has been committed.
88
+
89
+ ```bash
90
+ curl -sSL -o /tmp/list.xml "https://example.gov/sanctions/list.xml"
91
+ file /tmp/list.xml && wc -c /tmp/list.xml
92
+ ```
93
+
94
+ 1. **What encoding is it, really?** OFAC serves Windows-1252 and says nothing
95
+ about it in a header; the UN serves UTF-8 and says nothing either. Read as
96
+ the wrong one, thousands of accented names arrive as replacement characters.
97
+ The adapter states the encoding; it does not guess.
98
+ 2. **What does the publisher write where it means nothing?** OFAC writes `-0- `
99
+ — with a trailing space — roughly a quarter of a million times. Blank and
100
+ all-whitespace are already nil; a sentinel has to be declared.
101
+ 3. **Is there an identifier, and is it stable?** See
102
+ [section 6](#6-give-every-record-a-stable-id). This is the question most
103
+ likely to cost you a rewrite.
104
+ 4. **How many records, and of what kinds?** Count them. The counts belong in the
105
+ class comment, because a comment saying "736 individuals, 275 organizations"
106
+ is checkable against the file and a comment saying "some people and some
107
+ organizations" is not.
108
+ 5. **What separates repeated values?** Semicolons are usually unambiguous.
109
+ Commas usually are not — see the alias discussion in `canada_sema.rb` for
110
+ what splitting on an ambiguous separator manufactures.
111
+ 6. **Which fields have no home in `Entity`?** They go into `remarks` behind the
112
+ `[source fields]` marker, never on the floor.
113
+ 7. **Does one element or column mean two different things?** The UN's `QUALITY`
114
+ grades an alias under `<INDIVIDUAL_ALIAS>` and names the alias *kind* under
115
+ `<ENTITY_ALIAS>`. Canada's date element is a date of birth or a ship's build
116
+ date depending on the record. These are the bugs that produce a list which
117
+ parses cleanly and is wrong.
118
+ 8. **Does the document carry its own version marker?** The UN stamps
119
+ `dateGenerated` on the root element. If yours does, override
120
+ `#source_version` to return it — it is the string an examiner asking "which
121
+ version was this screened against" will recognise, and it is more precise
122
+ than the `Last-Modified` header `Base` falls back to.
123
+
124
+ ## 3. Declare the list
125
+
126
+ Declarations come from `Sources::Definition`, extended into `Sources::Base`, so
127
+ an adapter's class body reads as a description of the list:
128
+
129
+ ```ruby
130
+ key :ofac_sdn # required, never inherited
131
+ jurisdiction :us # required
132
+ authority "U.S. Department of the Treasury, Office of Foreign Assets Control"
133
+ format :csv # optional, informational
134
+
135
+ url :sdn, "https://sanctionslistservice.ofac.treas.gov/api/download/SDN.CSV"
136
+ url :alt, "https://sanctionslistservice.ofac.treas.gov/api/download/ALT.CSV"
137
+ url :add, "https://sanctionslistservice.ofac.treas.gov/api/download/ADD.CSV"
138
+ ```
139
+
140
+ - **`key`** is the public name of the list: what `config.sources` names, what a
141
+ stored snapshot is filed under, what the payload cache uses as a directory
142
+ name, and what a match result cites. Lowercase `snake_case`, and it is never
143
+ inherited — a subclass silently taking its parent's key would try to register
144
+ under a name already taken.
145
+ - **`authority`** is what a compliance report prints beside a hit, so spell it
146
+ the way the body spells itself: `"United Nations Security Council"`, not
147
+ `"UN"`.
148
+ - **`format`** is a label a CLI prints. It is not dispatched on and is not
149
+ checked against a list of known formats, so a source arriving as
150
+ `:fixed_width` can say so.
151
+ - **`url`** is declared once per file. Each is fetched, validated and cached
152
+ independently, because they change independently: a sync in which only
153
+ `ALT.CSV` moved should download only `ALT.CSV`. A source declaring several
154
+ files has each filed under `"#{key}-#{name}"` in the cache; one declaring a
155
+ single file is filed under the key itself.
156
+
157
+ Everything except `key` resolves up the superclass chain, so two lists from one
158
+ publisher share a base that holds the jurisdiction, the authority and the format
159
+ — that is exactly what `Sources::Ofac` is.
160
+
161
+ A source that is not fetched over HTTP at all — a bank's internal watchlist
162
+ backed by a database table — declares no URL and overrides `#retrieve` instead.
163
+ It does not have to subclass `Base` at all; see [section 9](#9-register-it).
164
+
165
+ ### What `#parse` is handed
166
+
167
+ Whatever the declaration implies, and never what a caller happened to pass:
168
+
169
+ - one declared URL → `#parse` receives the bytes as a `String`;
170
+ - several → it receives a `Hash` keyed by the names you declared, as in
171
+ `raw[:sdn]`.
172
+
173
+ That rule is applied by `Base` before `#parse` is called and again by the
174
+ conformance spec, so an adapter's signature does not change when a fixture is
175
+ handed to `#snapshot` directly:
176
+
177
+ ```ruby
178
+ source = ActiveSanction::Sources[:un_consolidated].new
179
+ source.snapshot(File.binread("spec/fixtures/un_consolidated/consolidated.xml"))
180
+ source.sync # the same path, with the network in front of it
181
+ ```
182
+
183
+ The bytes arrive **undecoded**. An adapter reading the Windows-1252 OFAC serves
184
+ is held to doing its own decoding rather than to being handed a `String` that
185
+ somebody already fixed up — which is what the format toolkits do for you when
186
+ you tell them the encoding.
187
+
188
+ ## 4. Choose how to read it
189
+
190
+ ### There is no field-mapping DSL
191
+
192
+ A declarative `field_map` DSL was planned (#13) and deliberately not built. The
193
+ intent was to make a new list a *mapping* rather than a hand-written parser, but
194
+ every launch source has at least one quirk that needed a transform hook to
195
+ absorb — `-0-` sentinels, bilingual values, one name spread across four
196
+ elements, an id that has to be synthesized — and when the escape hatches carry
197
+ most of the weight, the abstraction has not been found. Two real adapters later
198
+ the shared part turned out to be the *format* layer, not the mapping layer.
199
+
200
+ So: `#parse` is yours to write, and the reuse lives in
201
+ `ActiveSanction::Parsers`. Nothing in `Parsers` knows about `Entity` — they are
202
+ file readers, and keeping them ignorant of the canonical model is what lets an
203
+ adapter for a list nobody here has seen reuse them.
204
+
205
+ ### `Parsers::DelimitedTable` — CSV, TSV, anything delimited
206
+
207
+ ```ruby
208
+ LIST = ActiveSanction::Parsers::DelimitedTable.new(
209
+ columns: %i[ent_num sdn_name sdn_type program title remarks],
210
+ null: "-0-",
211
+ encoding: Encoding::WINDOWS_1252
212
+ )
213
+
214
+ LIST.read(raw).each { |row| row[:sdn_name] }
215
+ ```
216
+
217
+ A table is a description of the file, built once at class-definition time and
218
+ reused for every sync; `#read` is one pass over one payload.
219
+
220
+ - **`columns:`** names a headerless file's columns *and* pins its width. If the
221
+ publisher inserts a column, every row arrives the wrong width and says so in
222
+ `#warnings` — a far better failure than 19,321 entities quietly built from
223
+ shifted fields. Pass `columns: nil` (the default) for a file that carries its
224
+ own header; the names are read from the first row, lowercased and
225
+ snake_cased, so `City/State/Province/ZIP/Postal Code` and
226
+ `city_state_province_zip_postal_code` are the same column no matter how the
227
+ publisher capitalized it this quarter.
228
+ - **`null:`** takes one sentinel or several. It is matched after stripping
229
+ surrounding whitespace, and blank fields are nil regardless.
230
+ - **`col_sep:` / `quote_char:`** for TSV and the rest.
231
+ - **`liberal_parsing:`** defaults to `true`, because an unescaped quote inside a
232
+ company name is common in published data and failing the row is the wrong
233
+ default. Turn it off where a stray quote should be loud.
234
+
235
+ A `Row` is deliberately not a `Hash`: `row[:sdn_nme]` raises rather than
236
+ answering `nil` and letting a typo look like an empty column all the way into a
237
+ snapshot. `row.fetch(:col)` is there for the genuinely optional case,
238
+ `row.null?(:col)` asks whether the publisher left it empty, and `row.line` is
239
+ the line number a warning has to cite.
240
+
241
+ ### `Parsers::XmlRecords` — record-oriented XML
242
+
243
+ ```ruby
244
+ LIST = ActiveSanction::Parsers::XmlRecords.new(records: %w[INDIVIDUAL ENTITY])
245
+
246
+ reader = LIST.read(raw)
247
+ reader.each do |record|
248
+ record.name # => "INDIVIDUAL"
249
+ record["FIRST_NAME"] # => "ERIC"
250
+ record.values("NATIONALITY/VALUE") # => ["Chad", "Sudan"]
251
+ record.nodes("INDIVIDUAL_ALIAS") # => [Record, ...]
252
+ record["@dateGenerated"] # an attribute, XPath-style
253
+ end
254
+ reader.root["dateGenerated"] # the publisher's own version marker
255
+ ```
256
+
257
+ - **It streams.** One record's depth is held on a stack and dropped as soon as
258
+ you are done with it. Every list here today is small enough to have loaded
259
+ whole, which is exactly why it does not: the adapter written against this
260
+ interface is the one that reads OFAC's 126 MB `SDN_ADVANCED.XML` later,
261
+ unchanged.
262
+ - **Name only the record elements.** Scaffolding — `<CONSOLIDATED_LIST>`,
263
+ `<INDIVIDUALS>` — is skipped rather than built into nodes nobody asked for.
264
+ Naming several is normal and gives you one pass over both shapes.
265
+ - **Namespace prefixes are stripped** before matching, so a publisher adding an
266
+ `xmlns` next quarter does not silently stop matching anything.
267
+ - **Missing, self-closing and whitespace-only elements all read as `nil`.** That
268
+ is the only reading that survives real documents: the UN files 294 placeholder
269
+ aliases as `<INDIVIDUAL_ALIAS><QUALITY/><ALIAS_NAME/></INDIVIDUAL_ALIAS>` and
270
+ means nothing at all by them. Use `record.fetch(path)` for a field you
271
+ consider mandatory — it names the record and what it does carry when the path
272
+ is missing — and `record.present` to explore an unfamiliar document from
273
+ `bin/console`.
274
+
275
+ Do not pass `backend:`. Which XML library parses a list is an installation's
276
+ decision (`config.xml_backend`), not a list's.
277
+
278
+ ### `Parsers::Spreadsheet` — an `.xlsx` workbook
279
+
280
+ ```ruby
281
+ LIST = ActiveSanction::Parsers::Spreadsheet.new(sheet: "Consolidated List")
282
+
283
+ reader = LIST.read(raw)
284
+ reader.each do |row|
285
+ row[:name_of_individual_or_entity] # => "MOHAMMAD HASSAN AKHUND"
286
+ row[:date_of_birth] # => "1962-08-24"
287
+ row.number # => 2, the sheet's own row number
288
+ end
289
+ reader.sheet_names # => ["Consolidated List"]
290
+ reader.modified # the publisher's own save time
291
+ ```
292
+
293
+ Reach for this only when the publisher offers nothing else — Australia does not,
294
+ which is why it exists. It reads the workbook with `zlib` and `Parsers::XmlRecords`,
295
+ so it adds no dependency; an `.xlsx` is a ZIP of XML parts and the only thing
296
+ missing was a ZIP header unpacker.
297
+
298
+ - **The first row is always the header** and is never yielded as a record. Its
299
+ cells name the columns by default, lowercased and snake_cased the way
300
+ `DelimitedTable` normalizes a CSV header. Passing `columns:` renames them by
301
+ position instead; the header is still consumed, because it is still a header.
302
+ - **`sheet:`** takes a name, a zero-based index, or nil for the first sheet. Name
303
+ it: a publisher adding a tab should be an error rather than a silent change of
304
+ which sheet is read.
305
+ - **A cell arrives as a String, or nil.** Shared strings are resolved, inline
306
+ strings are read, a boolean reads `TRUE`/`FALSE`, and Excel's `_x000D_` escapes
307
+ are undone.
308
+ - **A date cell arrives as ISO 8601 at the precision its format displays** —
309
+ `2026-04-14`, `1973-11`, `1975` — which `PartialDate::Parser` reads directly. A
310
+ number under any non-date format is passed through as the publisher wrote it,
311
+ which is what keeps `1958` a year rather than becoming a day in 1905. Getting
312
+ this right *requires* `xl/styles.xml`: the two cells are otherwise identical.
313
+ - **A row states only the cells it filled**, so cells are placed by their
314
+ address (`r="C7"`) and never by position. A value past the last named column is
315
+ dropped and warned about; a row with nothing in it is spacing and is skipped.
316
+ - **Formulas are not evaluated.** A formula cell reads as the value cached in it,
317
+ which is what the file displays and what an export contains. Merged cells,
318
+ comments, charts and styling are ignored, and the older binary `.xls` is a
319
+ different format that is not read.
320
+
321
+ A `Row` behaves like `DelimitedTable::Row` — `row[:typo]` raises, `row.fetch` is
322
+ the optional case, `row.null?` asks whether the cell was empty — except that it
323
+ reports `row.number`, the spreadsheet's own row number, rather than a line.
324
+
325
+ ### `Parsers::Join` — several files, one logical record
326
+
327
+ ```ruby
328
+ join = ActiveSanction::Parsers::Join.new(
329
+ on: :ent_num, aliases: ALT.read(raw[:alt]), addresses: ADD.read(raw[:add])
330
+ )
331
+
332
+ join.each(PRIMARY.read(raw[:sdn])) do |row, related|
333
+ related[:aliases] # => [Row, ...] -- always an Array, never nil
334
+ related[:addresses] # => [Row, ...]
335
+ end
336
+
337
+ join.orphans # => { aliases: 0, addresses: 0 }
338
+ ```
339
+
340
+ The primary file streams; the child files are indexed and held in memory for the
341
+ length of the join. A child row matching no primary row is dropped and counted,
342
+ and a nonzero orphan count after a sync usually means the files were downloaded
343
+ at different moments and do not describe the same version of the list — a data
344
+ problem no amount of careful parsing fixes.
345
+
346
+ ### Neither
347
+
348
+ If the publisher serves JSON, a fixed-width file, or something with no shape
349
+ worth abstracting, parse it however it needs parsing and build Entities. The
350
+ toolkits are a convenience, not an obligation, and the conformance spec does not
351
+ know or care which you used. What it does care about is the rules in sections 5
352
+ to 7, which are not optional.
353
+
354
+ ## 5. Map the publisher's fields onto the canonical model
355
+
356
+ `Entity` is the single source-agnostic record every adapter produces. Nothing
357
+ downstream — storage, the diff between two syncs, the index, the matcher, the
358
+ report an examiner reads — should ever need to know which government published a
359
+ record.
360
+
361
+ ```ruby
362
+ ActiveSanction::Entity.new(
363
+ source: :ofac_sdn, # required -- your own key
364
+ source_ref: "2674", # the publisher's own reference, if it has one
365
+ id: nil, # derived from source + source_ref when omitted
366
+ type: :individual, # individual | organization | vessel | aircraft
367
+ names: [Name, ...],
368
+ addresses: [Address, ...],
369
+ identifiers: [Identifier, ...],
370
+ dates_of_birth: [PartialDate, ...],
371
+ nationalities: ["EG"],
372
+ programs: ["SDGT"],
373
+ listed_on: PartialDate,
374
+ remarks: "..."
375
+ )
376
+ ```
377
+
378
+ Instances are frozen and compare by value. The four collection members are
379
+ declared types, so `srb tc` refuses an adapter that hands over the string a
380
+ publisher wrote where a `PartialDate` belongs.
381
+
382
+ ### The rules that are not negotiable
383
+
384
+ **Every date is a `PartialDate`, never the string it was written as.** A date
385
+ left as the publisher's string cannot be compared with one from another list,
386
+ and `PartialDate` exists because no two lists agree on how precise a date of
387
+ birth is. `PartialDate.parse` reads ISO (`1972`, `1972-04`, `1972-04-29`),
388
+ worded forms (`29 Apr 1972`, `April 29, 1972`, `Apr 1972`), approximations
389
+ (`circa 1962`, `~1962`) and spans (`between 1971 and 1973`, `1971-1973`), and
390
+ returns `nil` on anything it cannot read. A publisher writing `14/03/2019` is
391
+ outside that vocabulary and the adapter converts before parsing; it never stores
392
+ the string.
393
+
394
+ **Types are the matcher's coarsest filter.** `vessel` and `aircraft` are
395
+ first-class because they are about 10% of the OFAC SDN list and carry name-like
396
+ strings; without a distinct type, a search for a person can rank a ship. Map the
397
+ publisher's vocabulary onto the four, warn about anything you did not recognise,
398
+ and default to `:organization` rather than dropping the record.
399
+
400
+ **A record with no name is not a record.** It cannot be screened against. Return
401
+ `nil` for it and record a warning naming the publisher's reference, so the day
402
+ one appears it is visible rather than absent.
403
+
404
+ **Never drop the publisher's free text.** `Entity#remarks` keeps it verbatim.
405
+ This is where the fields the canonical model has no home for still live —
406
+ OFAC's dates of birth and passport numbers are nowhere else — and an adapter
407
+ that drops it loses data no later issue can get back.
408
+
409
+ ### Fields with no home: `Sources::Remarks`
410
+
411
+ Append them behind one shared marker rather than inventing a convention per
412
+ source:
413
+
414
+ ```ruby
415
+ Remarks.build(row[:remarks], [["Vessel flag", "Panama"], ["Tonnage", "8000"]])
416
+ # => "Registered in Panama [source fields] Vessel flag: Panama; Tonnage: 8000"
417
+
418
+ Sources::Base.published_remarks(entity.remarks) # => "Registered in Panama"
419
+ ```
420
+
421
+ `Remarks.build` drops a label whose value is blank or missing, and a value may
422
+ be an `Array` — the UN files three designations under one element.
423
+ `Remarks.published` strips everything an adapter appended back off, which is
424
+ what anything reading the remark for what the publisher *actually wrote* must
425
+ use: OFAC's remarks parser must never see a vessel flag and read it as a
426
+ nationality. The conformance spec checks that at least one record still has
427
+ publisher text after stripping, so appending is safe and swallowing is not.
428
+
429
+ ### The value objects
430
+
431
+ | Class | Notes |
432
+ | --- | --- |
433
+ | `Name` | `value:` plus `kind:` (`:primary`, `:aka`, `:fka`, `:nka` — defaults to `:primary`), `quality:` (`:good`, `:low`, or `nil` for unstated), `script:` (a closed list; map the publisher's vocabulary onto it — OFAC's "Farsi" is `:arabic`). A blank value raises: a blank-valued name is a record that matches everything. |
434
+ | `Address` | `street:`, `city:`, `state_province:`, `postal_code:`, `country:`, `note:`. An address that located nothing raises `InvalidArgument` (an `ArgumentError`); rescue and drop it rather than keeping an empty one. |
435
+ | `Identifier` | `value:` plus `kind:` (`:passport`, `:national_id`, `:tax_id`, `:registration_number`, `:other`), `country:`, `issued_on:`, `expires_on:`, `note:`. `:other` is a real answer — a document we cannot classify still matches on its number. A document element with a type and no number has nothing to match on: drop it. |
436
+ | `PartialDate` | `.parse`, `.range(from, to)`, `.new(year:, month:, day:, approximate:)`. Never collapse a year to January 1st. |
437
+
438
+ ### The mistakes this section exists to prevent
439
+
440
+ Each of these is caught by the conformance spec, but knowing why is cheaper than
441
+ reading the failure:
442
+
443
+ - one field read as another, because a publisher uses one name for two things;
444
+ - a date left as a string, which quietly cannot be compared with anything;
445
+ - free text dropped to keep the schema tidy, which loses screening signal
446
+ permanently;
447
+ - an empty payload read as a list with nobody on it — see
448
+ [section 7](#7-report-what-you-could-not-read).
449
+
450
+ ## 6. Give every record a stable id
451
+
452
+ `Entity#id` is namespaced and never nil. When you pass `source_ref:` and no
453
+ `id:`, it is derived as `"#{source}:#{source_ref}"`, which is what you want
454
+ whenever the publisher supplies a reference of its own.
455
+
456
+ The id has to satisfy two properties, and the conformance spec checks both:
457
+
458
+ - **unique within a sync** — two records under one id are one record to storage,
459
+ and the second silently replaces the first;
460
+ - **identical on a second read of the same bytes** — the snapshot diff compares
461
+ records under their ids, and an id that moves because a `Hash` iterated
462
+ differently or a counter was involved reports the whole list as removed and
463
+ re-added. A diff that says everything changed says nothing at all.
464
+
465
+ So an id may never depend on position in the file, iteration order, a counter,
466
+ the wall clock, or anything else outside the record's own bytes.
467
+
468
+ ### When the publisher supplies no id
469
+
470
+ Canada is the case. Global Affairs publishes no identifier of any kind; what it
471
+ publishes is a citation — which regulation, which schedule, which item — and
472
+ `CanadaSema::SourceRef` derives a deterministic id from it. The recipe
473
+ generalizes:
474
+
475
+ ```ruby
476
+ module SourceRef
477
+ NORMALIZE = /[[:space:]]+/
478
+ SEPARATOR = "\u0000" # cannot occur in XML character data
479
+ LENGTH = 16 # 64 bits of SHA-256
480
+
481
+ module_function
482
+
483
+ def for(country:, schedule:, item:, name:)
484
+ parts = [country, schedule, item, name].map { |part| normalize(part) }
485
+ -Digest::SHA256.hexdigest(parts.join(SEPARATOR))[0, LENGTH]
486
+ end
487
+
488
+ def normalize(value) = value.to_s.split(NORMALIZE).join(" ").downcase
489
+ end
490
+ ```
491
+
492
+ Four decisions in there are worth copying deliberately:
493
+
494
+ 1. **Hash the citation *and the name*.** The citation alone is already unique
495
+ across all 5,690 published records, so the name looks redundant — until a
496
+ schedule is amended. Item numbers are positions in a list: delete item 5 and
497
+ everything after it moves up one, and hashing the citation alone would hand
498
+ item 6's old id to the person who used to be item 7. The diff would then
499
+ report that one person quietly changed their name, which is the same shape as
500
+ a correction and reads as one. With the name in the hash that amendment
501
+ reports as a removal and an addition — noisier, and true. The cost runs the
502
+ other way: correcting a typo in a published name re-ids that record. Churn in
503
+ a diff is a nuisance; one id covering two different people is a screening
504
+ failure, so the trade goes this way.
505
+ 2. **Normalize only case and whitespace.** Canada publishes `Venezuela` both
506
+ with and without a trailing space, and a record must not change id when a
507
+ space does. Nothing further is folded — not punctuation, not diacritics —
508
+ because every additional fold is another way for two genuinely different
509
+ records to collide into one id.
510
+ 3. **Join with a separator that cannot occur in the data.** Otherwise
511
+ `("a", "bc")` and `("ab", "c")` hash the same.
512
+ 4. **Treat the constants as versioned.** Changing the normalization, the
513
+ separator or the length re-ids every record of that source ever stored. That
514
+ makes each of them a decision with a migration attached, not a cleanup.
515
+
516
+ Document the choice in the class comment. A reader of a stored snapshot has to
517
+ be able to find out where an id came from.
518
+
519
+ ## 7. Report what you could not read
520
+
521
+ A sanctions list is not a file we control, and the two failure modes are
522
+ different:
523
+
524
+ **A row that could not be read** is a `Parsers::Warning`, kept rather than
525
+ raised. OFAC ships 19,321 rows, and a single unbalanced quote in the middle must
526
+ not cost the other 19,320 — refusing to load a list because one record is
527
+ malformed fails exactly when the list is most needed. The toolkits collect their
528
+ own warnings; the convention is that an adapter exposes them together with
529
+ anything it noticed itself:
530
+
531
+ ```ruby
532
+ attr_reader :warnings
533
+
534
+ def parse(raw)
535
+ reader = LIST.read(raw)
536
+ @unmapped = []
537
+ entities = reader.filter_map { |record| entity(record) }
538
+ @warnings = reader.warnings + @unmapped
539
+ entities
540
+ end
541
+ ```
542
+
543
+ Read `#warnings` after `#parse`; sync orchestration reports them. Give every
544
+ warning a line number where the parser can supply one — a 5.6 MB file is only
545
+ debuggable if the complaint says where.
546
+
547
+ **A payload that is not the list at all** raises `ParseError` (the toolkits
548
+ raise it under the name `Parsers::ParseError`; it is the same class). Both
549
+ toolkits already refuse an empty payload for you, and the delimited reader also
550
+ gives up after enough consecutive unparseable rows, on the grounds that the
551
+ publisher probably served an error page. Do not rescue that into an empty array.
552
+ No sanctions list has ever been published empty, so an empty payload is a failed
553
+ download, a moved URL or an outage — never a day on which nobody is sanctioned,
554
+ and letting a sync succeed at screening against nothing is the most expensive
555
+ way this library can fail.
556
+
557
+ A truncated download is the middle case. It may be worth salvaging — the XML
558
+ reader keeps the records it read before the break and warns — and what the
559
+ contract requires is only that half a list never comes back looking exactly like
560
+ the whole one.
561
+
562
+ Raising one of your own is fine, as long as it is in the hierarchy — a payload
563
+ this adapter can see is wrong is a `ParseError`, and give it a locator if you
564
+ have one:
565
+
566
+ ```ruby
567
+ raise ActiveSanction::ParseError.new("the sheet has no listing column", line: 1)
568
+ ```
569
+
570
+ Nothing an adapter raises may be a bare `RuntimeError`, an `ArgumentError` or an
571
+ exception class belonging to a library it happens to parse with: the whole point
572
+ of the [error hierarchy](../README.md#handling-errors) is that a host
573
+ application rescues `ActiveSanction::Error` and gets your adapter's failures
574
+ along with everything else. `#source_id` is filled in for you as the error
575
+ leaves the adapter, so there is no need to name your own list in it.
576
+
577
+ `#parse` should not rescue `ActiveSanction::Error` at all. `#sync` does not
578
+ rescue either: one source's failure being isolated from the others is a decision
579
+ about a *run*, and it belongs to sync orchestration, which needs an exception
580
+ here to notice.
581
+
582
+ ### What the doctor reads, and the two hooks it offers
583
+
584
+ `ActiveSanction.doctor` diagnoses whether a list still parses the way we think
585
+ it does, and it works entirely from what an adapter already produces: the record
586
+ count, the fill rates of every canonical field, `#warnings`, `#orphans` and
587
+ `#remarks_coverage` where those exist. An adapter that follows the convention
588
+ above is diagnosed with no extra work — the doctor asks what your adapter
589
+ responds to and measures whatever it finds.
590
+
591
+ Two things are worth declaring on top of that, and both are optional.
592
+
593
+ **A floor**, for the run that has nothing to compare against. The doctor's real
594
+ baseline is the snapshot the last sync stored, which never goes stale; a floor
595
+ is a coarse backstop for a first sync, a new source, or a store that was
596
+ cleared:
597
+
598
+ ```ruby
599
+ floor :remarks_coverage, 0.90
600
+ floor :record_count, 400
601
+ ```
602
+
603
+ The name is a doctor check name (`:record_count`, `:remarks_coverage`,
604
+ `:fill_addresses`, and so on) and the value is the least it may be without the
605
+ diagnosis saying so. Declare few, and declare them wide. A number committed here
606
+ goes stale on its own, and the day somebody widens one to make a build pass is
607
+ the day it stops being read.
608
+
609
+ **A column shape**, if — and only if — the publisher ships a *positional* file.
610
+ Declaring the column names already pins the width, so a column inserted upstream
611
+ arrives as a wrong-width row and every row warns. A column *reordered* upstream
612
+ keeps the width, parses cleanly, and builds every record out of shifted fields;
613
+ nothing raises and nothing warns. Assert what the values are, not only how many
614
+ there are:
615
+
616
+ ```ruby
617
+ def column_shapes(raw)
618
+ rows = LIST.read(raw).to_a
619
+ [Parsers::ColumnShape.new(name: :ent_num, matches: /\A\d+\z/, description: "numeric")]
620
+ .map { |shape| shape.tally(rows.map { |row| row[shape.name] }) }
621
+ end
622
+ ```
623
+
624
+ A file that names its own columns needs none of this, which is most of them.
625
+ Pick a threshold you could defend on a published file rather than on your
626
+ fixture: these are not validated files, and one row where somebody typed a
627
+ letter into a numeric column is a curiosity rather than a format change.
628
+
629
+ ## 8. Capture a fixture and wire up the conformance spec
630
+
631
+ ### The fixture
632
+
633
+ **Use real published records.** A conformance run against a fixture somebody
634
+ wrote to pass it proves nothing about the list. Download the real file and cut
635
+ it down.
636
+
637
+ **Choose records by quirk, not at random.** The Canadian fixture is sixteen
638
+ records covering all three record shapes, a bilingual value split on ` / ` and
639
+ another split on `|`, a value bilingual in neither, a name padded with U+00A0, a
640
+ vessel type wrapped across three lines, a semicolon-separated alias field and a
641
+ comma-separated one, a year-only date, a full date, two candidate dates, a date
642
+ nothing can read, a record with no schedule, and a padded schedule and country.
643
+ Every one of those is a line in the spec. Aim for the same: one record per thing
644
+ the adapter had to decide, plus one ordinary record.
645
+
646
+ **Keep the bytes verbatim.** The fixture is read with `File.binread` and reaches
647
+ `#parse` undecoded, which is the point — it holds the adapter to doing its own
648
+ decoding. The committed OFAC fixture is still Windows-1252, and an editor that
649
+ helpfully re-saves it as UTF-8 turns that spec into a test of nothing. Line
650
+ endings and the trailing `0x1A` byte DOS-lineage export tooling writes matter
651
+ less — the delimited reader trims that byte for you — but the safe cut is one
652
+ made by a tool that does not touch what it was not asked to:
653
+
654
+ ```bash
655
+ # delimited: the header, if there is one, plus the rows you picked
656
+ head -1 /tmp/SDN.CSV > spec/fixtures/my_source/SDN.CSV
657
+ grep -a '^36,' /tmp/SDN.CSV >> spec/fixtures/my_source/SDN.CSV
658
+ ```
659
+
660
+ For XML, keep the document element **and its attributes** — that is where a
661
+ `dateGenerated` version marker lives — and any intermediate scaffolding the
662
+ records sit inside, then delete whole record elements. Verify the result parses
663
+ before committing it.
664
+
665
+ **For a multi-file source, keep the join keys aligned.** If you trim `SDN.CSV`
666
+ to eight rows and leave `ALT.CSV` whole, every unmatched alias becomes an orphan
667
+ and the fixture stops describing a coherent version of the list. The committed
668
+ OFAC fixture is 8 primary rows, 7 aliases and 5 addresses, all keyed to the same
669
+ entities.
670
+
671
+ **Keep it small.** The existing fixtures run from 250 bytes to 18 KB. They are
672
+ read on every suite run and reviewed by hand.
673
+
674
+ ### The spec
675
+
676
+ ```ruby
677
+ RSpec.describe ActiveSanction::Sources::CanadaSema do
678
+ it_behaves_like "a sanction source", fixture: "canada_sema/sema.xml"
679
+ end
680
+ ```
681
+
682
+ Paths are relative to `spec/fixtures`. A source whose publisher splits the list
683
+ names one fixture per declared URL, under the keys the adapter declared:
684
+
685
+ ```ruby
686
+ it_behaves_like "a sanction source",
687
+ fixture: { sdn: "ofac_sdn/SDN.CSV", alt: "ofac_sdn/ALT.CSV", add: "ofac_sdn/ADD.CSV" }
688
+ ```
689
+
690
+ There is one option: `remarks: false`, for a list that publishes no free text of
691
+ its own anywhere — Canada is the only launch source that qualifies. It has to be
692
+ asked for, so that dropping a remark by accident stays a failure.
693
+
694
+ The group lives in `spec/support/shared_examples/sanction_source.rb` and checks
695
+ what everything downstream assumes and cannot check for itself: that the adapter
696
+ declares a key, a jurisdiction, an authority and a URL and registers itself; that
697
+ `#parse` returns Entities with unique, deterministic ids, a canonical type and at
698
+ least one name; that dates are `PartialDate`s; that every record survives the
699
+ round-trip through `#to_h`; that the publisher's own text survives in `remarks`;
700
+ that an empty payload is refused; and that a truncated one is not reported as the
701
+ whole list.
702
+
703
+ **It is the floor, not the ceiling.** Nothing in it knows that OFAC writes `-0-`
704
+ for null, that the UN means two different things by `QUALITY`, or which of your
705
+ fixture's records is a vessel. Only a spec that knows what is in the fixture can
706
+ check that the list was read *correctly*, so write one — every adapter here does.
707
+ Look up records the way a person would (by reference, or by name for a source
708
+ with no reference) and assert the meaning:
709
+
710
+ ```ruby
711
+ def entity(ref) = entities.find { |candidate| candidate.source_ref == ref }
712
+
713
+ it "types a record carrying an IMO number as a vessel" do
714
+ expect(entity("RT/2023/0330").type).to eq(:vessel)
715
+ end
716
+ ```
717
+
718
+ The suite is hermetic: `WebMock` blocks outbound connections, so an un-stubbed
719
+ HTTP call raises rather than quietly reaching a government server. A spec that
720
+ genuinely needs a live endpoint is tagged `:live`, excluded from the default run,
721
+ and executed with `bundle exec rspec --tag live`.
722
+
723
+ ## 9. Register it
724
+
725
+ An adapter whose file is required but which never registers is invisible:
726
+ `config.sources` cannot name it, `sync` will not run it, and nothing says so out
727
+ loud. The conformance spec checks for exactly this.
728
+
729
+ ### Inside this gem
730
+
731
+ One explicit line at the bottom of the adapter's own file, and a `require` in
732
+ `lib/active_sanction.rb`:
733
+
734
+ ```ruby
735
+ # lib/active_sanction/sources/my_source.rb
736
+ ActiveSanction::Sources.register(ActiveSanction::Sources::MySource)
737
+ ```
738
+
739
+ Registration is explicit rather than hooked onto `inherited` because
740
+ auto-registering every subclass would also enrol the abstract intermediates that
741
+ adapters sharing a publisher want — `Sources::Ofac` is one, and there is no such
742
+ list as "OFAC" — along with every throwaway subclass a test defines.
743
+
744
+ ### From outside this gem
745
+
746
+ The registry is open, and that is deliberate. Nothing there requires
747
+ `Sources::Base`: registration is duck-typed on `.key` and `.new`, so a source
748
+ backed by a database table rather than a published file — no URL to declare, no
749
+ payload to fetch — is a first-class citizen rather than something that has to
750
+ pretend to be a file download.
751
+
752
+ ```ruby
753
+ # in your own gem or initializer
754
+ ActiveSanction::Sources.register(MyCompany::InternalWatchlist)
755
+
756
+ ActiveSanction.configure { |c| c.sources = %i[ofac_sdn my_internal_watchlist] }
757
+ ```
758
+
759
+ Subclassing `Base` is the convenient way to write an adapter, not the price of
760
+ admission. If you do subclass it and your list is not fetched over HTTP,
761
+ override `#retrieve` to return a `Hash` of name => bytes (or `nil` when nothing
762
+ has changed) and leave the rest alone.
763
+
764
+ To replace a built-in adapter with a patched one, unregister first — two lists
765
+ cannot answer to one name, and registering over a claimed key raises at load
766
+ time, which is where the collision is cheap to fix:
767
+
768
+ ```ruby
769
+ ActiveSanction::Sources.unregister(:ofac_sdn)
770
+ ActiveSanction::Sources.register(MyCompany::PatchedOfacSdn)
771
+ ```
772
+
773
+ There is deliberately no `clear!`.
774
+
775
+ ### And commit a canary baseline
776
+
777
+ The upstream canary ([#69](https://github.com/Babystep-Technologies/active_sanction/issues/69))
778
+ fetches every registered source on weekdays and holds it against
779
+ `.github/baselines/<key>.json`. A newly registered adapter has no such file, and
780
+ nothing breaks: it is held to the coarse `floor` declarations you made in
781
+ [section 7](#7-report-what-you-could-not-read) instead, and the run reports
782
+ `compared: false` rather than reading as a clean comparison.
783
+
784
+ Commit one anyway, in the same pull request as the adapter:
785
+
786
+ ```console
787
+ $ CANARY_SOURCES=my_source bundle exec rake canary # what it measures
788
+ $ CANARY_SOURCES=my_source bundle exec rake canary:refresh # write the baseline
789
+ ```
790
+
791
+ That reaches the real publisher, which is the point: the numbers a reviewer sees
792
+ in `.github/baselines/my_source.json` are the numbers the file actually yields,
793
+ and from the next weekday onwards a change in any of them opens an issue. It is
794
+ also the cheapest review anybody will ever give your parser — a fill rate that
795
+ reads 4% where you expected 90% is a field you mapped to the wrong element, and
796
+ it is far easier to see in that diff than in a fixture of forty records.
797
+
798
+ [`.github/baselines/README.md`](../.github/baselines/README.md) documents the
799
+ format and the per-key tolerances.
800
+
801
+ ## 10. Typed, linted, and green
802
+
803
+ Every file in `lib/` is `# typed: strict`, and new files are born that way: a
804
+ signature written beside the code costs a line, and one retrofitted a milestone
805
+ later costs an afternoon of reading the code back. In practice that means
806
+ `extend T::Sig`, a `sig` on every method, `T.let` on every instance variable and
807
+ on constants, and `override.` on `#parse`, which `Base` declares. The worked
808
+ example below is written the way it would land in `lib/`.
809
+
810
+ An adapter in your own application is under no such obligation — nothing in the
811
+ public API requires signatures.
812
+
813
+ ```bash
814
+ bundle exec rake # rspec, then rubocop, then srb tc; all three must pass
815
+ ```
816
+
817
+ ## A worked example, end to end
818
+
819
+ Here is a complete adapter, its fixture and its spec. **The list is invented for
820
+ this document** — a real adapter's fixture must be real published bytes, as
821
+ [section 8](#8-capture-a-fixture-and-wire-up-the-conformance-spec) says. It is
822
+ otherwise exactly what lands in the repository, and it passes the conformance
823
+ spec, RuboCop and Sorbet unchanged.
824
+
825
+ The Ruritanian Ministry of Finance publishes one CSV with a header row, `N/A`
826
+ where it means nothing, a stable reference per record, semicolon-separated
827
+ aliases, a `Position` column the canonical model has no home for, and dates in
828
+ ISO.
829
+
830
+ ### The fixture
831
+
832
+ `spec/fixtures/ruritania_fsl/list.csv`:
833
+
834
+ ```
835
+ Reference,Name,Aliases,Type,Date of Listing,Regime,Date of Birth,Passport Number,Position,Notes
836
+ RT/2019/0014,ACME TRADING LIMITED,ACME TRADE; ACME TRADING LTD,Entity,2019-03-14,Ruritania (Financial Measures) Order 2019,N/A,N/A,N/A,Registered in Nicosia; ships through two subsidiaries
837
+ RT/2021/0102,JOHN AGYEMAN OKORO,N/A,Individual,2021-11-02,Ruritania (Financial Measures) Order 2019,1974,RT884213,Deputy Minister of Trade,Travels on a diplomatic passport
838
+ RT/2023/0330,MV NORTHERN STAR,NORTHERN STAR; SEVERNAYA ZVEZDA,Ship,2023-07-30,Ruritania (Shipping Measures) Order 2022,N/A,N/A,N/A,Reflagged twice in 2022
839
+ RT/2024/0007,MARIA ELENA VASQUEZ DE LEON,N/A,Individual,2024-01-19,Ruritania (Financial Measures) Order 2019,1988-06-04,N/A,N/A,N/A
840
+ ```
841
+
842
+ Four records: an organization with two aliases and no dates, an individual with
843
+ a year-only date of birth and a passport, a vessel, and an individual with a
844
+ full date of birth and no free text of any kind.
845
+
846
+ ### The adapter
847
+
848
+ `lib/active_sanction/sources/ruritania_fsl.rb`:
849
+
850
+ ```ruby
851
+ # typed: strict
852
+ # frozen_string_literal: true
853
+
854
+ require "sorbet-runtime"
855
+
856
+ require "active_sanction/parsers"
857
+ require "active_sanction/sources"
858
+ require "active_sanction/sources/base"
859
+
860
+ module ActiveSanction
861
+ module Sources
862
+ # The Ruritanian consolidated financial sanctions list: everyone named in
863
+ # an order made under the Financial Measures Act, plus the vessels listed
864
+ # under the Shipping Measures Order.
865
+ #
866
+ # snapshot = ActiveSanction::Sources[:ruritania_fsl].new.sync
867
+ #
868
+ # One file, one flat record shape, a stable reference per record, and
869
+ # `N/A` wherever the Ministry means nothing.
870
+ class RuritaniaFsl < Base
871
+ extend T::Sig
872
+
873
+ key :ruritania_fsl
874
+ jurisdiction :rt
875
+ authority "Ruritanian Ministry of Finance"
876
+ format :csv
877
+
878
+ url :main, "https://finance.gov.rt/sanctions/consolidated.csv"
879
+
880
+ # The Ministry ships a header row, so the column names are read from the
881
+ # file rather than declared; `N/A` is what it writes where it means
882
+ # nothing.
883
+ LIST = T.let(Parsers::DelimitedTable.new(null: "N/A"), Parsers::DelimitedTable)
884
+
885
+ # `Type` is free text and the Ministry writes three values.
886
+ TYPES = T.let(
887
+ { "individual" => :individual, "entity" => :organization, "ship" => :vessel }.freeze,
888
+ T::Hash[String, Symbol]
889
+ )
890
+
891
+ # The alias column is separated by semicolons and by nothing else.
892
+ ALIASES = T.let(";", String)
893
+
894
+ sig { returns(T::Array[Parsers::Warning]) }
895
+ attr_reader :warnings
896
+
897
+ sig { params(args: T.untyped, options: T.untyped).void }
898
+ def initialize(*args, **options)
899
+ super
900
+ @warnings = T.let([], T::Array[Parsers::Warning])
901
+ @unmapped = T.let([], T::Array[Parsers::Warning])
902
+ end
903
+
904
+ sig { override.params(raw: T.untyped).returns(T::Array[Entity]) }
905
+ def parse(raw)
906
+ reader = LIST.read(raw)
907
+ @unmapped = []
908
+ entities = reader.filter_map { |row| entity(row) }
909
+ @warnings = reader.warnings + @unmapped
910
+ entities
911
+ end
912
+
913
+ private
914
+
915
+ sig { params(row: Parsers::DelimitedTable::Row).returns(T.nilable(Entity)) }
916
+ def entity(row)
917
+ published = names(row)
918
+ return note_nameless(row) if published.empty?
919
+
920
+ Entity.new(source: key, source_ref: row[:reference], type: type(row), names: published,
921
+ identifiers: identifiers(row), dates_of_birth: dates_of_birth(row),
922
+ programs: [row[:regime]].compact, listed_on: PartialDate.parse(row[:date_of_listing]),
923
+ remarks: Remarks.build(row[:notes], [["Position", row[:position]]]))
924
+ end
925
+
926
+ sig { params(row: Parsers::DelimitedTable::Row).returns(T::Array[Name]) }
927
+ def names(row)
928
+ primary = row[:name]
929
+ return [] if primary.nil?
930
+
931
+ [Name.new(value: primary)] + aliases(row)
932
+ end
933
+
934
+ sig { params(row: Parsers::DelimitedTable::Row).returns(T::Array[Name]) }
935
+ def aliases(row)
936
+ row[:aliases].to_s.split(ALIASES).filter_map do |value|
937
+ text = value.strip
938
+ Name.new(value: text, kind: :aka) unless text.empty?
939
+ end
940
+ end
941
+
942
+ sig { params(row: Parsers::DelimitedTable::Row).returns(Symbol) }
943
+ def type(row)
944
+ published = row[:type].to_s.downcase
945
+ return T.must(TYPES[published]) if TYPES.key?(published)
946
+
947
+ @unmapped << Parsers::Warning.new(
948
+ line: row.line, message: "unknown Type #{row[:type].inspect}; treated as an organization"
949
+ )
950
+ :organization
951
+ end
952
+
953
+ sig { params(row: Parsers::DelimitedTable::Row).returns(T::Array[PartialDate]) }
954
+ def dates_of_birth(row) = [PartialDate.parse(row[:date_of_birth])].compact
955
+
956
+ sig { params(row: Parsers::DelimitedTable::Row).returns(T::Array[Identifier]) }
957
+ def identifiers(row)
958
+ return [] if row.null?(:passport_number)
959
+
960
+ [Identifier.new(kind: :passport, value: row[:passport_number])]
961
+ end
962
+
963
+ # A record with no name cannot be screened against. None of the records
964
+ # published today is nameless; the warning exists so that the day one is,
965
+ # it is visible rather than absent.
966
+ sig { params(row: Parsers::DelimitedTable::Row).returns(NilClass) }
967
+ def note_nameless(row)
968
+ @unmapped << Parsers::Warning.new(
969
+ line: row.line, message: "row #{row[:reference].inspect} has no Name and was skipped"
970
+ )
971
+ nil
972
+ end
973
+ end
974
+ end
975
+ end
976
+
977
+ ActiveSanction::Sources.register(ActiveSanction::Sources::RuritaniaFsl)
978
+ ```
979
+
980
+ Then add the require to `lib/active_sanction.rb`:
981
+
982
+ ```ruby
983
+ require "active_sanction/sources/ruritania_fsl"
984
+ ```
985
+
986
+ ### The spec
987
+
988
+ `spec/active_sanction/sources/ruritania_fsl_spec.rb`:
989
+
990
+ ```ruby
991
+ # frozen_string_literal: true
992
+
993
+ RSpec.describe ActiveSanction::Sources::RuritaniaFsl do
994
+ def raw = File.binread(File.expand_path("../../fixtures/ruritania_fsl/list.csv", __dir__))
995
+
996
+ let(:adapter) { described_class.new }
997
+ let(:entities) { adapter.parse(raw) }
998
+
999
+ def entity(ref) = entities.find { |candidate| candidate.source_ref == ref }
1000
+
1001
+ it_behaves_like "a sanction source", fixture: "ruritania_fsl/list.csv"
1002
+
1003
+ it "reads the publisher's reference as the entity id" do
1004
+ expect(entity("RT/2021/0102").id).to eq("ruritania_fsl:RT/2021/0102")
1005
+ end
1006
+
1007
+ it "splits the alias column on semicolons" do
1008
+ expect(entity("RT/2023/0330").names.map(&:value))
1009
+ .to eq(["MV NORTHERN STAR", "NORTHERN STAR", "SEVERNAYA ZVEZDA"])
1010
+ end
1011
+
1012
+ it "reads a year-only date of birth without inventing a day" do
1013
+ expect(entity("RT/2021/0102").dates_of_birth.first.to_h).to include(year: 1974, month: nil, day: nil)
1014
+ end
1015
+
1016
+ it "keeps the Position column behind the source-fields marker" do
1017
+ expect(entity("RT/2021/0102").remarks)
1018
+ .to eq("Travels on a diplomatic passport [source fields] Position: Deputy Minister of Trade")
1019
+ end
1020
+
1021
+ it "reads N/A as nothing at all" do
1022
+ expect(entity("RT/2019/0014").identifiers).to be_empty
1023
+ end
1024
+ end
1025
+ ```
1026
+
1027
+ That is 19 conformance examples and 5 of its own, and the list is now reachable
1028
+ everywhere:
1029
+
1030
+ ```ruby
1031
+ ActiveSanction::Sources[:ruritania_fsl] # => the adapter class
1032
+ ActiveSanction.configure { |c| c.sources = %i[ofac_sdn ruritania_fsl] }
1033
+ snapshot = ActiveSanction::Sources[:ruritania_fsl].new.sync
1034
+ ```
1035
+
1036
+ ### What a harder list adds to that
1037
+
1038
+ The example above is the easy shape. In roughly the order they bite:
1039
+
1040
+ - **No publisher id** → [section 6](#6-give-every-record-a-stable-id), and copy
1041
+ `CanadaSema::SourceRef`.
1042
+ - **Several files** → declare a `url` per file, read `raw[:name]`, join with
1043
+ `Parsers::Join`, and watch the orphan counts.
1044
+ - **XML** → `Parsers::XmlRecords`, and split the mapping into its own `Record`
1045
+ class the way `un_consolidated/record.rb` does.
1046
+ - **An encoding the publisher does not declare** → pass `encoding:` to the
1047
+ table.
1048
+ - **A field that means two things** → map it twice, per record shape, and say so
1049
+ in the class comment.
1050
+ - **Free text carrying the identifiers** → OFAC's shape.
1051
+ `Sources::Ofac::RemarksParser` is the precedent, along with its coverage
1052
+ reporting: extraction is additive, the remark is kept verbatim either way, and
1053
+ every sync reports how much of the text was understood.
1054
+
1055
+ ## Checklist
1056
+
1057
+ - [ ] Read the closest existing adapter, and its spec.
1058
+ - [ ] Downloaded the real file and answered the eight questions in
1059
+ [section 2](#2-look-at-what-the-publisher-actually-serves).
1060
+ - [ ] Declared `key`, `jurisdiction`, `authority`, `format`, and one `url` per
1061
+ file.
1062
+ - [ ] Overrode `#source_version` if the document carries its own version marker.
1063
+ - [ ] `#parse` returns `Entity` objects and nothing else.
1064
+ - [ ] Every id is unique and identical on a second read of the same bytes.
1065
+ - [ ] Every date is a `PartialDate`.
1066
+ - [ ] Every type is one of the four canonical types, and an unrecognised one
1067
+ warns rather than dropping the record.
1068
+ - [ ] The publisher's free text is kept verbatim; extra fields are appended with
1069
+ `Remarks.build`.
1070
+ - [ ] Unreadable rows become warnings; a payload that is not the list raises.
1071
+ - [ ] A `floor` for anything worth a coarse bound on a first sync, and
1072
+ `#column_shapes` if the publisher ships a positional file.
1073
+ - [ ] A trimmed fixture of real bytes, one record per quirk, committed under
1074
+ `spec/fixtures/<key>/`.
1075
+ - [ ] `it_behaves_like "a sanction source"` passes, plus a spec that knows what
1076
+ is in the fixture.
1077
+ - [ ] `Sources.register` at the bottom of the file, and a `require` in
1078
+ `lib/active_sanction.rb`.
1079
+ - [ ] A canary baseline committed under `.github/baselines/<key>.json`, written
1080
+ by `CANARY_SOURCES=<key> bundle exec rake canary:refresh`.
1081
+ - [ ] A class comment describing the list, its record counts, its quirks, and
1082
+ what the adapter refuses to do about them.
1083
+ - [ ] `bundle exec rake` is green.