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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +538 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/CONTRIBUTING.md +312 -0
- data/LICENSE.txt +21 -0
- data/README.md +616 -0
- data/SECURITY.md +97 -0
- data/active_sanction.gemspec +98 -0
- data/docs/adding_a_source.md +1083 -0
- data/docs/api_stability.md +357 -0
- data/docs/bundle_format.md +407 -0
- data/lib/active_sanction/address.rb +170 -0
- data/lib/active_sanction/client.rb +341 -0
- data/lib/active_sanction/configuration.rb +721 -0
- data/lib/active_sanction/countries.txt +292 -0
- data/lib/active_sanction/country.rb +164 -0
- data/lib/active_sanction/deprecation.rb +168 -0
- data/lib/active_sanction/diff/change.rb +208 -0
- data/lib/active_sanction/diff.rb +348 -0
- data/lib/active_sanction/doctor/checkup.rb +356 -0
- data/lib/active_sanction/doctor/diagnosis.rb +245 -0
- data/lib/active_sanction/doctor/finding.rb +183 -0
- data/lib/active_sanction/doctor/profile.rb +384 -0
- data/lib/active_sanction/doctor/report.rb +227 -0
- data/lib/active_sanction/doctor.rb +347 -0
- data/lib/active_sanction/entity.rb +293 -0
- data/lib/active_sanction/error.rb +332 -0
- data/lib/active_sanction/fetcher/result.rb +116 -0
- data/lib/active_sanction/fetcher.rb +242 -0
- data/lib/active_sanction/http_client/errors.rb +94 -0
- data/lib/active_sanction/http_client/response.rb +129 -0
- data/lib/active_sanction/http_client.rb +324 -0
- data/lib/active_sanction/identifier.rb +212 -0
- data/lib/active_sanction/index/builder.rb +89 -0
- data/lib/active_sanction/index/candidate.rb +63 -0
- data/lib/active_sanction/index/entry.rb +66 -0
- data/lib/active_sanction/index/features.rb +112 -0
- data/lib/active_sanction/index.rb +381 -0
- data/lib/active_sanction/match_result.rb +345 -0
- data/lib/active_sanction/matcher.rb +388 -0
- data/lib/active_sanction/name.rb +183 -0
- data/lib/active_sanction/normalizer/cache.rb +82 -0
- data/lib/active_sanction/normalizer/dictionaries/honorifics.txt +30 -0
- data/lib/active_sanction/normalizer/dictionaries/legal_forms.txt +72 -0
- data/lib/active_sanction/normalizer/dictionaries/organization_stopwords.txt +20 -0
- data/lib/active_sanction/normalizer/dictionaries/particles.txt +35 -0
- data/lib/active_sanction/normalizer/dictionary/stoplist.rb +121 -0
- data/lib/active_sanction/normalizer/dictionary.rb +243 -0
- data/lib/active_sanction/normalizer/form.rb +279 -0
- data/lib/active_sanction/normalizer.rb +137 -0
- data/lib/active_sanction/parsers/column_shape.rb +274 -0
- data/lib/active_sanction/parsers/delimited_table/reader.rb +194 -0
- data/lib/active_sanction/parsers/delimited_table/row.rb +81 -0
- data/lib/active_sanction/parsers/delimited_table.rb +151 -0
- data/lib/active_sanction/parsers/format.rb +107 -0
- data/lib/active_sanction/parsers/join.rb +149 -0
- data/lib/active_sanction/parsers/spreadsheet/archive.rb +258 -0
- data/lib/active_sanction/parsers/spreadsheet/reader.rb +213 -0
- data/lib/active_sanction/parsers/spreadsheet/row.rb +88 -0
- data/lib/active_sanction/parsers/spreadsheet/workbook.rb +314 -0
- data/lib/active_sanction/parsers/spreadsheet.rb +168 -0
- data/lib/active_sanction/parsers/xml_records/backends/nokogiri.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/backends/rexml.rb +157 -0
- data/lib/active_sanction/parsers/xml_records/backends.rb +103 -0
- data/lib/active_sanction/parsers/xml_records/builder.rb +90 -0
- data/lib/active_sanction/parsers/xml_records/reader.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/record.rb +174 -0
- data/lib/active_sanction/parsers/xml_records.rb +140 -0
- data/lib/active_sanction/parsers.rb +94 -0
- data/lib/active_sanction/partial_date/parser.rb +124 -0
- data/lib/active_sanction/partial_date.rb +303 -0
- data/lib/active_sanction/payload_cache/checksum.rb +66 -0
- data/lib/active_sanction/payload_cache/entry.rb +304 -0
- data/lib/active_sanction/payload_cache.rb +366 -0
- data/lib/active_sanction/phonetics/double_metaphone.rb +662 -0
- data/lib/active_sanction/phonetics.rb +40 -0
- data/lib/active_sanction/query.rb +290 -0
- data/lib/active_sanction/rescreen/alert.rb +314 -0
- data/lib/active_sanction/rescreen.rb +332 -0
- data/lib/active_sanction/scorer/adjustments.rb +241 -0
- data/lib/active_sanction/scorer/name_score.rb +279 -0
- data/lib/active_sanction/scorer/reason.rb +136 -0
- data/lib/active_sanction/scorer/result.rb +129 -0
- data/lib/active_sanction/scorer/subject.rb +235 -0
- data/lib/active_sanction/scorer/weights.rb +306 -0
- data/lib/active_sanction/scorer.rb +303 -0
- data/lib/active_sanction/similarity/jaro_winkler.rb +210 -0
- data/lib/active_sanction/similarity/levenshtein.rb +179 -0
- data/lib/active_sanction/similarity/token_set.rb +159 -0
- data/lib/active_sanction/similarity/token_sort.rb +120 -0
- data/lib/active_sanction/similarity.rb +183 -0
- data/lib/active_sanction/snapshot/bundle/header.rb +302 -0
- data/lib/active_sanction/snapshot/bundle/payload.rb +185 -0
- data/lib/active_sanction/snapshot/bundle/signature.rb +201 -0
- data/lib/active_sanction/snapshot/bundle.rb +381 -0
- data/lib/active_sanction/snapshot.rb +305 -0
- data/lib/active_sanction/sources/australia_dfat/published_date.rb +166 -0
- data/lib/active_sanction/sources/australia_dfat/record.rb +319 -0
- data/lib/active_sanction/sources/australia_dfat.rb +291 -0
- data/lib/active_sanction/sources/base.rb +295 -0
- data/lib/active_sanction/sources/canada_sema/record.rb +282 -0
- data/lib/active_sanction/sources/canada_sema/source_ref.rb +89 -0
- data/lib/active_sanction/sources/canada_sema.rb +153 -0
- data/lib/active_sanction/sources/definition.rb +360 -0
- data/lib/active_sanction/sources/eu_fsf/record.rb +467 -0
- data/lib/active_sanction/sources/eu_fsf.rb +203 -0
- data/lib/active_sanction/sources/ofac/record.rb +251 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/coverage.rb +120 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/vocabulary.rb +121 -0
- data/lib/active_sanction/sources/ofac/remarks_parser.rb +312 -0
- data/lib/active_sanction/sources/ofac.rb +248 -0
- data/lib/active_sanction/sources/ofac_consolidated/record.rb +37 -0
- data/lib/active_sanction/sources/ofac_consolidated.rb +203 -0
- data/lib/active_sanction/sources/ofac_sdn.rb +43 -0
- data/lib/active_sanction/sources/remarks.rb +84 -0
- data/lib/active_sanction/sources/uk_sanctions_list/published_date.rb +104 -0
- data/lib/active_sanction/sources/uk_sanctions_list/record.rb +412 -0
- data/lib/active_sanction/sources/uk_sanctions_list.rb +214 -0
- data/lib/active_sanction/sources/un_consolidated/record.rb +279 -0
- data/lib/active_sanction/sources/un_consolidated.rb +140 -0
- data/lib/active_sanction/sources.rb +196 -0
- data/lib/active_sanction/storage/active_record/reader.rb +124 -0
- data/lib/active_sanction/storage/active_record/row.rb +175 -0
- data/lib/active_sanction/storage/active_record/writer.rb +141 -0
- data/lib/active_sanction/storage/active_record.rb +277 -0
- data/lib/active_sanction/storage/base.rb +216 -0
- data/lib/active_sanction/storage/file_system.rb +373 -0
- data/lib/active_sanction/storage/memory.rb +67 -0
- data/lib/active_sanction/storage/meta.rb +188 -0
- data/lib/active_sanction/storage.rb +74 -0
- data/lib/active_sanction/subject.rb +271 -0
- data/lib/active_sanction/sync/report.rb +245 -0
- data/lib/active_sanction/sync/result.rb +296 -0
- data/lib/active_sanction/sync.rb +390 -0
- data/lib/active_sanction/validator_store/file_system.rb +103 -0
- data/lib/active_sanction/validator_store/memory.rb +41 -0
- data/lib/active_sanction/validator_store.rb +119 -0
- data/lib/active_sanction/validators.rb +195 -0
- data/lib/active_sanction/version.rb +23 -0
- data/lib/active_sanction.rb +331 -0
- data/lib/generators/active_sanction/install/install_generator.rb +60 -0
- data/lib/generators/active_sanction/install/templates/create_active_sanction_tables.rb.tt +142 -0
- 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.
|