Class: ActiveSanction::Subject

Inherits:
Object
  • Object
show all
Extended by:
T::Sig
Defined in:
lib/active_sanction/subject.rb

Overview

One entry in a book of business: an application's own id, and everything it knows about the person or company behind it.

subject = ActiveSanction::Subject.new(
id:            "cust_1",
name:          "Bosco Ntaganda",
type:          :individual,
date_of_birth: "1973",
country:       "CD"
)

subject.id     # => "cust_1"
subject.name   # => "Bosco Ntaganda"

Why an id is the whole of what this adds

screen answers about a name. Rescreening answers about a customer, and the two are not the same question: an alert has to name the row in the host's database that a compliance team is going to open, hold and eventually dispose of. A book screened as bare names comes back as an array somebody has to re-join by position, which works exactly until a book is filtered, streamed in batches, or contains the same name twice -- and two customers called Jane Miller is not a corner case, it is Tuesday.

So the id is required, it is the caller's own, and nothing here interprets it. It travels onto every alert the subject produces (Rescreen::Alert), so a run's output joins back to the host's records without the host having kept the order it sent them in.

It takes what screen takes

Every evidence field a screening call accepts is accepted here, in every spelling Query accepts it in -- date_of_birth: and dates_of_birth:, country:, countries: and nationalities:, identifier: and identifiers:. A subject is a query about a customer, and a caller should not have to learn a second vocabulary to write one.

The two things it refuses are the search options that a rescreen decides for itself. sources: is settled by the diff being applied -- a rescreen runs against one list version pair and nothing else -- and limit: would cap the alerts a subject can raise, which is not a thing this library is willing to do: an alert dropped for being eleventh is a sanctions hit nobody sees. Both are refused rather than ignored, because a search option that is silently dropped is a caller screening under a rule they think they set.

threshold: is accepted, and is the one policy knob a subject carries. Risk-based screening is ordinary -- a correspondent bank at 70, a retail customer at 85 -- and a book that could not express it would force a host into one call per tier. Left unset, the subject is screened at whatever threshold the run names. See Rescreen.

Where the evidence lives

In a Scorer::Subject, built once here, which is the same object a Query holds and hands the scorer. The name is folded once, at construction, and a rescreening run compares that one folded form against every changed record rather than re-folding per comparison -- which is what makes streaming a large book past a small diff cheap.

Instances are frozen on construction and compare by value.

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(id:, **fields) ⇒ void

id and name are required; everything else is optional, for the reason Scorer::Subject gives -- most callers have a name and little else, and a field absent on either side is neutral rather than a conflict.

The evidence fields are taken in any spelling Query accepts them in, and resolved here rather than in a separate builder: unlike a Query, which a screening call constructs on a caller's behalf, this is the object a host writes out by hand, so .new is the door everything comes through.

Parameters:

  • id (T.untyped)
  • fields (T.untyped)


148
149
150
151
152
153
154
155
156
157
158
159
160
# File 'lib/active_sanction/subject.rb', line 148

def initialize(id:, **fields)
  attributes = normalize(fields)
  @id = T.let(id!(id), String)
  @evidence = T.let(
    Scorer::Subject.new(name: attributes[:name], type: attributes[:type],
                        dates_of_birth: attributes[:dates_of_birth] || [],
                        nationalities: attributes[:nationalities] || [],
                        identifiers: attributes[:identifiers] || []),
    Scorer::Subject
  )
  @threshold = T.let(threshold!(attributes[:threshold]), T.nilable(Float))
  freeze
end

Instance Attribute Details

#evidence ⇒ Scorer::Subject (readonly)

The evidence, folded once. What the scorer compares against a record.

Returns:



98
99
100
# File 'lib/active_sanction/subject.rb', line 98

def evidence
  @evidence
end

#id ⇒ String (readonly)

The caller's own id for this subject, carried onto every alert.

Returns:

  • (String)


94
95
96
# File 'lib/active_sanction/subject.rb', line 94

def id
  @id
end

#threshold ⇒ Float? (readonly)

The lowest score worth an alert for this subject, or nil to take the run's. See the class comment.

Returns:

  • (Float, nil)


