engram 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,510 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ module Internal
5
+ # Captures reconciliation candidates and verifies that an untrusted custom
6
+ # consolidator treated both the records and their collection as read-only.
7
+ #
8
+ # Core-method binding prevents custom equality, traversal, and serialization
9
+ # hooks from hiding mutations. Arbitrary application metadata leaves are opaque:
10
+ # their identity is retained without executing application behavior.
11
+ class CandidateIntegrity
12
+ class Error < StandardError; end
13
+ class InvalidStateError < Error; end
14
+ class MutationError < Error; end
15
+
16
+ COLLECTION_MUTATION_MESSAGE = "consolidators must not mutate the candidates collection"
17
+ CANDIDATE_MUTATION_MESSAGE = "consolidators must not mutate candidates"
18
+ MAX_NESTING_DEPTH = 100
19
+
20
+ RECORD_STATE_READERS = Engram::Record::STATE_READERS.to_h do |attribute|
21
+ [attribute, Engram::Record.instance_method(attribute)]
22
+ end.freeze
23
+ RECORD_INSTANCE_VARIABLES = Engram::Record::STATE_READERS.map { |attribute| :"@#{attribute}" }.freeze
24
+
25
+ RECORD_FIELD_CLASSES = {
26
+ id: [NilClass, Integer, String],
27
+ content: [String],
28
+ scope: [String],
29
+ kind: [Symbol],
30
+ importance: [Integer, Float],
31
+ created_at: [Time],
32
+ last_accessed_at: [NilClass, Time]
33
+ }.freeze
34
+
35
+ Snapshot = Struct.new(:collection, :collection_state, :records)
36
+ private_constant :Snapshot
37
+
38
+ # Retains the snapshotted object so object-id reuse cannot make a replacement
39
+ # look identical after the original nested value becomes unreachable.
40
+ class IdentityToken
41
+ def initialize(value)
42
+ @value = value
43
+ end
44
+
45
+ def ==(other)
46
+ Object.instance_method(:instance_of?).bind_call(other, IdentityToken) &&
47
+ Object.instance_method(:equal?).bind_call(@value, other.value)
48
+ end
49
+
50
+ protected
51
+
52
+ attr_reader :value
53
+ end
54
+ private_constant :IdentityToken
55
+
56
+ def snapshot(candidates)
57
+ collection = snapshot_collection(candidates)
58
+ records = collection.map { |candidate| [candidate, snapshot_record(candidate)] }
59
+ Snapshot.new(collection, collection_state(candidates), records)
60
+ end
61
+
62
+ def verify!(candidates, snapshot)
63
+ verify_collection!(candidates, snapshot.collection, snapshot.collection_state)
64
+ changed = snapshot.records.any? do |candidate, record_snapshot|
65
+ snapshot_record(candidate) != record_snapshot
66
+ end
67
+ raise MutationError, CANDIDATE_MUTATION_MESSAGE if changed
68
+ rescue InvalidStateError => error
69
+ raise MutationError, CANDIDATE_MUTATION_MESSAGE, error.backtrace
70
+ end
71
+
72
+ # Validates a candidate without dispatching through any of its readers.
73
+ def validate!(candidate)
74
+ snapshot_record(candidate)
75
+ nil
76
+ end
77
+
78
+ # Returns a structurally detached copy. Plain containers and core mutable
79
+ # values do not share references; opaque application leaves retain identity.
80
+ def detach(candidate)
81
+ validate_detachable_record!(candidate)
82
+ attributes = RECORD_STATE_READERS.to_h do |attribute, reader|
83
+ value = reader.bind_call(candidate)
84
+ validate_record_field!(attribute, value)
85
+ [attribute, attribute.equal?(:metadata) ? duplicate_metadata_value(value) : duplicate_value(value)]
86
+ end
87
+ Engram::Record.new(**attributes)
88
+ end
89
+
90
+ private
91
+
92
+ def duplicate_value(value, active = {}.compare_by_identity, depth = 0)
93
+ validate_nesting_depth!(value, depth)
94
+ value_class = plain_class(value)
95
+ return value unless supported_value_class?(value_class)
96
+
97
+ if custom_behavior?(value, value_class) || !Object.instance_method(:instance_variables).bind_call(value).empty?
98
+ raise InvalidStateError, "unsupported candidate value"
99
+ end
100
+
101
+ if value_class.equal?(String) || value_class.equal?(Time)
102
+ Object.instance_method(:dup).bind_call(value)
103
+ elsif value_class.equal?(Array)
104
+ with_acyclic_value(value, active) do
105
+ copy = []
106
+ Array.instance_method(:each).bind_call(value) do |element|
107
+ copy << duplicate_value(element, active, depth + 1)
108
+ end
109
+ copy
110
+ end
111
+ elsif value_class.equal?(Hash)
112
+ duplicate_hash(value, active, depth)
113
+ else
114
+ # The remaining supported scalar values are immutable.
115
+ canonical_value(value, active, depth)
116
+ value
117
+ end
118
+ end
119
+
120
+ def duplicate_hash(value, active, depth)
121
+ default_proc = Hash.instance_method(:default_proc).bind_call(value)
122
+ raise InvalidStateError, "unsupported candidate value Hash with a default proc" if default_proc
123
+
124
+ with_acyclic_value(value, active) do
125
+ # Hash#transform_values copies the core table and preserves its existing keys
126
+ # without hashing, comparing, serializing, or otherwise dispatching through
127
+ # application key behavior. Values still pass through our structural copier.
128
+ copy = Hash.instance_method(:transform_values).bind_call(value) do |nested_value|
129
+ duplicate_value(nested_value, active, depth + 1)
130
+ end
131
+ copy.default = duplicate_value(Hash.instance_method(:default).bind_call(value), active, depth + 1)
132
+ copy
133
+ end
134
+ end
135
+
136
+ def snapshot_collection(candidates)
137
+ unless Object.instance_method(:instance_of?).bind_call(candidates, Array) &&
138
+ !custom_behavior?(candidates, Array) &&
139
+ Object.instance_method(:instance_variables).bind_call(candidates).empty?
140
+ raise InvalidStateError, "candidates must be a plain Array"
141
+ end
142
+
143
+ snapshot = []
144
+ Array.instance_method(:each).bind_call(candidates) { |candidate| snapshot << candidate }
145
+ snapshot
146
+ end
147
+
148
+ def collection_state(candidates)
149
+ [identity(candidates), frozen?(candidates)]
150
+ end
151
+
152
+ def verify_collection!(candidates, snapshot, original_state)
153
+ unless Object.instance_method(:instance_of?).bind_call(candidates, Array) &&
154
+ !custom_behavior?(candidates, Array) &&
155
+ Object.instance_method(:instance_variables).bind_call(candidates).empty? &&
156
+ collection_state(candidates) == original_state
157
+ raise MutationError, COLLECTION_MUTATION_MESSAGE
158
+ end
159
+
160
+ current_size = Array.instance_method(:length).bind_call(candidates)
161
+ original_size = Array.instance_method(:length).bind_call(snapshot)
162
+ unchanged = current_size == original_size && original_size.times.all? do |index|
163
+ current = Array.instance_method(:[]).bind_call(candidates, index)
164
+ original = Array.instance_method(:[]).bind_call(snapshot, index)
165
+ Object.instance_method(:equal?).bind_call(current, original)
166
+ end
167
+ return if unchanged
168
+
169
+ raise MutationError, COLLECTION_MUTATION_MESSAGE
170
+ end
171
+
172
+ def snapshot_record(candidate)
173
+ unless plain_record?(candidate)
174
+ raise InvalidStateError, "candidate must be a plain Engram::Record"
175
+ end
176
+
177
+ instance_variables = Object.instance_method(:instance_variables).bind_call(candidate)
178
+ unless instance_variables.sort == RECORD_INSTANCE_VARIABLES.sort
179
+ unexpected = instance_variables - RECORD_INSTANCE_VARIABLES
180
+ missing = RECORD_INSTANCE_VARIABLES - instance_variables
181
+ details = []
182
+ details << "unexpected: #{unexpected.map(&:inspect).join(", ")}" unless unexpected.empty?
183
+ details << "missing: #{missing.map(&:inspect).join(", ")}" unless missing.empty?
184
+ raise InvalidStateError,
185
+ "candidate Record must have exactly the expected instance variables (#{details.join("; ")})"
186
+ end
187
+
188
+ [frozen?(candidate), RECORD_STATE_READERS.to_h do |attribute, reader|
189
+ value = reader.bind_call(candidate)
190
+ validate_record_field!(attribute, value)
191
+ canonical = attribute.equal?(:metadata) ? canonical_metadata_value(value) : canonical_value(value)
192
+ [attribute, canonical]
193
+ end, provenance_evidence(RECORD_STATE_READERS.fetch(:metadata).bind_call(candidate))]
194
+ end
195
+
196
+ # Provenance is security evidence rather than arbitrary application metadata.
197
+ # Parse it independently so an opaque Hash subclass cannot hide changes to the
198
+ # schema fields used for persistence and destructive authorization. Invalid and
199
+ # future schemas remain opaque-compatible here: write paths reject them, while a
200
+ # noop observation may continue without CandidateIntegrity rejecting it early.
201
+ def provenance_evidence(metadata)
202
+ provenance = Engram::Provenance.canonical_payload_for_persistence(metadata)
203
+ provenance ? [:valid, provenance] : [:absent]
204
+ rescue Engram::Error
205
+ [:invalid]
206
+ end
207
+
208
+ def validate_record_field!(attribute, value)
209
+ if attribute.equal?(:metadata)
210
+ unless core_container?(value, Hash)
211
+ raise InvalidStateError, "candidate metadata must be a Hash"
212
+ end
213
+ return
214
+ end
215
+
216
+ if attribute.equal?(:embedding)
217
+ validate_embedding!(value)
218
+ return
219
+ end
220
+
221
+ value_class = plain_class(value)
222
+ allowed = RECORD_FIELD_CLASSES.fetch(attribute)
223
+ unless allowed.any? { |klass| value_class.equal?(klass) }
224
+ raise InvalidStateError, "candidate #{attribute} has an unsupported value"
225
+ end
226
+ canonical_value(value)
227
+ end
228
+
229
+ def validate_embedding!(value)
230
+ return if plain_class(value).equal?(NilClass)
231
+ unless plain_class(value).equal?(Array)
232
+ raise InvalidStateError, "candidate embedding must be a plain Array of numbers or nil"
233
+ end
234
+
235
+ canonical_value(value)
236
+ Array.instance_method(:each).bind_call(value) do |element|
237
+ element_class = plain_class(element)
238
+ unless element_class.equal?(Integer) || element_class.equal?(Float)
239
+ raise InvalidStateError, "candidate embedding must be a plain Array of numbers or nil"
240
+ end
241
+ end
242
+ end
243
+
244
+ # Metadata containers are structural even when subclassed: bound core traversal
245
+ # ignores their behavior. Only non-container application leaves remain opaque.
246
+ def canonical_metadata_value(value, active = {}.compare_by_identity, depth = 0)
247
+ validate_nesting_depth!(value, depth)
248
+ if core_container?(value, Hash)
249
+ default_proc = Hash.instance_method(:default_proc).bind_call(value)
250
+ raise InvalidStateError, "unsupported candidate value Hash with a default proc" if default_proc
251
+
252
+ with_acyclic_value(value, active) do
253
+ entries = []
254
+ Engram::Internal::CoreHash.each_pair(value) do |key, nested_value|
255
+ entries << [
256
+ canonical_metadata_value(key, active, depth + 1),
257
+ canonical_metadata_value(nested_value, active, depth + 1)
258
+ ]
259
+ end
260
+ default = canonical_metadata_value(Hash.instance_method(:default).bind_call(value), active, depth + 1)
261
+ [:metadata_hash, identity(value), frozen?(value),
262
+ Hash.instance_method(:compare_by_identity?).bind_call(value), default, entries]
263
+ end
264
+ elsif core_container?(value, Array)
265
+ with_acyclic_value(value, active) do
266
+ elements = []
267
+ Array.instance_method(:each).bind_call(value) do |element|
268
+ elements << canonical_metadata_value(element, active, depth + 1)
269
+ end
270
+ [:metadata_array, identity(value), frozen?(value), elements]
271
+ end
272
+ else
273
+ value_class = plain_class(value)
274
+ if !supported_value_class?(value_class) || custom_behavior?(value, value_class) ||
275
+ !Object.instance_method(:instance_variables).bind_call(value).empty?
276
+ [:opaque, identity(value)]
277
+ else
278
+ canonical_value(value, active, depth)
279
+ end
280
+ end
281
+ end
282
+
283
+ def duplicate_metadata_value(value, active = {}.compare_by_identity, depth = 0)
284
+ validate_nesting_depth!(value, depth)
285
+ if core_container?(value, Hash)
286
+ default_proc = Hash.instance_method(:default_proc).bind_call(value)
287
+ raise InvalidStateError, "unsupported candidate value Hash with a default proc" if default_proc
288
+
289
+ with_acyclic_value(value, active) do
290
+ copy = Hash.instance_method(:transform_values).bind_call(value) do |nested_value|
291
+ duplicate_metadata_value(nested_value, active, depth + 1)
292
+ end
293
+ copy.default = duplicate_metadata_value(
294
+ Hash.instance_method(:default).bind_call(value), active, depth + 1
295
+ )
296
+ copy
297
+ end
298
+ elsif core_container?(value, Array)
299
+ with_acyclic_value(value, active) do
300
+ copy = []
301
+ Array.instance_method(:each).bind_call(value) do |element|
302
+ copy << duplicate_metadata_value(element, active, depth + 1)
303
+ end
304
+ copy
305
+ end
306
+ else
307
+ value_class = plain_class(value)
308
+ if !supported_value_class?(value_class) || custom_behavior?(value, value_class) ||
309
+ !Object.instance_method(:instance_variables).bind_call(value).empty?
310
+ value
311
+ else
312
+ duplicate_value(value, active, depth)
313
+ end
314
+ end
315
+ end
316
+
317
+ def core_container?(value, klass)
318
+ Object.instance_method(:is_a?).bind_call(value, klass)
319
+ rescue TypeError
320
+ false
321
+ end
322
+
323
+ def canonical_value(value, active = {}.compare_by_identity, depth = 0)
324
+ validate_nesting_depth!(value, depth)
325
+ value_class = plain_class(value)
326
+ return [:opaque, identity(value)] unless supported_value_class?(value_class)
327
+
328
+ if custom_behavior?(value, value_class)
329
+ raise InvalidStateError, "unsupported candidate value with custom behavior"
330
+ end
331
+ unless Object.instance_method(:instance_variables).bind_call(value).empty?
332
+ raise InvalidStateError, "unsupported candidate value with custom state"
333
+ end
334
+
335
+ if value_class.equal?(NilClass)
336
+ [:nil]
337
+ elsif value_class.equal?(TrueClass)
338
+ [:boolean, true]
339
+ elsif value_class.equal?(FalseClass)
340
+ [:boolean, false]
341
+ elsif value_class.equal?(String)
342
+ encoding = String.instance_method(:encoding).bind_call(value)
343
+ bytes = String.instance_method(:b).bind_call(value)
344
+ [:string, identity(value), frozen?(value), encoding, bytes]
345
+ elsif value_class.equal?(Symbol)
346
+ [:symbol, value]
347
+ elsif value_class.equal?(Integer)
348
+ [:integer, value.to_s]
349
+ elsif value_class.equal?(Float)
350
+ [:float, [value].pack("G")]
351
+ elsif value_class.equal?(Time)
352
+ canonical_time(value)
353
+ elsif value_class.equal?(Array)
354
+ canonical_array(value, active, depth)
355
+ elsif value_class.equal?(Hash)
356
+ canonical_hash(value, active, depth)
357
+ else
358
+ raise InvalidStateError, "unsupported candidate value"
359
+ end
360
+ end
361
+
362
+ def plain_record?(candidate)
363
+ Object.instance_method(:instance_of?).bind_call(candidate, Engram::Record) &&
364
+ !custom_behavior?(candidate, Engram::Record)
365
+ rescue TypeError
366
+ false
367
+ end
368
+
369
+ def validate_detachable_record!(candidate)
370
+ is_record = Object.instance_method(:is_a?).bind_call(candidate, Engram::Record)
371
+ raise InvalidStateError, "candidate must be an Engram::Record" unless is_record
372
+
373
+ instance_variables = Object.instance_method(:instance_variables).bind_call(candidate)
374
+ unless instance_variables.sort == RECORD_INSTANCE_VARIABLES.sort
375
+ raise InvalidStateError, "candidate Record must have exactly the expected instance variables"
376
+ end
377
+
378
+ if Object.instance_method(:instance_of?).bind_call(candidate, Engram::Record) && !plain_record?(candidate)
379
+ raise InvalidStateError, "candidate must be a plain Engram::Record"
380
+ end
381
+ rescue TypeError
382
+ raise InvalidStateError, "candidate must be an Engram::Record"
383
+ end
384
+
385
+ def plain_class(value)
386
+ Object.instance_method(:class).bind_call(value)
387
+ rescue TypeError
388
+ nil
389
+ end
390
+
391
+ def supported_value_class?(value_class)
392
+ [NilClass, TrueClass, FalseClass, String, Symbol, Integer, Float, Time, Array, Hash].any? do |klass|
393
+ value_class.equal?(klass)
394
+ end
395
+ end
396
+
397
+ def custom_behavior?(value, value_class)
398
+ singleton_class = begin
399
+ Object.instance_method(:singleton_class).bind_call(value)
400
+ rescue TypeError
401
+ return false
402
+ end
403
+ return false if singleton_class.equal?(value_class)
404
+
405
+ method_visibilities = %i[
406
+ public_instance_methods protected_instance_methods private_instance_methods
407
+ ]
408
+ return true if method_visibilities.any? do |visibility|
409
+ Module.instance_method(visibility).bind_call(singleton_class, false).any?
410
+ end
411
+
412
+ singleton_ancestors = Module.instance_method(:ancestors).bind_call(singleton_class)
413
+ class_ancestors = Module.instance_method(:ancestors).bind_call(value_class)
414
+ return true unless singleton_ancestors.length == class_ancestors.length + 1 &&
415
+ class_ancestors.each_with_index.all? { |ancestor, index| singleton_ancestors[index + 1].equal?(ancestor) }
416
+
417
+ method_visibilities.any? do |visibility|
418
+ singleton_methods = Module.instance_method(visibility).bind_call(singleton_class, true)
419
+ class_methods = Module.instance_method(visibility).bind_call(value_class, true)
420
+ singleton_methods.length != class_methods.length ||
421
+ singleton_methods.any? { |method_name| !class_methods.include?(method_name) }
422
+ end
423
+ end
424
+
425
+ def canonical_time(value)
426
+ exact_value = Time.instance_method(:to_r).bind_call(value)
427
+ components = Time.instance_method(:to_a).bind_call(value)
428
+ [:time,
429
+ identity(value),
430
+ frozen?(value),
431
+ Time.instance_method(:to_i).bind_call(value),
432
+ Time.instance_method(:nsec).bind_call(value),
433
+ Rational.instance_method(:numerator).bind_call(exact_value),
434
+ Rational.instance_method(:denominator).bind_call(exact_value),
435
+ Time.instance_method(:utc_offset).bind_call(value),
436
+ Time.instance_method(:utc?).bind_call(value),
437
+ components.map { |component| canonical_time_component(component) }]
438
+ end
439
+
440
+ def canonical_time_component(value)
441
+ value_class = Object.instance_method(:class).bind_call(value)
442
+ if value_class.equal?(NilClass)
443
+ [:nil]
444
+ elsif value_class.equal?(TrueClass) || value_class.equal?(FalseClass)
445
+ [:boolean, value]
446
+ elsif value_class.equal?(String)
447
+ [:string, String.instance_method(:encoding).bind_call(value), String.instance_method(:b).bind_call(value)]
448
+ elsif value_class.equal?(Integer)
449
+ [:integer, value.to_s]
450
+ else
451
+ raise InvalidStateError, "unsupported Time component #{value_class}"
452
+ end
453
+ end
454
+
455
+ def canonical_array(value, active, depth)
456
+ with_acyclic_value(value, active) do
457
+ elements = []
458
+ Array.instance_method(:each).bind_call(value) do |element|
459
+ elements << canonical_value(element, active, depth + 1)
460
+ end
461
+ [:array, identity(value), frozen?(value), elements]
462
+ end
463
+ end
464
+
465
+ def canonical_hash(value, active, depth)
466
+ default_proc = Hash.instance_method(:default_proc).bind_call(value)
467
+ raise InvalidStateError, "unsupported candidate value Hash with a default proc" if default_proc
468
+
469
+ with_acyclic_value(value, active) do
470
+ entries = []
471
+ Engram::Internal::CoreHash.each_pair(value) do |key, nested_value|
472
+ entries << [
473
+ canonical_value(key, active, depth + 1),
474
+ canonical_value(nested_value, active, depth + 1)
475
+ ]
476
+ end
477
+ default = Hash.instance_method(:default).bind_call(value)
478
+ compare_by_identity = Hash.instance_method(:compare_by_identity?).bind_call(value)
479
+ [:hash, identity(value), frozen?(value), compare_by_identity,
480
+ canonical_value(default, active, depth + 1), entries]
481
+ end
482
+ end
483
+
484
+ def frozen?(value)
485
+ Object.instance_method(:frozen?).bind_call(value)
486
+ end
487
+
488
+ def identity(value)
489
+ IdentityToken.new(value)
490
+ end
491
+
492
+ def validate_nesting_depth!(value, depth)
493
+ return if depth <= MAX_NESTING_DEPTH
494
+ return unless core_container?(value, Array) || core_container?(value, Hash)
495
+
496
+ raise InvalidStateError,
497
+ "unsupported candidate value: nesting exceeds maximum depth of #{MAX_NESTING_DEPTH}"
498
+ end
499
+
500
+ def with_acyclic_value(value, active)
501
+ raise InvalidStateError, "unsupported cyclic candidate value" if active.key?(value)
502
+
503
+ active[value] = true
504
+ yield
505
+ ensure
506
+ active.delete(value)
507
+ end
508
+ end
509
+ end
510
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ module Internal
5
+ # Iterates a Hash's stored (key, value) pairs without dispatching through the
6
+ # hash's own overridden methods and without provoking per-key #hash/#eql?.
7
+ #
8
+ # Bound Hash#each_pair was chosen elsewhere to bypass a Hash subclass's
9
+ # overridden traversal. It is not enough on its own: on Ruby 3.4 a hash left in
10
+ # a delete-then-insert collision state can invoke a *key's* #eql? during plain
11
+ # iteration (Hash#each_pair / #to_a), which would let a malicious String-subclass
12
+ # key run application code inside code that must stay behavior-free. Bound #keys
13
+ # and #values return the stored key and value objects in matching insertion order
14
+ # without comparing keys, so zipping them by position reconstructs every pair
15
+ # with no application dispatch.
16
+ module CoreHash
17
+ KEYS = ::Hash.instance_method(:keys)
18
+ VALUES = ::Hash.instance_method(:values)
19
+ private_constant :KEYS, :VALUES
20
+
21
+ module_function
22
+
23
+ def each_pair(hash)
24
+ keys = KEYS.bind_call(hash)
25
+ values = VALUES.bind_call(hash)
26
+ index = 0
27
+ count = keys.length
28
+ while index < count
29
+ yield keys[index], values[index]
30
+ index += 1
31
+ end
32
+ hash
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ module Internal
5
+ # Compares Record scopes without dispatching through application-defined readers
6
+ # or equality methods. Keep orchestration preflight and persistence enforcement on
7
+ # the same fail-closed rules.
8
+ module Scope
9
+ RECORD_SCOPE_READER = Engram::Record.instance_method(:scope)
10
+
11
+ module_function
12
+
13
+ def record_matches?(record, expected_scope)
14
+ same?(RECORD_SCOPE_READER.bind_call(record), expected_scope)
15
+ rescue TypeError
16
+ false
17
+ end
18
+
19
+ def same?(actual_scope, expected_scope)
20
+ actual_class = Object.instance_method(:class).bind_call(actual_scope)
21
+ expected_class = Object.instance_method(:class).bind_call(expected_scope)
22
+ return true if actual_class.equal?(NilClass) && expected_class.equal?(NilClass)
23
+ return false unless actual_class.equal?(String) && expected_class.equal?(String)
24
+
25
+ String.instance_method(:==).bind_call(actual_scope, expected_scope)
26
+ rescue TypeError
27
+ false
28
+ end
29
+ end
30
+ end
31
+ end
data/lib/engram/memory.rb CHANGED
@@ -104,6 +104,21 @@ module Engram
104
104
  .call(scope: scope, older_than: older_than, min_importance: min_importance)
105
105
  end
106
106
 
107
+ # Return the memories in this scope whose provenance references the exact host
108
+ # source. `source_id` and `source_type` must each be non-blank Strings and are
109
+ # matched exactly, without normalization. Source IDs are references, not an
110
+ # authorization boundary; the lookup stays bound to this scope.
111
+ def memories_from_source(source_id:, source_type:)
112
+ UseCases::SourceImpact.new(store: @store)
113
+ .call(scope: scope, source_id: source_id, source_type: source_type)
114
+ end
115
+
116
+ # Count memories by their weakest provenance source alignment. Records whose
117
+ # provenance is absent or not understood are counted as unattributed.
118
+ def grounding_report
119
+ UseCases::GroundingReport.new(store: @store).call(scope: scope)
120
+ end
121
+
107
122
  private
108
123
 
109
124
  def persist(record)