open-standards/openpay/crates/op-screening/src/storage.rs
Transaction Science 2d7de0396c
All checks were successful
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 10s
standards workspaces build + test / macOS energy backends (pull_request) Has been skipped
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 1m4s
standards workspaces build + test / openpay (openpay, openpay, linux-arm64) (pull_request) Successful in 6m13s
openpay: a PEP match is recorded as a PEP match, not an OFAC sanctions hit
op-connect's PEP index tagged every entry `SanctionsList::OfacConsolidated`,
because the enum had no place for a PEP list. Its comment said the operator
surface "should not surface this". But a PEP index is a `Screener`, and every
screen appends `source_list.label()` to the screener's signed, hash-chained
audit log. So each PEP match was written into the tamper-evident compliance
record as "OFAC Consolidated": a sanctions designation (block, report) where
there was a due-diligence flag. An adverse-media index had the same problem:
it too could only tag its entries with a sanctions authority.

- `SanctionsList` is now `WatchList`, the term `ScreenerConfig::watchlist_filter`
  already used. It gains `Pep` ("PEP list") and `AdverseMedia` ("Adverse
  media"), and `is_sanctions()`, which is false for exactly those two. Its
  docs say why a match must never be recorded under a list it did not come
  from. A deprecated `SanctionsList` alias keeps the old name compiling.
- `build_pep_index_from_names` tags its entries `WatchList::Pep`.
- Serialized forms are unchanged: the enum's variant names stay the same, so
  stored indexes still load. No published vector or doc carries the label.

Tests:
- `a_pep_match_is_audited_as_a_pep_match_not_a_sanctions_match` screens a PEP
  fixture and checks that the audit chain verifies and that its row names
  "PEP list".
- `only_the_authorities_are_sanctions_lists` and `every_list_has_its_own_label`
  pin the kinds and labels. An exhaustive match makes a new list fail to
  compile until it is classified.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 20:47:42 -04:00

254 lines
8.2 KiB
Rust

//! In-memory index storage with on-disk CBOR snapshot.
//!
//! The sanctions corpus is small (~30k entries across all lists in
//! 2026) but every screen call has to be fast — payment auth has a
//! ~100ms budget end-to-end. We sit a tiny bloom filter in front of
//! an inverted index of normalised name tokens; the bloom rules out
//! 99% of misses without touching the hash map.
use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::io::{BufReader, BufWriter};
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
use crate::lists::SanctionedEntity;
use crate::normalize::{NormalizedName, normalize};
/// Stable reference into [`SanctionsIndex::by_id`].
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct EntityRef(pub String);
/// Tiny in-memory bloom filter.
///
/// We don't need a serious bloom filter — 30k entries, dozens of
/// tokens each. A 64-bit bitmap with two hash functions gives us a
/// false-positive rate around 1% at this fill level, which is the
/// design point: the bloom is only a coarse pre-filter, false
/// positives just cost a hash-map miss.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BloomFilter {
/// 4096-bit bitmap, 64 `u64`s.
bits: Vec<u64>,
}
impl Default for BloomFilter {
fn default() -> Self {
Self::new()
}
}
impl BloomFilter {
const BITS: usize = 4096;
const WORDS: usize = Self::BITS / 64;
/// Empty filter.
#[must_use]
pub fn new() -> Self {
Self { bits: vec![0u64; Self::WORDS] }
}
/// Insert a token's two hash positions.
pub fn insert(&mut self, token: &str) {
let (h1, h2) = Self::hashes(token);
let (w1, b1) = (h1 / 64, h1 % 64);
let (w2, b2) = (h2 / 64, h2 % 64);
self.bits[w1] |= 1u64 << b1;
self.bits[w2] |= 1u64 << b2;
}
/// Membership test (with false-positive rate).
#[must_use]
pub fn might_contain(&self, token: &str) -> bool {
let (h1, h2) = Self::hashes(token);
let (w1, b1) = (h1 / 64, h1 % 64);
let (w2, b2) = (h2 / 64, h2 % 64);
(self.bits[w1] & (1u64 << b1)) != 0 && (self.bits[w2] & (1u64 << b2)) != 0
}
fn hashes(token: &str) -> (usize, usize) {
// BLAKE3 keyed twice. Plenty fast for our scale and gives us
// two independent uniform positions without rolling our own
// hash family.
let h1 = blake3::keyed_hash(&[1u8; 32], token.as_bytes());
let h2 = blake3::keyed_hash(&[2u8; 32], token.as_bytes());
let h1_bytes = h1.as_bytes();
let h2_bytes = h2.as_bytes();
let p1 = u64::from_le_bytes([
h1_bytes[0], h1_bytes[1], h1_bytes[2], h1_bytes[3],
h1_bytes[4], h1_bytes[5], h1_bytes[6], h1_bytes[7],
]) as usize;
let p2 = u64::from_le_bytes([
h2_bytes[0], h2_bytes[1], h2_bytes[2], h2_bytes[3],
h2_bytes[4], h2_bytes[5], h2_bytes[6], h2_bytes[7],
]) as usize;
(p1 % Self::BITS, p2 % Self::BITS)
}
}
/// Combined bloom + inverted-index over a population of
/// [`SanctionedEntity`]s.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SanctionsIndex {
/// Bloom over every normalised token in the corpus.
pub bloom: BloomFilter,
/// Inverted index: normalised token -> entity refs that contain it.
pub by_name: HashMap<NormalizedName, Vec<EntityRef>>,
/// Primary store: entity id -> full record.
pub by_id: HashMap<String, SanctionedEntity>,
}
impl SanctionsIndex {
/// Build a fresh index from a list of entities.
#[must_use]
pub fn build(entities: Vec<SanctionedEntity>) -> Self {
let mut idx = Self::default();
for ent in entities {
idx.insert(ent);
}
idx
}
/// Insert a single entity, updating bloom and inverted index.
pub fn insert(&mut self, entity: SanctionedEntity) {
let id = entity.id.clone();
// Index every normalised token of every name + alias.
let mut names: Vec<String> = vec![entity.name.clone()];
names.extend(entity.name_aliases.iter().cloned());
for name in &names {
let norm = normalize(name);
for token in norm.as_str().split_whitespace() {
self.bloom.insert(token);
self.by_name
.entry(NormalizedName(token.to_string()))
.or_default()
.push(EntityRef(id.clone()));
}
}
self.by_id.insert(id, entity);
}
/// Number of indexed entities.
#[must_use]
pub fn len(&self) -> usize {
self.by_id.len()
}
/// Whether the index is empty.
#[must_use]
pub fn is_empty(&self) -> bool {
self.by_id.is_empty()
}
/// All entity IDs that share at least one normalised token with
/// `normalized_query`. The bloom filter narrows the per-token
/// lookups; the inverted index resolves to entity IDs.
#[must_use]
pub fn candidate_ids(&self, normalized_query: &str) -> HashSet<String> {
let mut out: HashSet<String> = HashSet::new();
for token in normalized_query.split_whitespace() {
if !self.bloom.might_contain(token) {
continue;
}
if let Some(refs) = self.by_name.get(&NormalizedName(token.to_string())) {
for r in refs {
out.insert(r.0.clone());
}
}
}
out
}
/// Serialise the index to a CBOR file.
pub fn save_snapshot(&self, path: &Path) -> Result<()> {
let file = File::create(path)?;
let writer = BufWriter::new(file);
ciborium::into_writer(self, writer).map_err(|e| Error::Cbor(e.to_string()))?;
Ok(())
}
/// Read an index back from a CBOR file written by [`save_snapshot`].
pub fn load_snapshot(path: &Path) -> Result<Self> {
let file = File::open(path)?;
let reader = BufReader::new(file);
let idx: Self =
ciborium::from_reader(reader).map_err(|e| Error::Cbor(e.to_string()))?;
Ok(idx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lists::{EntityType, WatchList};
use chrono::Utc;
fn fake(id: &str, name: &str) -> SanctionedEntity {
SanctionedEntity {
id: id.to_string(),
name: name.to_string(),
name_aliases: vec![],
entity_type: EntityType::Individual,
dob: None,
place_of_birth: None,
addresses: vec![],
nationalities: vec![],
identifications: vec![],
programs: vec![],
last_updated: Utc::now(),
source_list: WatchList::OfacSdn,
}
}
#[test]
fn build_and_lookup() {
let idx = SanctionsIndex::build(vec![
fake("1", "John Smith"),
fake("2", "Maria Garcia"),
]);
assert_eq!(idx.len(), 2);
let cands = idx.candidate_ids("john smith");
assert!(cands.contains("1"));
assert!(!cands.contains("2"));
}
#[test]
fn bloom_membership_works() {
let mut bf = BloomFilter::new();
bf.insert("alpha");
bf.insert("beta");
assert!(bf.might_contain("alpha"));
assert!(bf.might_contain("beta"));
// Highly likely not-contained — over 4096 bits, single token,
// this isn't a false-positive in practice.
assert!(!bf.might_contain("gamma_unlikely_token_xyz"));
}
#[test]
fn snapshot_roundtrip() {
let idx = SanctionsIndex::build(vec![fake("1", "Test Name")]);
let dir = tempdir();
let path = dir.join("snap.cbor");
idx.save_snapshot(&path).expect("save");
let loaded = SanctionsIndex::load_snapshot(&path).expect("load");
assert_eq!(loaded.len(), 1);
assert!(loaded.by_id.contains_key("1"));
}
fn tempdir() -> std::path::PathBuf {
let mut p = std::env::temp_dir();
let uniq = format!(
"op-screening-test-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
);
p.push(uniq);
std::fs::create_dir_all(&p).expect("mkdir");
p
}
}