103
104
105
# File 'lib/active_sanction/subject.rb', line 103

def threshold
  @threshold
end

Class Method Details

.build(value) ⇒ Subject

Whatever a caller had, as a Subject:

Subject.build(subject)                                  # itself
Subject.build(id: "cust_1", name: "Bosco Ntaganda")     # a Hash, string keys or symbol

What a book of business is streamed through, so a host can hand this library the rows it already has rather than mapping them first.

Parameters:

  • value (T.untyped)

Returns:



116
117
118
119
120
121
122
123
124
125
# File 'lib/active_sanction/subject.rb', line 116

def build(value)
  case value
  when Subject then value
  when Hash then from_h(value)
  else
    raise InvalidArgument,
          "a book holds ActiveSanction::Subject or Hash entries, got #{value.class}. A rescreen reports " \
          "alerts against a caller's own id, so a bare name is not enough to raise one"
  end
end

.from_h(hash) ⇒ Subject

Rebuilds a subject from #to_h output, accepting string keys so a book read out of a database or a JSON payload needs no translation.

Parameters:

  • hash (T.untyped)

Returns:



130
131
132
133
134
135
# File 'lib/active_sanction/subject.rb', line 130

def from_h(hash)
  # `new(**hash)` past a required keyword parameter is one of the few
  # things Sorbet cannot check statically. #initialize validates what
  # arrives, which is where a bad round-trip is caught.
  T.unsafe(self).new(**hash.to_h.transform_keys { |key| key.to_s.to_sym })
end

Instance Method Details

#==(other) ⇒ Boolean Also known as: eql?

Parameters:

  • other (T.untyped)

Returns:

  • (Boolean)


196
197
198
199
200
# File 'lib/active_sanction/subject.rb', line 196

def ==(other)
  return false unless other.instance_of?(self.class)

  to_h == other.to_h
end

#dates_of_birth ⇒ Array<PartialDate>

Returns:



170
# File 'lib/active_sanction/subject.rb', line 170

def dates_of_birth = evidence.dates_of_birth

#form ⇒ Normalizer::Form

The folded name every comparison runs against, folded once at construction. What a rescreening run hands the index rather than the string it came from.

Returns:



182
# File 'lib/active_sanction/subject.rb', line 182

def form = evidence.form

#hash ⇒ Integer

Returns:

  • (Integer)


204
# File 'lib/active_sanction/subject.rb', line 204

def hash = [self.class, to_h].hash

#identifiers ⇒ Array<Identifier>

Returns:



176
# File 'lib/active_sanction/subject.rb', line 176

def identifiers = evidence.identifiers

#inspect ⇒ String

Returns:

  • (String)


207
# File 'lib/active_sanction/subject.rb', line 207

def inspect = "#<#{self.class} #{id} #{name.inspect}>"

#name ⇒ String

The name as the caller wrote it, which is what an alert quotes back.

Returns:

  • (String)


164
# File 'lib/active_sanction/subject.rb', line 164

def name = evidence.name

#nationalities ⇒ Array<String>

Returns:

  • (Array<String>)


173
# File 'lib/active_sanction/subject.rb', line 173

def nationalities = evidence.nationalities

#query(threshold: nil, sources: nil) ⇒ Query

This subject as the screening call it is, at the threshold given or its own -- which is what a MatchResult records as the question that was asked. sources: is the list the run covers.

Parameters:

  • threshold (T.untyped) (defaults to: nil)
  • sources (T.untyped) (defaults to: nil)

Returns:



188
189
190
# File 'lib/active_sanction/subject.rb', line 188

def query(threshold: nil, sources: nil)
  T.unsafe(Query).new(**evidence.to_h, sources: sources, threshold: threshold || self.threshold)
end

#to_h ⇒ Hash{Symbol => T.untyped}

Returns:

  • (Hash{Symbol => T.untyped})


193
# File 'lib/active_sanction/subject.rb', line 193

def to_h = { id: id }.merge(evidence.to_h).merge(threshold: threshold)

#type ⇒ Symbol?

Returns:

  • (Symbol, nil)


167
# File 'lib/active_sanction/subject.rb', line 167

def type = evidence.type