Class: ActiveSanction::Snapshot

Inherits:
Object
  • Object
show all
Extended by:
T::Sig
Defined in:
lib/active_sanction/snapshot.rb,
lib/active_sanction/snapshot/bundle.rb,
lib/active_sanction/snapshot/bundle/header.rb,
lib/active_sanction/snapshot/bundle/payload.rb,
lib/active_sanction/snapshot/bundle/signature.rb

Overview

One source's entities as they stood at one moment, with a checksum over their content. This is the unit of persistence (#24) and the anchor for reproducibility.

ActiveSanction::Snapshot.new(
source:         :ofac_sdn,
entities:       [Entity, ...],
fetched_at:     Time.now.utc,
source_version: "2026-08-28"   # the publisher's own date, if it gives one
)

A screening decision has to be reproducible months later, in front of an examiner. MatchResult (#33) stamps checksum onto every result, so "why did we clear this customer on 5 Jan" is answered by reloading the exact list version that was screened against. Without that the library produces unauditable results.

Instances are frozen on construction and compare by value.

Defined Under Namespace

Modules: Bundle Classes: ChecksumMismatch

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(source:, entities:, fetched_at: nil, checksum: nil, record_count: nil, schema_version: SCHEMA_VERSION, source_version: nil, trusted: false) ⇒ void

checksum and record_count are derived, not supplied. Passing them -- which is what .from_h does with a stored snapshot -- asserts what the content should be, and construction fails if it is not.

Parameters:

  • source (T.untyped)
  • entities (T.untyped)
  • fetched_at (T.untyped) (defaults to: nil)
  • checksum (T.untyped) (defaults to: nil)
  • record_count (T.untyped) (defaults to: nil)
  • schema_version (T.untyped) (defaults to: SCHEMA_VERSION)
  • source_version (T.untyped) (defaults to: nil)
  • trusted (T.untyped) (defaults to: false)


138
139
140
141
142
143
144
145
146
147
148
149
# File 'lib/active_sanction/snapshot.rb', line 138

def initialize(source:, entities:, fetched_at: nil, checksum: nil, record_count: nil,
               schema_version: SCHEMA_VERSION, source_version: nil, trusted: false)
  @source = T.let(symbol!(:source, source), Symbol)
  @entities = T.let(entities!(entities), T::Array[T.untyped])
  @fetched_at = T.let(time!(fetched_at), Time)
  @schema_version = T.let(version!(schema_version), Integer)
  @source_version = T.let(string_or_nil(source_version), T.nilable(String))
  @record_count = T.let(count!(record_count), Integer)
  @checksum = T.let(checksum!(checksum), String)
  @trusted = T.let(trusted == true, T::Boolean)
  freeze
end

Instance Attribute Details

#checksum ⇒ String (readonly)

sha256: and 64 hex digits, over the content and nothing else.

Returns:

  • (String)


81
82
83
# File 'lib/active_sanction/snapshot.rb', line 81

def checksum
  @checksum
end

#entities ⇒ Array<T.untyped> (readonly)

Deliberately not T::Array[Entity], and the one member of the canonical model that is not declared. The storage conformance group builds a snapshot out of half-deserialized hashes on purpose, to prove it catches a store that hands them back that way (#24); #entities! below states the real contract -- anything that serializes -- in a message written for whoever has to fix the adapter. An element type would raise a TypeError there instead, one layer too early to say anything useful.

Returns:

  • (Array<T.untyped>)


73
74
75
# File 'lib/active_sanction/snapshot.rb', line 73

def entities
  @entities
end

#fetched_at ⇒ Time (readonly)

UTC, truncated to the second, which is the precision #to_h serializes.

Returns:

  • (Time)


77
78
79
# File 'lib/active_sanction/snapshot.rb', line 77

def fetched_at
  @fetched_at
end

#record_count ⇒ Integer (readonly)

Returns:

  • (Integer)


84
85
86
# File 'lib/active_sanction/snapshot.rb', line 84

def record_count
  @record_count
end

#schema_version ⇒ Integer (readonly)

Returns:

  • (Integer)


87
88
89
# File 'lib/active_sanction/snapshot.rb', line 87

def schema_version
  @schema_version
end

#source ⇒ Symbol (readonly)

Returns:

  • (Symbol)


63
64
65
# File 'lib/active_sanction/snapshot.rb', line 63

def source
  @source
end

#source_version ⇒ String? (readonly)

The publisher's own version string where it gives one, which is not something every list does.

Returns:

  • (String, nil)


92
93
94
# File 'lib/active_sanction/snapshot.rb', line 92

def source_version
  @source_version
end

Class Method Details

.from_h(hash) ⇒ T.attached_class

Rebuilds a snapshot from #to_h output, verifying the checksum as it goes. Accepts string keys, so a snapshot survives the round-trip through gzipped JSON that storage (#24) puts it through.

Parameters:

  • hash (T.untyped)

Returns:

  • (T.attached_class)

Raises:



98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/active_sanction/snapshot.rb', line 98

def self.from_h(hash)
  attributes = hash.to_h.transform_keys(&:to_sym)
  unknown = attributes.keys - MEMBERS
  raise InvalidArgument, "unknown Snapshot attribute(s): #{unknown.join(", ")}" if unknown.any?

  attributes[:entities] &&= attributes[:entities].map { |value| build_entity(value) }
  # `new(**hash)` past required keyword parameters is one of the few things
  # Sorbet cannot check statically. #initialize validates what arrives --
  # including, here, the checksum -- which is where a bad round-trip is
  # caught.
  T.unsafe(self).new(**attributes)
end

Instance Method Details

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

Two fetches of an unchanged list are the same snapshot with different timestamps, and the checksum is what says so; equality follows it rather than #to_h so a re-fetch does not read as a new list version.

Parameters:

  • other (T.untyped)

Returns:

  • (Boolean)


203
204
205
206
207
# File 'lib/active_sanction/snapshot.rb', line 203

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

  checksum == other.checksum
end

#empty? ⇒ Boolean

Returns:

  • (Boolean)


152
# File 'lib/active_sanction/snapshot.rb', line 152

def empty? = entities.empty?

#hash ⇒ Integer

Returns:

  • (Integer)


211
212
213
# File 'lib/active_sanction/snapshot.rb', line 211

def hash
  [self.class, checksum].hash
end

#inspect ⇒ String

Returns:

  • (String)


216
217
218
219
# File 'lib/active_sanction/snapshot.rb', line 216

def inspect
  "#<#{self.class} #{source} #{record_count} entities #{checksum} " \
    "fetched_at=#{fetched_at.iso8601}#{" verified" if trusted?}>"
end

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

Returns:

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


187
188
189
190
191
192
193
194
195
196
197
# File 'lib/active_sanction/snapshot.rb', line 187

def to_h
  {
    source: source,
    entities: entities.map(&:to_h),
    fetched_at: fetched_at.iso8601,
    checksum: checksum,
    record_count: record_count,
    schema_version: schema_version,
    source_version: source_version
  }
end

#trusted? ⇒ Boolean

Whether this snapshot's provenance was verified: it came out of a bundle (#57) whose signature checked out under a public key the caller supplied. False for everything else, including a list this process fetched itself -- a sync proves that bytes parsed, not who published them.

Why it is not a member

It is out of MEMBERS, out of #to_h, out of the checksum and out of equality, because it is not a fact about the content. Two snapshots holding the same entities are the same list version whether or not one of them arrived signed, and a checksum that said otherwise would report a new list every time somebody imported one.

It does not survive storage

store.write_snapshot(bundle_snapshot)
store.read_snapshot(:ofac_sdn).trusted?   # => false

Deliberately, and it is the honest answer. A signature attests to the bytes of a bundle file; once those entities have been rewritten into a store's own gzipped JSON or its own table, nothing signed covers what is on disk, and a stored flag claiming otherwise would be the library laundering an attestation it no longer holds. What a store guarantees is its own -- the checksum, re-derived on every read.

So a process that wants MatchResult#verified? to be true keeps the bundle's snapshot in memory: Storage::Memory holds the object it was given, and Matcher reads this off the very snapshot it indexed.

Returns:

  • (Boolean)


184
# File 'lib/active_sanction/snapshot.rb', line 184

def trusted? = @trusted