Class: ActiveSanction::Configuration

Inherits:
Object
  • Object
show all
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 ~/.cache is 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 XML

See 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 un against kim yong chol blends 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

Class Method Summary collapse

Instance Method Summary collapse

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

Returns:

  • (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.

Returns:

  • (Integer)


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.

Returns:

  • (Float)


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.

Returns:

  • (T.untyped)


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.

Returns:

  • (T.untyped)


247
248
249
# File 'lib/active_sanction/configuration.rb', line 247

def logger
  @logger
end

#max_redirects ⇒ Integer

Returns:

  • (Integer)


184
185
186
# File 'lib/active_sanction/configuration.rb', line 184

def max_redirects
  @max_redirects
end

#max_retries ⇒ Integer

Returns:

  • (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.

Returns:

  • (Numeric)


178
179
180
# File 'lib/active_sanction/configuration.rb', line 178

def open_timeout
  @open_timeout
end

#read_timeout ⇒ Numeric

Returns:

  • (Numeric)


181
182
183
# File 'lib/active_sanction/configuration.rb', line 181

def read_timeout
  @read_timeout
end

#retain_payloads ⇒ Integer

Returns:

  • (Integer)


199
200
201
# File 'lib/active_sanction/configuration.rb', line 199

def retain_payloads
  @retain_payloads
end

#retry_backoff ⇒ Numeric

Returns:

  • (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=.

Returns:



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.

Returns:

  • (Integer)


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.

Returns:

  • (Float)


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=.

Returns:

  • (Array<Symbol>, nil)


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=.

Returns:

  • (Numeric, nil)


203
204
205
# File 'lib/active_sanction/configuration.rb', line 203

def stale_after
  @stale_after
end

#storage_dir ⇒ String

Returns:

  • (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.

Returns:

  • (Integer)


219
220
221
# File 'lib/active_sanction/configuration.rb', line 219

def sync_concurrency
  @sync_concurrency
end

#user_agent ⇒ String

Returns:

  • (String)


173
174
175
# File 'lib/active_sanction/configuration.rb', line 173

def user_agent
  @user_agent
end

#xml_backend ⇒ Symbol

Returns:

  • (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

Returns:

  • (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.expand_path(File.join(home, DEFAULT_CACHE_DIRNAME))
end

.default_storage_dir ⇒ String

Returns:

  • (String)


612
613
614
# File 'lib/active_sanction/configuration.rb', line 612

def self.default_storage_dir
  -File.expand_path(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.

Parameters:

  • value (T.untyped)

Returns:

  • (Float)

Raises:



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.

Parameters:

  • value (T.untyped)

Returns:

  • (Integer)

Raises:



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.

Returns:

  • (Array<Symbol>)


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.

Parameters:

  • value (T.untyped)

Returns:

  • (Integer)

Raises:



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.

Parameters:

  • value (T.untyped)

Returns:

  • (String)


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.

Parameters:

  • overrides (T.untyped)

Returns:

  • (T.self_type)


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.

Returns:

  • (T.self_type)


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.

Parameters:



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.

Returns:



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.

Parameters:

  • value (T.untyped)


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.

Parameters:

  • overrides (T.untyped)

Returns:



527
528
529
# File 'lib/active_sanction/configuration.rb', line 527

def with(**overrides)
  dup.tap { |copy| copy.apply(**overrides) }
end