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,407 @@
1
+ # The bundle format
2
+
3
+ A bundle is one sanctions list, in one file, that a machine which has never
4
+ spoken to the publisher can load and trust. It carries the records, everything
5
+ needed to prove they are intact, and — optionally — a signature saying who
6
+ published them.
7
+
8
+ ```
9
+ ofac_sdn.asb
10
+ ├── ACTIVESANCTION-BUNDLE/1 the magic, and the format version
11
+ ├── {"format_version":1,...} the header: one canonical JSON line
12
+ ├── ecdsa-sha256 MEUCIQ... the signature, or "-"
13
+ └── <deflated NDJSON> the records, one JSON object per line
14
+ ```
15
+
16
+ This document specifies it completely enough to be produced and read by
17
+ something that is not this gem and is not Ruby. In Ruby it is
18
+ `ActiveSanction::Snapshot::Bundle`:
19
+
20
+ ```ruby
21
+ File.open("ofac_sdn.asb", "wb") { |io| ActiveSanction::Snapshot::Bundle.write(snapshot, io: io) }
22
+ File.open("ofac_sdn.asb", "rb") { |io| ActiveSanction::Snapshot::Bundle.read(io) }
23
+ ```
24
+
25
+ ## Contents
26
+
27
+ 1. [Why the format exists](#1-why-the-format-exists)
28
+ 2. [Conventions](#2-conventions)
29
+ 3. [The container](#3-the-container)
30
+ 4. [The magic line](#4-the-magic-line)
31
+ 5. [The header line](#5-the-header-line)
32
+ 6. [The signature line](#6-the-signature-line)
33
+ 7. [The payload](#7-the-payload)
34
+ 8. [Producing a bundle](#8-producing-a-bundle)
35
+ 9. [Reading a bundle](#9-reading-a-bundle)
36
+ 10. [Determinism, and what is comparable](#10-determinism-and-what-is-comparable)
37
+ 11. [Versioning](#11-versioning)
38
+ 12. [A worked example](#12-a-worked-example)
39
+
40
+ ## 1. Why the format exists
41
+
42
+ `Storage::FileSystem` also writes compressed JSON, and its layout is private and
43
+ expected to change. A bundle is the opposite thing: a published artifact with a
44
+ stability contract, which three situations need and a private directory layout
45
+ cannot serve.
46
+
47
+ - **Publishers go down.** Government endpoints break, change format, and
48
+ rate-limit. A bundle produced once and distributed is the difference between
49
+ a bad afternoon at Treasury and a failed deploy for everyone downstream.
50
+ - **Air-gapped and privacy-sensitive installations.** A compliance team that
51
+ will not send subject names to a third-party API will happily consume fresh
52
+ data. A file serves them; a request/response API cannot.
53
+ - **Audit.** A checksum proves a list is internally intact. A signature proves
54
+ it is *the one that was published*, which is the claim an examiner is asking
55
+ about.
56
+
57
+ Anyone may produce a bundle, from any source, with no key and no licence. That
58
+ is the point of specifying it here rather than in a product.
59
+
60
+ ## 2. Conventions
61
+
62
+ - **Bytes.** A bundle is a byte stream. It is not text, and it must not be
63
+ transferred in a mode that rewrites line endings.
64
+ - **Line terminator.** `LF` (`0x0A`) alone. `CRLF` is not accepted anywhere.
65
+ - **Text.** The three header lines are UTF-8. So is the decompressed payload.
66
+ - **JSON.** [RFC 8259](https://www.rfc-editor.org/rfc/rfc8259). Serialized
67
+ compactly: no whitespace between tokens, no trailing newline inside a line,
68
+ and no reordering — key order is part of the format wherever this document
69
+ gives one.
70
+ - **Digests.** SHA-256, written `sha256:` followed by 64 lowercase hex digits.
71
+ The same form this library quotes every digest in.
72
+ - **Extension.** `.asb`, by convention. Nothing depends on it.
73
+ - **Media type.** `application/vnd.active-sanction.bundle`, by convention.
74
+
75
+ ## 3. The container
76
+
77
+ ```
78
+ bundle = magic LF header LF signature LF payload
79
+ magic = "ACTIVESANCTION-BUNDLE/" 1*DIGIT
80
+ header = <one JSON object, serialized compactly>
81
+ signature = "-" / algorithm SP base64
82
+ payload = <a zlib stream, RFC 1950>
83
+ ```
84
+
85
+ The payload runs to end of file and is not length-prefixed in the container:
86
+ its uncompressed length and digest are in the header, and bytes after the end of
87
+ the compressed stream are an error (§9).
88
+
89
+ The first three lines are text on purpose. `head -c 512 ofac_sdn.asb` tells an
90
+ operator what list a file holds, how many records, from when, and who says so —
91
+ with no tooling and nothing decompressed.
92
+
93
+ Each of the three lines is at most **65536 bytes** including its terminator. A
94
+ reader must refuse a longer one rather than buffering it.
95
+
96
+ ## 4. The magic line
97
+
98
+ ```
99
+ ACTIVESANCTION-BUNDLE/1
100
+ ```
101
+
102
+ The literal `ACTIVESANCTION-BUNDLE`, a `/`, and the format version as decimal
103
+ digits. This is the first thing a reader checks, and a version it does not
104
+ implement is refused here — before any JSON is parsed, and before a byte is
105
+ decompressed.
106
+
107
+ The version also appears in the header, and the two must agree (§9). The magic
108
+ is what a reader acts on early; the copy in the header is the one a signature
109
+ covers, so the version cannot be reinterpreted without breaking the signature.
110
+
111
+ ## 5. The header line
112
+
113
+ One JSON object, serialized compactly, with its keys **in this order**:
114
+
115
+ | # | Key | Type | Notes |
116
+ |---|-----|------|-------|
117
+ | 1 | `format_version` | integer | Equals the version in the magic line. |
118
+ | 2 | `gem_version` | string | The `active_sanction` that serialized the records. Diagnostic; a non-Ruby producer writes the version of whatever wrote it. |
119
+ | 3 | `generator` | string | Who published this bundle, e.g. `"active_sanction/1.0.0"`, `"acme-mirror/2.0"`. |
120
+ | 4 | `source` | string | The list's key: lowercase, `[a-z][a-z0-9_]*`, e.g. `"ofac_sdn"`. |
121
+ | 5 | `schema_version` | integer | `Snapshot::SCHEMA_VERSION` the records are written under. `2` at the time of writing. |
122
+ | 6 | `snapshot_checksum` | digest | Over the records' content. §7.3. |
123
+ | 7 | `record_count` | integer | Number of lines in the uncompressed payload. |
124
+ | 8 | `fetched_at` | string | When the publisher's file was fetched: RFC 3339, UTC, whole seconds, `Z` — `"2026-08-28T09:30:00Z"`. |
125
+ | 9 | `source_version` | string \| null | The publisher's own version string, where it gives one. |
126
+ | 10 | `payload_encoding` | string | `"ndjson"` in v1. |
127
+ | 11 | `payload_compression` | string | `"deflate"` in v1. |
128
+ | 12 | `payload_digest` | digest | SHA-256 over the **uncompressed** payload. |
129
+ | 13 | `payload_bytes` | integer | Length of the **uncompressed** payload. |
130
+
131
+ Every key is present. `source_version` is written as `null` rather than omitted
132
+ when there is none — the key order is the format, and a reader that had to cope
133
+ with holes in it would be coping with a different file for every publisher.
134
+
135
+ A reader must refuse a header carrying a key it does not know, rather than
136
+ ignoring it. A field a producer thought was meaningful and a reader silently
137
+ dropped is the shape of every quiet integrity failure this format exists to
138
+ avoid.
139
+
140
+ There is deliberately **no written-at timestamp**, and no field describing the
141
+ compressed bytes. Both would make two writes of one snapshot different files
142
+ (§10).
143
+
144
+ ## 6. The signature line
145
+
146
+ ```
147
+ - an unsigned bundle
148
+ ecdsa-sha256 MEUCIQD0kBCt... a signed one
149
+ ```
150
+
151
+ An algorithm name, a single space, and the signature in
152
+ [RFC 4648 §4](https://www.rfc-editor.org/rfc/rfc4648#section-4) base64 with
153
+ padding and no line breaks. `-` when the bundle is unsigned.
154
+
155
+ **The signed bytes are the header line's, without its terminator.** Nothing
156
+ else. Because the header carries `payload_digest`, signing a few hundred bytes stands for
157
+ every record in the file, and three things follow:
158
+
159
+ - A verifier settles who published a bundle **before inflating any of it**.
160
+ Compressed data from a party you have not authenticated is the last thing
161
+ worth expanding.
162
+ - Verification costs the same for a 500-record list and a 6,000-record one.
163
+ - Re-compressing a bundle at another level does not invalidate its signature.
164
+ The claim is about the list, not about the packing.
165
+
166
+ | Algorithm | Key | Signature |
167
+ |-----------|-----|-----------|
168
+ | `rsa-sha256` | RSA | RSASSA-PKCS1-v1_5 over SHA-256 |
169
+ | `ecdsa-sha256` | EC | ECDSA over SHA-256, DER-encoded |
170
+ | `ed25519` | — | **Reserved.** Not produced or verified by v1 readers. |
171
+
172
+ `ed25519` is named so that a v1 reader refuses it with "this bundle is signed
173
+ with something I am too old for" rather than "unknown algorithm", and so that
174
+ adding it later is not a format change.
175
+
176
+ Verification is opt-in. A reader given no key does not look at this line, and an
177
+ unsigned bundle is a fully valid bundle — its records are still proven against
178
+ `payload_digest` and `snapshot_checksum`. What it is not is *attested*, and a
179
+ consumer is entitled to know which of the two it has. In this library that is
180
+ `Snapshot#trusted?`, and it reaches every screening result as
181
+ `MatchResult#verified?`.
182
+
183
+ ## 7. The payload
184
+
185
+ ### 7.1 Layout
186
+
187
+ Everything after the signature line's terminator is a zlib stream
188
+ ([RFC 1950](https://www.rfc-editor.org/rfc/rfc1950): the 2-byte header, deflate
189
+ data, and the Adler-32 trailer). Not gzip — a gzip header carries a modification
190
+ time and an OS byte, neither of which is a fact about a sanctions list.
191
+
192
+ Compression level **6**. Named here rather than left to a library's default so
193
+ that two producers agree; the level does not affect correctness, and a reader
194
+ must not care what a file was packed at.
195
+
196
+ Decompressed, the payload is newline-delimited JSON: one entity per line, each
197
+ line terminated by `LF`, **including the last**. `payload_bytes` counts those
198
+ bytes, terminators included.
199
+
200
+ ### 7.2 Record order
201
+
202
+ Records are sorted ascending by the hex SHA-256 of the line's own bytes, without
203
+ its terminator — the same per-entity fingerprint the snapshot checksum is built
204
+ from (§7.3).
205
+
206
+ Sorting rather than preserving the publisher's order is what makes the format
207
+ deterministic: a government reshuffling its file is not a new list, and two
208
+ producers who parsed the same list produce the same bytes. Duplicate records
209
+ survive it — an entity listed twice yields two identical lines.
210
+
211
+ ### 7.3 The record shape, and the snapshot checksum
212
+
213
+ Each line is one `ActiveSanction::Entity` serialized as its documented hash, key
214
+ order as the model declares it: `id`, `source`, `source_ref`, `type`, `names`,
215
+ `addresses`, `identifiers`, `dates_of_birth`, `nationalities`, `programs`,
216
+ `listed_on`, `remarks`. Absent scalars are `null`; absent collections are `[]`.
217
+
218
+ `snapshot_checksum` is computed over the records and nothing else — not over
219
+ `fetched_at`, not over `source_version`, not over anything in the container. Its
220
+ definition is the snapshot's, reproduced here so that a non-Ruby implementation
221
+ can compute it:
222
+
223
+ ```
224
+ fingerprints = sorted([ hex_sha256(line) for line in records ]) # ascending
225
+ material = "{schema_version}\n{source}\n" + "".join(f + "\n" for f in fingerprints)
226
+ checksum = "sha256:" + hex_sha256(material)
227
+ ```
228
+
229
+ Two consequences are the reason it is defined that way. A refetch of an
230
+ unchanged list reproduces it exactly, so "has this list changed since we last
231
+ screened?" is answerable; and changing any field of any record changes exactly
232
+ one fingerprint.
233
+
234
+ Because the fingerprints are the sort key of §7.2, a bundle's record order and
235
+ its checksum cannot drift apart.
236
+
237
+ ## 8. Producing a bundle
238
+
239
+ 1. Serialize each entity to compact JSON. Sort the lines by the hex SHA-256 of
240
+ each line (§7.2).
241
+ 2. Concatenate them, each followed by `LF`. This is the uncompressed payload.
242
+ Take its SHA-256 and its length — these are `payload_digest` and
243
+ `payload_bytes`.
244
+ 3. Compute `snapshot_checksum` (§7.3).
245
+ 4. Build the header (§5) and serialize it compactly, keys in order.
246
+ 5. Sign the header line's bytes if a key was supplied (§6).
247
+ 6. Write: magic, `LF`, header, `LF`, signature, `LF`, then the payload
248
+ compressed as a zlib stream at level 6.
249
+
250
+ A producer that streams the payload straight to the file cannot: the header
251
+ states a digest and a length of bytes that do not exist yet, so it must be
252
+ buffered or written in two passes. This library buffers it — the snapshot is
253
+ already in memory by then, and the compressed copy of even the largest published
254
+ list is about 25 MB.
255
+
256
+ ## 9. Reading a bundle
257
+
258
+ In this order. Every step is a refusal, never a repair.
259
+
260
+ 1. **Magic.** Read the first line. If it is not `ACTIVESANCTION-BUNDLE/<digits>`,
261
+ this is not a bundle. If the version is above what the reader implements,
262
+ stop and say so — do not parse further.
263
+ 2. **Header.** Read the second line and parse it. An unknown key, a missing key,
264
+ a malformed digest or an unusable source key are all refusals.
265
+ 3. **Agreement.** `format_version` must equal the version on the magic line.
266
+ 4. **Support.** `schema_version`, `payload_encoding` and `payload_compression`
267
+ must all be ones the reader implements. Anything else is a version problem,
268
+ not a corruption problem, and the two must be reported differently: one is
269
+ fixed by upgrading, the other by fetching the file again.
270
+ 5. **Signature.** Read the third line. If the caller supplied a key, verify it
271
+ over the header line's bytes now, before decompressing anything. A bundle
272
+ with `-` and a caller asking for verification is a failure, distinct from a
273
+ bad signature.
274
+ 6. **Payload.** Inflate incrementally. For each complete line: accumulate it
275
+ into the digest, count its bytes, and parse it into a record. **Stop the
276
+ moment the inflated total exceeds `payload_bytes`** — a file that lies about
277
+ its own size is either damaged or built to exhaust whoever opens it.
278
+ 7. **Completeness.** The compressed stream must terminate cleanly, the last
279
+ record must be terminated, and there must be no bytes after the end of the
280
+ stream.
281
+ 8. **Digest.** The accumulated digest and length must equal `payload_digest` and
282
+ `payload_bytes`.
283
+ 9. **Content.** Re-derive `snapshot_checksum` from the records that actually
284
+ arrived (§7.3) and compare it to the header's. Count them and compare to
285
+ `record_count`.
286
+
287
+ Steps 8 and 9 are both required, and they catch different things: 8 says the
288
+ bytes are the bytes that were packed, and 9 says those bytes are the list the
289
+ header describes.
290
+
291
+ Nothing partial is ever returned. A reader that hands back the eight thousand
292
+ records it managed to parse out of nineteen thousand produces a report that
293
+ looks exactly like a clean one, which is the most expensive thing a screening
294
+ library can get wrong.
295
+
296
+ ### Failure modes
297
+
298
+ | Condition | `ActiveSanction::Snapshot::Bundle` raises |
299
+ |-----------|--------------------------------------------|
300
+ | Not a bundle, truncated, digest or count mismatch, trailing bytes, oversized payload, magic disagrees with header | `Corrupt` |
301
+ | Signature does not verify under the key given | `UntrustedSignature` |
302
+ | No signature, and verification was asked for | `Unsigned` (a subclass of the above) |
303
+ | Format version, snapshot schema, payload encoding or signature algorithm this reader does not implement | `UnsupportedFormat` |
304
+
305
+ `Corrupt` and `UntrustedSignature` are deliberately different: "these bytes were
306
+ damaged" and "these bytes came from somebody else" are different incidents, and
307
+ only one of them is fixed by downloading the file again.
308
+
309
+ ## 10. Determinism, and what is comparable
310
+
311
+ **The same snapshot produces the same bundle.** Record order is derived from
312
+ content, header keys have a fixed order, the compression level is stated rather
313
+ than defaulted, and nothing anywhere in the file says when it was written.
314
+
315
+ Two things qualify that, and both are deliberate:
316
+
317
+ - **The header line is the invariant that survives everything.** zlib
318
+ implementations disagree about what to emit for identical input, so a file
319
+ packed by zlib-ng and one packed by zlib may differ byte for byte while
320
+ holding exactly the same list. `payload_digest` is over the *uncompressed*
321
+ payload for this reason: it is the half of the file two machines can be held
322
+ to. Two bundles with identical header lines carry identical records.
323
+ - **A signature line may differ between two signings.** ECDSA is randomized.
324
+ Determinism is a property of the magic line, the header and the payload.
325
+
326
+ `gem_version` and `generator` are inputs, not noise: two mirrors that bundled the
327
+ same list produce different files, and should, because they are different
328
+ publications of it. The list they carry is the same, and `snapshot_checksum`
329
+ says so.
330
+
331
+ ## 11. Versioning
332
+
333
+ `format_version` is the format's own number and moves independently of the gem's.
334
+
335
+ - A reader **must** refuse a `format_version` above what it implements, at
336
+ step 1, with an error that says to upgrade. It must never read a newer file
337
+ partially: a newer shape will usually deserialize into plausible, wrong
338
+ records, and the symptom is names that quietly stop matching.
339
+ - A reader **must** accept every version at or below its own.
340
+ - Within a format version, no key changes meaning, no key is removed, and no key
341
+ is reordered. Keys are not added either: a reader that refuses unknown keys
342
+ (§5) would reject them, which is what makes the refusal safe to require.
343
+ - `schema_version` is the record model's number and moves on its own. A bundle
344
+ can carry records too new for a reader while its container is a version that
345
+ reader understands, which is why the two are checked separately.
346
+
347
+ ## 12. A worked example
348
+
349
+ One entity, unsigned. Reproduce it with:
350
+
351
+ ```ruby
352
+ entity = ActiveSanction::Entity.new(
353
+ source: :ofac_sdn, source_ref: "2674", type: :individual, programs: ["SDGT"],
354
+ names: [ActiveSanction::Name.new(value: "AL ZAWAHIRI, Aiman", kind: :primary)]
355
+ )
356
+ snapshot = ActiveSanction::Snapshot.new(
357
+ source: :ofac_sdn, entities: [entity],
358
+ fetched_at: Time.utc(2026, 8, 28, 9, 30, 0), source_version: "2026-08-28"
359
+ )
360
+ File.open("example.asb", "wb") { |io| ActiveSanction::Snapshot::Bundle.write(snapshot, io: io) }
361
+ ```
362
+
363
+ The magic line:
364
+
365
+ ```
366
+ ACTIVESANCTION-BUNDLE/1
367
+ ```
368
+
369
+ The header line, wrapped here and written as one line in the file:
370
+
371
+ ```json
372
+ {"format_version":1,"gem_version":"1.0.0","generator":"active_sanction/1.0.0",
373
+ "source":"ofac_sdn","schema_version":2,
374
+ "snapshot_checksum":"sha256:ed0eebb275106f4e7075683af4ca7ae17c7aa100cd617f8bc41eebe9d9cf797d",
375
+ "record_count":1,"fetched_at":"2026-08-28T09:30:00Z","source_version":"2026-08-28",
376
+ "payload_encoding":"ndjson","payload_compression":"deflate",
377
+ "payload_digest":"sha256:f07b780b3849a40ad3d2ca0a679469809ffa8e69006728123a33e1bb156c32b9",
378
+ "payload_bytes":293}
379
+ ```
380
+
381
+ The signature line:
382
+
383
+ ```
384
+ -
385
+ ```
386
+
387
+ The payload, decompressed — 293 bytes, one line and its terminator:
388
+
389
+ ```json
390
+ {"id":"ofac_sdn:2674","source":"ofac_sdn","source_ref":"2674","type":"individual","names":[{"value":"AL ZAWAHIRI, Aiman","kind":"primary","quality":null,"script":null}],"addresses":[],"identifiers":[],"dates_of_birth":[],"nationalities":[],"programs":["SDGT"],"listed_on":null,"remarks":null}
391
+ ```
392
+
393
+ The whole file is 695 bytes. Checking the two digests by hand is the shortest
394
+ useful conformance test for an implementation:
395
+
396
+ ```bash
397
+ ruby -rzlib -rdigest -e 'b = File.binread(ARGV[0])
398
+ puts Digest::SHA256.hexdigest(Zlib::Inflate.inflate(b.split("\n", 4)[3]))' example.asb
399
+ # => f07b780b3849a40ad3d2ca0a679469809ffa8e69006728123a33e1bb156c32b9
400
+ ```
401
+
402
+ Everything before the payload is text, so the rest of the file needs no tooling
403
+ at all:
404
+
405
+ ```bash
406
+ head -3 example.asb
407
+ ```
@@ -0,0 +1,170 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module ActiveSanction
7
+ # A place a sanctions list attached to an entity. OFAC ships 25,078 of them in
8
+ # ADD.CSV; the UN ships far fewer and far thinner ones.
9
+ #
10
+ # ActiveSanction::Address.new(
11
+ # street: "Ave. Luis Maria Drago 1136",
12
+ # city: "Buenos Aires",
13
+ # state_province: nil,
14
+ # postal_code: "C1414",
15
+ # country: "Argentina",
16
+ # note: "as of early 2016"
17
+ # )
18
+ #
19
+ # Every field is optional because the publishers populate wildly different
20
+ # subsets: the UN routinely supplies only COUNTRY plus a free-text NOTE, and
21
+ # an address type that insisted on a street would drop those rows entirely.
22
+ # What it will not accept is an address that says nothing at all -- a record
23
+ # with every field blank is a parsing accident, not a location.
24
+ #
25
+ # A pure data holder: it stores what the publisher said and nothing more. No
26
+ # geocoding, no country-code lookup, no case folding. Instances are frozen on
27
+ # construction and compare by value.
28
+ class Address
29
+ extend T::Sig
30
+
31
+ # Canonical member order, from the most specific part of an address to the
32
+ # least. Snapshot (#8) checksums the serialized form, so #to_h must lay its
33
+ # keys out the same way every time.
34
+ #
35
+ # @api private
36
+ MEMBERS = T.let(%i[street city state_province postal_code country note].freeze, T::Array[Symbol])
37
+
38
+ # `note` is an annotation about the address rather than a part of it, so it
39
+ # is rendered apart from the rest by #to_s.
40
+ #
41
+ # @api private
42
+ PARTS = T.let((MEMBERS - %i[note]).freeze, T::Array[Symbol])
43
+
44
+ # Every field is nilable because the publishers populate wildly different
45
+ # subsets of them; what #initialize refuses is all six being empty at once.
46
+ sig { returns(T.nilable(String)) }
47
+ attr_reader :street
48
+
49
+ sig { returns(T.nilable(String)) }
50
+ attr_reader :city
51
+
52
+ sig { returns(T.nilable(String)) }
53
+ attr_reader :state_province
54
+
55
+ sig { returns(T.nilable(String)) }
56
+ attr_reader :postal_code
57
+
58
+ sig { returns(T.nilable(String)) }
59
+ attr_reader :country
60
+
61
+ sig { returns(T.nilable(String)) }
62
+ attr_reader :note
63
+
64
+ # Rebuilds an address from #to_h output. Accepts string keys, so a record
65
+ # that has been through JSON round-trips without a separate coercion step.
66
+ sig { params(hash: T.untyped).returns(T.attached_class) }
67
+ def self.from_h(hash)
68
+ attributes = hash.to_h.transform_keys(&:to_sym)
69
+ unknown = attributes.keys - MEMBERS
70
+ raise InvalidArgument, "unknown Address attribute(s): #{unknown.join(", ")}" if unknown.any?
71
+
72
+ new(**attributes)
73
+ end
74
+
75
+ # Untyped on purpose, and the same choice Entity makes: all six are the
76
+ # publisher's own text arriving as whatever the parser made of it, and
77
+ # #string_or_nil says below what happens to it.
78
+ sig do
79
+ params(street: T.untyped, city: T.untyped, state_province: T.untyped, postal_code: T.untyped,
80
+ country: T.untyped, note: T.untyped).void
81
+ end
82
+ def initialize(street: nil, city: nil, state_province: nil, postal_code: nil, country: nil, note: nil)
83
+ @street = T.let(string_or_nil(street), T.nilable(String))
84
+ @city = T.let(string_or_nil(city), T.nilable(String))
85
+ @state_province = T.let(string_or_nil(state_province), T.nilable(String))
86
+ @postal_code = T.let(string_or_nil(postal_code), T.nilable(String))
87
+ @country = T.let(string_or_nil(country), T.nilable(String))
88
+ @note = T.let(string_or_nil(note), T.nilable(String))
89
+ reject_empty!
90
+ freeze
91
+ end
92
+
93
+ # The address parts the publisher actually filled in, in canonical order.
94
+ sig { returns(T::Array[String]) }
95
+ def parts
96
+ PARTS.filter_map { |member| public_send(member) }
97
+ end
98
+
99
+ # True when nothing but a note survived parsing -- the UN's "as of early
100
+ # 2016" with no place attached. Such an address is worth keeping (it is
101
+ # evidence the publisher had something) but is not worth matching on.
102
+ sig { returns(T::Boolean) }
103
+ def note_only? = parts.empty?
104
+
105
+ sig { returns(T::Hash[Symbol, T.untyped]) }
106
+ def to_h
107
+ {
108
+ street: street,
109
+ city: city,
110
+ state_province: state_province,
111
+ postal_code: postal_code,
112
+ country: country,
113
+ note: note
114
+ }
115
+ end
116
+
117
+ # A single line, which is how an address is displayed in a hit list and how
118
+ # the normalizer (#26) will want it before folding.
119
+ sig { returns(String) }
120
+ def to_s
121
+ line = parts.join(", ")
122
+ note = self.note
123
+ return line if note.nil?
124
+
125
+ line.empty? ? note : "#{line} (#{note})"
126
+ end
127
+
128
+ # Class is part of the comparison to keep #== and #hash agreeing, which is
129
+ # what Hash and Set rely on.
130
+ sig { params(other: T.untyped).returns(T::Boolean) }
131
+ def ==(other)
132
+ return false unless other.instance_of?(self.class)
133
+
134
+ to_h == other.to_h
135
+ end
136
+ alias eql? ==
137
+
138
+ sig { returns(Integer) }
139
+ def hash
140
+ [self.class, to_h].hash
141
+ end
142
+
143
+ sig { returns(String) }
144
+ def inspect
145
+ "#<#{self.class} #{to_s.inspect}>"
146
+ end
147
+
148
+ private
149
+
150
+ # Surrounding whitespace is stripped -- the delimited sources pad their
151
+ # fields -- but nothing else is touched. Case, diacritics and punctuation
152
+ # are all signal the matcher needs to see as published. A field that was
153
+ # only whitespace is nil: an empty string is not a smaller address, it is
154
+ # an absent field, and storing one would split two identical addresses.
155
+ sig { params(value: T.untyped).returns(T.nilable(String)) }
156
+ def string_or_nil(value)
157
+ return nil if value.nil?
158
+
159
+ string = value.to_s.strip
160
+ string.empty? ? nil : -string
161
+ end
162
+
163
+ sig { void }
164
+ def reject_empty!
165
+ return if MEMBERS.any? { |member| public_send(member) }
166
+
167
+ raise InvalidArgument, "an address needs at least one populated field"
168
+ end
169
+ end
170
+ end