Class: ActiveSanction::Configuration
- Inherits:
-
Object
- Object
- ActiveSanction::Configuration
- Extended by:
- T::Sig
- Defined in:
- lib/active_sanction/configuration.rb
Overview
Library-wide settings, set once at boot:
ActiveSanction.configure do |c|
c.user_agent = "my-app/1.0 (compliance@example.com)"
end
The fetch layer's settings live here, plus which sources a sync runs,
where they are stored, and the thresholds a screening call defaults to.
Every value has a working default, so an application that configures
nothing still runs -- the point of configure is that a caller can
identify itself, not that it must recite the whole schema.
Constant Summary collapse
- DEFAULT_USER_AGENT =
OFAC returns 403 to a request with no User-Agent, so this cannot be nil. The default identifies the library and points at its source, which is what a publisher watching its logs actually wants; applications should still override it with their own contact address, because a publisher that needs to reach whoever is hammering an endpoint can only reach the gem's repository otherwise.
T.let( -"active_sanction/#{VERSION} (+https://github.com/Babystep-Technologies/active_sanction)", String )
- DEFAULT_OPEN_TIMEOUT =
Generous by list-download standards: these are government file servers redirecting to blob storage, and a 126 MB XML body arrives in bursts with real gaps between them. The read timeout is per-read, not per-request, so it does not cap how long a large download may take overall.
T.let(10, Numeric)
- DEFAULT_READ_TIMEOUT =
Seconds to wait for the next chunk of a body, not for the whole of one.
T.let(60, Numeric)
- DEFAULT_MAX_REDIRECTS =
OFAC's download URLs 302 to blob storage, one hop. Five leaves room for a publisher to add a vanity domain or a region redirect without a release of this gem, and still stops a redirect chain from becoming a crawl.
T.let(5, Integer)
- DEFAULT_MAX_RETRIES =
Three attempts total for a transient failure. Sanctions syncs are batch work with no user waiting on them, but they are also not worth an hour of a government server's patience.
T.let(2, Integer)
- DEFAULT_RETRY_BACKOFF =
Seconds before the first retry, doubling on each one after it.
T.let(1.0, Numeric)
- DEFAULT_CACHE_DIRNAME =
The directory this gem takes for itself under whichever cache root wins.
T.let("active_sanction", String)
- DEFAULT_STORAGE_DIRNAME =
Where Storage::FileSystem (#24) keeps parsed snapshots. Deliberately not under
cache_dir, and the difference is the whole distinction between the two directories: everything under~/.cacheis recoverable by fetching again and a user is entitled to delete it, while a stored snapshot is the system of record -- once a publisher overwrites its file, the list version a past decision was screened against exists only here. T.let(".active_sanction", String)
- DEFAULT_RETAIN_PAYLOADS =
How many raw payloads PayloadCache keeps per source. Three is enough to diff a suspicious list against the two that came before it, and small enough that a cache directory does not quietly grow by 126 MB a day. An installation that must keep every version it ever screened against wants its own retention storage, not a bigger number here.
T.let(3, Integer)
- DEFAULT_SYNC_CONCURRENCY =
How many publishers a sync fetches from at once. One, because these are government file servers with nobody waiting on the result, and a library that opens four connections to Treasury by default is a library that gets a jurisdiction's operators asking who we are. Raising it fetches from more publishers at a time and never harder from any one of them -- Sync groups sources by the host they download from and runs each group in order -- so the number is a bound on how many governments are being asked at once, not on how fast any one of them is asked.
T.let(1, Integer)
- DEFAULT_DOCTOR_TOLERANCE =
How far one of Doctor's measurements may move from what it was at the last sync before the run says so. A tenth, because these lists move by single-digit percentages between syncs -- a designation round is dozens of records against tens of thousands -- while the changes this is looking for halve a fill rate. Tightening it finds drift sooner and reports more of the movement that is just the list changing; loosening it does the reverse, and a diagnostic nobody reads because it always says something is worse than one that says slightly less.
T.let(0.10, Float)
- DEFAULT_STALE_AFTER =
The launch lists change roughly daily at most, so a source last confirmed within a day is not worth asking about again when a caller is only trying to decide whether a sync is due. This is what #stale? measures against; it does not cap how long a cached copy may be used, which is the calling application's policy to set.
T.let(86_400, T.nilable(Numeric))
- DEFAULT_XML_BACKEND =
Which XML library the XML toolkit parses with. REXML is stdlib, needs no build step, and -- the part that decides it -- gives every installation the same answer. Snapshot checksums a list's parsed content and a screening decision has to be re-derivable months later, so the parser must not be chosen by whether the host app happened to load Nokogiri.
c.xml_backend = :nokogiri # libxml2, for a host parsing OFAC's 126 MB XMLSee Parsers::XmlRecords::Backends for the contract a backend implements.
T.let(:rexml, Symbol)
- DEFAULT_CANDIDATE_LIMIT =
How many names the index (#31) hands the scorer per query.
200 is where the recall curve flattens on this corpus, and a deliberately damaged query still finds its own name inside the first handful. Raising it buys precision nothing -- the scorer already sees everything that could clear a threshold -- and spends milliseconds a service does not have.
What it costs is the scorer's cost, and that depends on the threshold rather than on this number alone: 200 candidates is roughly 16 ms of comparison under YJIT at a threshold of 75 and roughly 46 ms with no threshold at all, because the early exits are what stop the expensive comparisons running on candidates that cannot clear. See Scorer::NameScore, and
rake benchmark:scorer. T.let(200, Integer)
- DEFAULT_SCREENING_THRESHOLD =
The lowest score a screening call reports, on the scorer's 0..100 scale.
75 is where the scorer's own table separates the two things it has to separate. An inverted name blends to 90.4 and a company named by half its words to 84.2 -- both true matches, both reported.
kim jong unagainstkim yong cholblends to 54.8, and a query of one common given name against a full listed name lands in the high seventies with nothing but the name agreeing, which is why the identifiers exist and why this is a floor rather than a verdict.It is also most of what a screening call costs. Everything a threshold turns off is a comparison that could not have changed the answer -- see Scorer::NameScore -- so 75 is roughly a third of the work of screening with no threshold at all, and returns the same scores.
T.let(75.0, Float)
- DEFAULT_SCREENING_LIMIT =
How many results a screening call returns, highest score first.
Ten is a review queue rather than a report: a human clears alerts one at a time, and a call that returned every name over the threshold would bury the one that matters under the fifty that share a given name. A caller writing an investigation tool rather than an onboarding check raises it per query.
T.let(10, Integer)
- DEFAULT_SOURCES =
Which lists a sync runs, by key. nil means every registered source, which is what an application that has not thought about it should get: requiring an explicit list would mean a gem adding a jurisdiction had no way to take effect without an edit to the host app's initializer.
T.let(nil, T.nilable(T::Array[Symbol]))
Instance Attribute Summary collapse
- #cache_dir ⇒ String
-
#candidate_limit ⇒ Integer
See DEFAULT_CANDIDATE_LIMIT.
-
#doctor_tolerance ⇒ Float
See DEFAULT_DOCTOR_TOLERANCE.
-
#instrumenter ⇒ T.untyped
Anything answering
#call(event), or nil for the default, which is that nothing is listening. -
#logger ⇒ T.untyped
Anything Logger-shaped, which is what #logger= checks for and all this library ever asks of it.
- #max_redirects ⇒ Integer
- #max_retries ⇒ Integer
-
#normalizer_dictionary ⇒ Normalizer::Dictionary
The token lists the normalizer strips per entity type.
-
#open_timeout ⇒ Numeric
Seconds.
- #read_timeout ⇒ Numeric
- #retain_payloads ⇒ Integer
- #retry_backoff ⇒ Numeric
-
#scorer_weights ⇒ Scorer::Weights
What each signal the scorer reads is worth.
-
#screening_limit ⇒ Integer
See DEFAULT_SCREENING_LIMIT.
-
#screening_threshold ⇒ Float
See DEFAULT_SCREENING_THRESHOLD.
-
#sources ⇒ Array<Symbol>?
nil means every registered source.
-
#stale_after ⇒ Numeric?
nil disables the staleness clock entirely -- see #stale_after=.
- #storage_dir ⇒ String
-
#sync_concurrency ⇒ Integer
See DEFAULT_SYNC_CONCURRENCY.
- #user_agent ⇒ String
- #xml_backend ⇒ Symbol
Class Method Summary collapse
- .default_cache_dir ⇒ String
- .default_storage_dir ⇒ String
-
.doctor_tolerance!(value) ⇒ Float
Shared with Doctor, so a per-run
tolerance:is held to the same rule as the configured default. -
.retain_payloads!(value) ⇒ Integer
Shared by PayloadCache, so a cache built with an explicit
retain:fails the same way as a misconfigured global. -
.settings ⇒ Array<Symbol>
Every setting a caller may name, which is what
Client.newandConfiguration#withaccept as keyword arguments and what an unknown one is reported against. -
.sync_concurrency!(value) ⇒ Integer
Shared by Sync, so a run given an explicit
concurrency:fails the same way as a misconfigured global. -
.user_agent!(value) ⇒ String
Shared by HttpClient, so a client built with an explicit
user_agent:fails the same way and with the same message as a misconfigured global.
Instance Method Summary collapse
-
#apply(**overrides) ⇒ T.self_type
Assigns through the writers, so a value given to
Client.newis held to exactly the rule the same value set in aconfigureblock is held to, and fails with the same message. -
#freeze ⇒ T.self_type
A built configuration is frozen, and a client freezes the one it holds.
- #initialize ⇒ void constructor
-
#initialize_copy(other) ⇒ void
A copy starts unfrozen -- that is Ruby's rule for
dup, and the reasonwithcan derive a mutable configuration from a frozen one -- and drops the store that was derived fromstorage_dir, so a copy that moves the directory reads the directory it names. -
#storage ⇒ Storage::Base
Where synced lists are read from and written to.
-
#storage=(value) ⇒ void
A Storage::Base subclass, which is the contract the whole query path is written against -- see Storage::Base, and the conformance group an adapter that is not this is held to.
-
#with(**overrides) ⇒ Configuration
A copy of these settings with some of them changed:.
Constructor Details
#initialize ⇒ void
255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 |
# File 'lib/active_sanction/configuration.rb', line 255 def initialize @user_agent = T.let(DEFAULT_USER_AGENT, String) @open_timeout = T.let(DEFAULT_OPEN_TIMEOUT, Numeric) @read_timeout = T.let(DEFAULT_READ_TIMEOUT, Numeric) @max_redirects = T.let(DEFAULT_MAX_REDIRECTS, Integer) @max_retries = T.let(DEFAULT_MAX_RETRIES, Integer) @retry_backoff = T.let(DEFAULT_RETRY_BACKOFF, Numeric) @cache_dir = T.let(self.class.default_cache_dir, String) @storage_dir = T.let(self.class.default_storage_dir, String) @retain_payloads = T.let(DEFAULT_RETAIN_PAYLOADS, Integer) @stale_after = T.let(DEFAULT_STALE_AFTER, T.nilable(Numeric)) @sources = T.let(DEFAULT_SOURCES, T.nilable(T::Array[Symbol])) @xml_backend = T.let(DEFAULT_XML_BACKEND, Symbol) @sync_concurrency = T.let(DEFAULT_SYNC_CONCURRENCY, Integer) @doctor_tolerance = T.let(DEFAULT_DOCTOR_TOLERANCE, Float) @candidate_limit = T.let(DEFAULT_CANDIDATE_LIMIT, Integer) @screening_threshold = T.let(DEFAULT_SCREENING_THRESHOLD, Float) @screening_limit = T.let(DEFAULT_SCREENING_LIMIT, Integer) @normalizer_dictionary = T.let(Normalizer::Dictionary.default, Normalizer::Dictionary) @scorer_weights = T.let(Scorer::Weights.default, Scorer::Weights) @logger = T.let(nil, T.untyped) @instrumenter = T.let(nil, T.untyped) @storage = T.let(nil, T.nilable(Storage::Base)) @default_storage = T.let(nil, T.nilable(Storage::Base)) end |
Instance Attribute Details
#cache_dir ⇒ String
193 194 195 |
# File 'lib/active_sanction/configuration.rb', line 193 def cache_dir @cache_dir end |
#candidate_limit ⇒ Integer
See DEFAULT_CANDIDATE_LIMIT. A per-query limit: overrides it.
223 224 225 |
# File 'lib/active_sanction/configuration.rb', line 223 def candidate_limit @candidate_limit end |
#doctor_tolerance ⇒ Float
See DEFAULT_DOCTOR_TOLERANCE. A per-run tolerance: overrides it.
215 216 217 |
# File 'lib/active_sanction/configuration.rb', line 215 def doctor_tolerance @doctor_tolerance end |
#instrumenter ⇒ T.untyped
Anything answering #call(event), or nil for the default, which is that
nothing is listening. See #instrumenter= and Instrumentation.
252 253 254 |
# File 'lib/active_sanction/configuration.rb', line 252 def instrumenter @instrumenter end |
#logger ⇒ T.untyped
Anything Logger-shaped, which is what #logger= checks for and all this
library ever asks of it. Declaring ::Logger would make a host's
wrapper, a Rails logger broadcast or a test spy a type error rather than
the perfectly good logger each of them is.
247 248 249 |
# File 'lib/active_sanction/configuration.rb', line 247 def logger @logger end |
#max_redirects ⇒ Integer
184 185 186 |
# File 'lib/active_sanction/configuration.rb', line 184 def max_redirects @max_redirects end |
#max_retries ⇒ Integer
187 188 189 |
# File 'lib/active_sanction/configuration.rb', line 187 def max_retries @max_retries end |
#normalizer_dictionary ⇒ Normalizer::Dictionary
The token lists the normalizer strips per entity type. Defaults to the shipped ones; see #normalizer_dictionary= and Normalizer::Dictionary.
236 237 238 |
# File 'lib/active_sanction/configuration.rb', line 236 def normalizer_dictionary @normalizer_dictionary end |
#open_timeout ⇒ Numeric
Seconds. Numeric rather than Integer because the writers run every value through Float(), so a timeout set to 2.5 stays 2.5.
178 179 180 |
# File 'lib/active_sanction/configuration.rb', line 178 def open_timeout @open_timeout end |
#read_timeout ⇒ Numeric
181 182 183 |
# File 'lib/active_sanction/configuration.rb', line 181 def read_timeout @read_timeout end |
#retain_payloads ⇒ Integer
199 200 201 |
# File 'lib/active_sanction/configuration.rb', line 199 def retain_payloads @retain_payloads end |
#retry_backoff ⇒ Numeric
190 191 192 |
# File 'lib/active_sanction/configuration.rb', line 190 def retry_backoff @retry_backoff end |
#scorer_weights ⇒ Scorer::Weights
What each signal the scorer reads is worth. See #scorer_weights=.
240 241 242 |
# File 'lib/active_sanction/configuration.rb', line 240 def scorer_weights @scorer_weights end |
#screening_limit ⇒ Integer
See DEFAULT_SCREENING_LIMIT. A per-query limit: overrides it.
231 232 233 |
# File 'lib/active_sanction/configuration.rb', line 231 def screening_limit @screening_limit end |
#screening_threshold ⇒ Float
See DEFAULT_SCREENING_THRESHOLD. A per-query threshold: overrides it.
227 228 229 |
# File 'lib/active_sanction/configuration.rb', line 227 def screening_threshold @screening_threshold end |
#sources ⇒ Array<Symbol>?
nil means every registered source. Keys are not resolved here; see #sources=.
208 209 210 |
# File 'lib/active_sanction/configuration.rb', line 208 def sources @sources end |
#stale_after ⇒ Numeric?
nil disables the staleness clock entirely -- see #stale_after=.
203 204 205 |
# File 'lib/active_sanction/configuration.rb', line 203 def stale_after @stale_after end |
#storage_dir ⇒ String
196 197 198 |
# File 'lib/active_sanction/configuration.rb', line 196 def storage_dir @storage_dir end |
#sync_concurrency ⇒ Integer
See DEFAULT_SYNC_CONCURRENCY. A per-run concurrency: overrides it.
219 220 221 |
# File 'lib/active_sanction/configuration.rb', line 219 def sync_concurrency @sync_concurrency end |
#user_agent ⇒ String
173 174 175 |
# File 'lib/active_sanction/configuration.rb', line 173 def user_agent @user_agent end |
#xml_backend ⇒ Symbol
211 212 213 |
# File 'lib/active_sanction/configuration.rb', line 211 def xml_backend @xml_backend end |
Class Method Details
.default_cache_dir ⇒ String
617 618 619 620 621 |
# File 'lib/active_sanction/configuration.rb', line 617 def self.default_cache_dir home = ENV.fetch(XDG_CACHE_HOME, nil) home = File.join(Dir.home, ".cache") if home.nil? || home.strip.empty? -File.(File.join(home, DEFAULT_CACHE_DIRNAME)) end |
.default_storage_dir ⇒ String
612 613 614 |
# File 'lib/active_sanction/configuration.rb', line 612 def self.default_storage_dir -File.(File.join(Dir.home, DEFAULT_STORAGE_DIRNAME)) end |
.doctor_tolerance!(value) ⇒ Float
Shared with Doctor, so a per-run tolerance: is held to the same rule as
the configured default. A share of what a measurement was, so 1.0 is
"report nothing short of a doubling or a disappearance" and 0.0 is
"report every movement at all", both of which are legitimate settings for
somebody and neither of which is a default.
661 662 663 664 665 666 667 668 669 670 |
# File 'lib/active_sanction/configuration.rb', line 661 def self.doctor_tolerance!(value) ratio = begin Float(value) rescue TypeError, ArgumentError raise ConfigurationError, "doctor_tolerance must be a share between 0 and 1, got #{value.inspect}" end return ratio if ratio.between?(0.0, 1.0) raise ConfigurationError, "doctor_tolerance must be a share between 0 and 1, got #{value.inspect}" end |
.retain_payloads!(value) ⇒ Integer
Shared by PayloadCache, so a cache built with an explicit retain: fails
the same way as a misconfigured global. Zero is not allowed: a cache that
keeps nothing still writes every payload to disk before deleting it, and
an installation that wants no payload cache should not build one.
628 629 630 631 632 633 634 635 636 637 |
# File 'lib/active_sanction/configuration.rb', line 628 def self.retain_payloads!(value) integer = begin Integer(value) rescue TypeError, ArgumentError raise ConfigurationError, "retain_payloads must be a whole number of payloads, got #{value.inspect}" end raise ConfigurationError, "retain_payloads must be at least 1, got #{integer}" unless integer.positive? integer end |
.settings ⇒ Array<Symbol>
Every setting a caller may name, which is what Client.new and
Configuration#with accept as keyword arguments and what an unknown one
is reported against. Derived from the writers rather than listed, so a
setting added below is accepted here without anything remembering to say
so twice.
512 513 514 515 516 517 |
# File 'lib/active_sanction/configuration.rb', line 512 def self.settings @settings ||= T.let( public_instance_methods(false).grep(/=\z/).map { |name| name.to_s.chomp("=").to_sym }.sort.freeze, T.nilable(T::Array[Symbol]) ) end |
.sync_concurrency!(value) ⇒ Integer
Shared by Sync, so a run given an explicit concurrency: fails the same
way as a misconfigured global. Zero is refused rather than read as "no
parallelism": a sync that runs no sources is a typo, and one is what
sequential is spelled as.
644 645 646 647 648 649 650 651 652 653 |
# File 'lib/active_sanction/configuration.rb', line 644 def self.sync_concurrency!(value) integer = begin Integer(value) rescue TypeError, ArgumentError raise ConfigurationError, "sync_concurrency must be a whole number of sources, got #{value.inspect}" end raise ConfigurationError, "sync_concurrency must be at least 1, got #{integer}" unless integer.positive? integer end |
.user_agent!(value) ⇒ String
Shared by HttpClient, so a client built with an explicit user_agent:
fails the same way and with the same message as a misconfigured global.
Whitespace counts as blank: a header of " " is what a publisher sees as
no header at all, and it would earn the same 403.
677 678 679 680 681 682 683 684 685 686 |
# File 'lib/active_sanction/configuration.rb', line 677 def self.user_agent!(value) string = value.to_s.strip if string.empty? raise ConfigurationError, "user_agent is required -- OFAC and other publishers reject requests without one. " \ 'Set ActiveSanction.configure { |c| c.user_agent = "my-app/1.0 (you@example.com)" }' end -string end |
Instance Method Details
#apply(**overrides) ⇒ T.self_type
Assigns through the writers, so a value given to Client.new is held to
exactly the rule the same value set in a configure block is held to,
and fails with the same message.
535 536 537 538 539 540 541 542 543 544 |
# File 'lib/active_sanction/configuration.rb', line 535 def apply(**overrides) unknown = overrides.keys - self.class.settings if unknown.any? raise ConfigurationError, "unknown setting(s): #{unknown.join(", ")}. Expected any of #{self.class.settings.join(", ")}" end overrides.each { |name, value| public_send(:"#{name}=", value) } self end |
#freeze ⇒ T.self_type
A built configuration is frozen, and a client freezes the one it holds.
The default store is resolved on the way through, because it is the one
thing here that is built lazily and a frozen object cannot memoize. That
is also the point: a store settled at build time is a store no two
threads can race to construct, and a client that never screens pays for
a File.expand_path rather than for a directory.
The dictionary and the weights are already frozen value objects, and the source list is frozen here so that a caller holding the array it passed in cannot edit the lists a running client syncs.
558 559 560 561 562 563 564 |
# File 'lib/active_sanction/configuration.rb', line 558 def freeze return self if frozen? storage @sources = T.let(@sources&.dup&.freeze, T.nilable(T::Array[Symbol])) super end |
#initialize_copy(other) ⇒ void
This method returns an undefined value.
A copy starts unfrozen -- that is Ruby's rule for dup, and the reason
with can derive a mutable configuration from a frozen one -- and drops
the store that was derived from storage_dir, so a copy that moves the
directory reads the directory it names. A store the caller assigned is
not derived and is carried over.
287 288 289 290 |
# File 'lib/active_sanction/configuration.rb', line 287 def initialize_copy(other) super @default_storage = nil end |
#storage ⇒ Storage::Base
Where synced lists are read from and written to. Defaults to gzipped
JSON under storage_dir, which is what makes this library screen a name
without an application having provisioned anything first.
c.storage = ActiveSanction::Storage::Memory.new
Built on first use rather than at boot, because constructing it touches
the filesystem and a process that never screens should not pay for a
directory it will not read. Client.new reads it once on the way to
freezing, so a client's store is settled before any thread can race for
it -- see #freeze.
489 490 491 |
# File 'lib/active_sanction/configuration.rb', line 489 def storage @storage || default_storage end |
#storage=(value) ⇒ void
This method returns an undefined value.
A Storage::Base subclass, which is the contract the whole query path is written against -- see Storage::Base, and the conformance group an adapter that is not this is held to.
497 498 499 500 501 502 503 504 |
# File 'lib/active_sanction/configuration.rb', line 497 def storage=(value) unless value.is_a?(Storage::Base) raise ConfigurationError, "storage must be an ActiveSanction::Storage::Base subclass, got #{value.class}" end @storage = value end |
#with(**overrides) ⇒ Configuration
A copy of these settings with some of them changed:
audit = ActiveSanction.config.with(storage: pinned_store, sources: %i[ofac_sdn])
The copy is mutable and unfrozen whatever this one is, which is what makes a frozen configuration a value object rather than a dead end: a client derives its neighbour from it instead of rebuilding the schema.
527 528 529 |
# File 'lib/active_sanction/configuration.rb', line 527 def with(**overrides) dup.tap { |copy| copy.apply(**overrides) } end |