igniter-ledger 0.5.2

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.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +481 -0
  3. data/examples/intelligent_ledger/availability_boundary_ledger.rb +1190 -0
  4. data/examples/intelligent_ledger/availability_deriver.rb +150 -0
  5. data/examples/intelligent_ledger/availability_ledger.rb +197 -0
  6. data/examples/intelligent_ledger/ledger_boundary.rb +180 -0
  7. data/examples/store_poc.rb +45 -0
  8. data/exe/igniter-ledger-server +111 -0
  9. data/exe/igniter-store-server +6 -0
  10. data/ext/igniter_store_native/Cargo.toml +28 -0
  11. data/ext/igniter_store_native/extconf.rb +6 -0
  12. data/ext/igniter_store_native/src/fact.rs +303 -0
  13. data/ext/igniter_store_native/src/fact_log.rs +180 -0
  14. data/ext/igniter_store_native/src/file_backend.rs +91 -0
  15. data/ext/igniter_store_native/src/lib.rs +55 -0
  16. data/lib/igniter/ledger.rb +7 -0
  17. data/lib/igniter/store/access_path.rb +84 -0
  18. data/lib/igniter/store/change_event.rb +65 -0
  19. data/lib/igniter/store/changefeed_buffer.rb +585 -0
  20. data/lib/igniter/store/codecs.rb +253 -0
  21. data/lib/igniter/store/contractable_receipt_sink.rb +172 -0
  22. data/lib/igniter/store/fact.rb +121 -0
  23. data/lib/igniter/store/fact_log.rb +103 -0
  24. data/lib/igniter/store/file_backend.rb +269 -0
  25. data/lib/igniter/store/http_adapter.rb +413 -0
  26. data/lib/igniter/store/igniter_store.rb +838 -0
  27. data/lib/igniter/store/mcp_adapter.rb +403 -0
  28. data/lib/igniter/store/native.rb +80 -0
  29. data/lib/igniter/store/network_backend.rb +159 -0
  30. data/lib/igniter/store/protocol/handlers/access_path_handler.rb +38 -0
  31. data/lib/igniter/store/protocol/handlers/command_handler.rb +59 -0
  32. data/lib/igniter/store/protocol/handlers/derivation_handler.rb +27 -0
  33. data/lib/igniter/store/protocol/handlers/effect_handler.rb +65 -0
  34. data/lib/igniter/store/protocol/handlers/history_handler.rb +24 -0
  35. data/lib/igniter/store/protocol/handlers/projection_handler.rb +41 -0
  36. data/lib/igniter/store/protocol/handlers/relation_handler.rb +43 -0
  37. data/lib/igniter/store/protocol/handlers/store_handler.rb +24 -0
  38. data/lib/igniter/store/protocol/handlers/subscription_handler.rb +24 -0
  39. data/lib/igniter/store/protocol/interpreter.rb +447 -0
  40. data/lib/igniter/store/protocol/receipt.rb +96 -0
  41. data/lib/igniter/store/protocol/sync_profile.rb +53 -0
  42. data/lib/igniter/store/protocol/wire_envelope.rb +214 -0
  43. data/lib/igniter/store/protocol.rb +27 -0
  44. data/lib/igniter/store/read_cache.rb +163 -0
  45. data/lib/igniter/store/schema_graph.rb +248 -0
  46. data/lib/igniter/store/segmented_file_backend.rb +699 -0
  47. data/lib/igniter/store/server_config.rb +55 -0
  48. data/lib/igniter/store/server_logger.rb +64 -0
  49. data/lib/igniter/store/server_metrics.rb +222 -0
  50. data/lib/igniter/store/store_server.rb +597 -0
  51. data/lib/igniter/store/subscription_registry.rb +73 -0
  52. data/lib/igniter/store/tbackend_adapter_descriptor.rb +307 -0
  53. data/lib/igniter/store/tcp_adapter.rb +127 -0
  54. data/lib/igniter/store/wire_protocol.rb +42 -0
  55. data/lib/igniter/store.rb +64 -0
  56. data/lib/igniter-ledger.rb +4 -0
  57. data/lib/igniter-store.rb +5 -0
  58. metadata +212 -0
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Igniter
6
+ module Store
7
+ module Protocol
8
+ # OP3 — Wire Envelope: process boundary routing for StoreServer.
9
+ #
10
+ # Every request is wrapped in:
11
+ # { protocol: :igniter_store, schema_version: 1,
12
+ # request_id: "req_...", op: :write_fact,
13
+ # packet: { kind: :fact, store: :tasks, key: "t1", value: {} } }
14
+ #
15
+ # Every response is:
16
+ # { protocol: :igniter_store, schema_version: 1,
17
+ # request_id: "req_...",
18
+ # status: :ok | :error,
19
+ # result: { ... } | error: "message" }
20
+ #
21
+ # This layer sits above the CRC32-framed WireProtocol transport and below
22
+ # application-level handlers. It is pure Ruby — no I/O, no sockets.
23
+ # The StoreServer feeds deserialized hashes in and ships serialized responses out.
24
+ class WireEnvelope
25
+ PROTOCOL = :igniter_store
26
+ SCHEMA_VERSION = 1
27
+
28
+ OPERATIONS = %i[
29
+ register_descriptor
30
+ write
31
+ append
32
+ write_fact
33
+ read
34
+ query
35
+ resolve
36
+ causation_chain
37
+ lineage
38
+ fact_ref
39
+ metadata_snapshot
40
+ descriptor_snapshot
41
+ observability_snapshot
42
+ sync_hub_profile
43
+ replay
44
+ storage_stats
45
+ segment_manifest
46
+ compaction_activity
47
+ ].freeze
48
+
49
+ def initialize(interpreter)
50
+ @interpreter = interpreter
51
+ end
52
+
53
+ # Dispatch a single envelope hash.
54
+ # Returns a response envelope hash (never raises).
55
+ def dispatch(envelope)
56
+ envelope = envelope.transform_keys(&:to_sym)
57
+ req_id = envelope[:request_id]
58
+
59
+ proto = envelope[:protocol]&.to_sym
60
+ unless proto == PROTOCOL
61
+ return error_response(req_id, "Unknown protocol: #{proto.inspect}")
62
+ end
63
+
64
+ op = envelope[:op]&.to_sym
65
+ unless op && OPERATIONS.include?(op)
66
+ return error_response(req_id, "Unknown or missing op: #{op.inspect}")
67
+ end
68
+
69
+ packet = (envelope[:packet] || {})
70
+ packet = packet.transform_keys(&:to_sym) if packet.is_a?(Hash)
71
+
72
+ result = route(op, packet)
73
+ ok_response(req_id, result)
74
+ rescue => e
75
+ error_response(req_id, "Internal error: #{e.message}")
76
+ end
77
+
78
+ private
79
+
80
+ def route(op, packet)
81
+ case op
82
+ when :register_descriptor
83
+ @interpreter.register(packet)
84
+
85
+ when :write
86
+ @interpreter.write(
87
+ store: packet.fetch(:store),
88
+ key: packet.fetch(:key),
89
+ value: packet.fetch(:value),
90
+ producer: packet[:producer]
91
+ )
92
+
93
+ when :append
94
+ @interpreter.append(
95
+ history: packet.fetch(:history),
96
+ event: packet.fetch(:event),
97
+ key: packet[:key],
98
+ partition_key: packet[:partition_key],
99
+ schema_version: packet.fetch(:schema_version, 1),
100
+ valid_time: packet[:valid_time],
101
+ term: packet[:term],
102
+ producer: packet[:producer],
103
+ derivation: packet[:derivation]
104
+ )
105
+
106
+ when :write_fact
107
+ @interpreter.write_fact(packet)
108
+
109
+ when :read
110
+ value = @interpreter.read(
111
+ store: packet.fetch(:store),
112
+ key: packet.fetch(:key),
113
+ as_of: packet[:as_of]
114
+ )
115
+ { value: value, found: !value.nil? }
116
+
117
+ when :query
118
+ items = @interpreter.query(
119
+ store: packet.fetch(:store),
120
+ where: packet.fetch(:where, {}),
121
+ order: packet[:order],
122
+ limit: packet[:limit],
123
+ as_of: packet[:as_of]
124
+ )
125
+ { items: items, results: items.map { |item| item[:value] }, count: items.size }
126
+
127
+ when :resolve
128
+ items = @interpreter.resolve_items(
129
+ packet.fetch(:relation).to_sym,
130
+ from: packet.fetch(:from),
131
+ as_of: packet[:as_of]
132
+ )
133
+ { items: items, results: items.map { |item| item[:value] }, count: items.size }
134
+
135
+ when :causation_chain
136
+ chain = @interpreter.causation_chain(
137
+ store: packet.fetch(:store),
138
+ key: packet.fetch(:key)
139
+ )
140
+ { chain: chain, count: chain.size }
141
+
142
+ when :lineage
143
+ @interpreter.lineage(
144
+ store: packet.fetch(:store),
145
+ key: packet.fetch(:key)
146
+ )
147
+
148
+ when :fact_ref
149
+ ref = @interpreter.fact_ref(packet.fetch(:fact_id))
150
+ { found: !ref.nil?, ref: ref }
151
+
152
+ when :metadata_snapshot
153
+ @interpreter.metadata_snapshot
154
+
155
+ when :descriptor_snapshot
156
+ @interpreter.descriptor_snapshot
157
+
158
+ when :observability_snapshot
159
+ @interpreter.observability_snapshot
160
+
161
+ when :sync_hub_profile
162
+ @interpreter.sync_hub_profile(
163
+ as_of: packet[:as_of],
164
+ cursor: packet[:cursor],
165
+ stores: packet[:stores]
166
+ )
167
+
168
+ when :replay
169
+ facts = @interpreter.replay(
170
+ from: packet[:from],
171
+ to: packet[:to],
172
+ filter: packet[:filter]
173
+ )
174
+ { facts: facts, count: facts.size }
175
+
176
+ when :storage_stats
177
+ @interpreter.storage_stats(store: packet[:store])
178
+
179
+ when :segment_manifest
180
+ @interpreter.segment_manifest(store: packet[:store])
181
+
182
+ when :compaction_activity
183
+ @interpreter.compaction_activity(
184
+ store: packet[:store],
185
+ kind: packet[:kind],
186
+ since: packet[:since],
187
+ limit: packet[:limit]
188
+ )
189
+ end
190
+ end
191
+
192
+ def ok_response(request_id, result)
193
+ {
194
+ protocol: PROTOCOL,
195
+ schema_version: SCHEMA_VERSION,
196
+ request_id: request_id,
197
+ status: :ok,
198
+ result: result
199
+ }
200
+ end
201
+
202
+ def error_response(request_id, message)
203
+ {
204
+ protocol: PROTOCOL,
205
+ schema_version: SCHEMA_VERSION,
206
+ request_id: request_id,
207
+ status: :error,
208
+ error: message
209
+ }
210
+ end
211
+ end
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "protocol/receipt"
4
+ require_relative "protocol/handlers/store_handler"
5
+ require_relative "protocol/handlers/history_handler"
6
+ require_relative "protocol/handlers/access_path_handler"
7
+ require_relative "protocol/handlers/relation_handler"
8
+ require_relative "protocol/handlers/projection_handler"
9
+ require_relative "protocol/handlers/derivation_handler"
10
+ require_relative "protocol/handlers/command_handler"
11
+ require_relative "protocol/handlers/effect_handler"
12
+ require_relative "protocol/handlers/subscription_handler"
13
+ require_relative "protocol/interpreter"
14
+ require_relative "protocol/sync_profile"
15
+ require_relative "protocol/wire_envelope"
16
+
17
+ module Igniter
18
+ module Store
19
+ module Protocol
20
+ # Convenience factory: Protocol.new returns a fresh Interpreter backed by
21
+ # an in-memory IgniterStore. Pass an existing store to wrap it instead.
22
+ def self.new(store = nil)
23
+ Interpreter.new(store || IgniterStore.new)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module Igniter
6
+ module Store
7
+ class ReadCache
8
+ include MonitorMixin
9
+
10
+ DEFAULT_LRU_CAP = 1_000
11
+
12
+ def initialize(lru_cap: DEFAULT_LRU_CAP)
13
+ super()
14
+ @entries = {}
15
+ @consumers = Hash.new { |hash, key| hash[key] = [] }
16
+ @scope_consumers = Hash.new { |hash, key| hash[key] = [] }
17
+ @lru_cap = lru_cap
18
+ # Tracks insertion/access order for time-travel cache entries only.
19
+ # Current-state entries (as_of: nil) live until explicit invalidation.
20
+ # Ruby Hash preserves insertion order; delete+reinsert = move to MRU.
21
+ @lru_order = {}
22
+ end
23
+
24
+ def register_consumer(store, callable)
25
+ synchronize { @consumers[store] << callable }
26
+ end
27
+
28
+ def register_scope_consumer(store, scope, callable)
29
+ synchronize { @scope_consumers[[store, scope]] << callable }
30
+ end
31
+
32
+ def get(store:, key:, as_of: nil, ttl: nil)
33
+ cache_key = [store, key, as_of]
34
+ entry = synchronize do
35
+ e = @entries[cache_key]
36
+ if e && as_of
37
+ @lru_order.delete(cache_key)
38
+ @lru_order[cache_key] = true
39
+ end
40
+ e
41
+ end
42
+ return nil unless entry
43
+
44
+ if ttl
45
+ age = Process.clock_gettime(Process::CLOCK_REALTIME) - entry.fetch(:cached_at)
46
+ return nil if age > ttl
47
+ end
48
+
49
+ entry.fetch(:fact)
50
+ end
51
+
52
+ def put(store:, key:, fact:, as_of: nil)
53
+ cache_key = [store, key, as_of]
54
+ synchronize do
55
+ @entries[cache_key] = {
56
+ fact: fact,
57
+ cached_at: Process.clock_gettime(Process::CLOCK_REALTIME)
58
+ }
59
+ if as_of
60
+ @lru_order[cache_key] = true
61
+ evict_lru_if_needed
62
+ end
63
+ end
64
+ end
65
+
66
+ def get_scope(store:, scope:, as_of: nil, ttl: nil)
67
+ cache_key = [:scope, store, scope, as_of]
68
+ entry = synchronize do
69
+ e = @entries[cache_key]
70
+ if e && as_of
71
+ @lru_order.delete(cache_key)
72
+ @lru_order[cache_key] = true
73
+ end
74
+ e
75
+ end
76
+ return nil unless entry
77
+
78
+ if ttl
79
+ age = Process.clock_gettime(Process::CLOCK_REALTIME) - entry.fetch(:cached_at)
80
+ return nil if age > ttl
81
+ end
82
+
83
+ entry.fetch(:facts)
84
+ end
85
+
86
+ def put_scope(store:, scope:, facts:, as_of: nil)
87
+ cache_key = [:scope, store, scope, as_of]
88
+ synchronize do
89
+ @entries[cache_key] = {
90
+ facts: facts,
91
+ cached_at: Process.clock_gettime(Process::CLOCK_REALTIME)
92
+ }
93
+ if as_of
94
+ @lru_order[cache_key] = true
95
+ evict_lru_if_needed
96
+ end
97
+ end
98
+ end
99
+
100
+ # +scope_changes+ is a Hash of { scope_name => :changed | :unchanged | :unknown }
101
+ # produced by IgniterStore#update_scope_indices. Scope consumers are only
102
+ # notified for scopes that are :changed or :unknown (conservative). Scopes
103
+ # marked :unchanged are skipped — their membership did not change and firing
104
+ # their consumers would be a false-positive thundering herd.
105
+ def invalidate(store:, key: nil, scope_changes: {})
106
+ point_targets, scope_notifications = synchronize do
107
+ affected_scopes = []
108
+ @entries.delete_if do |cache_key, _entry|
109
+ should_delete = if cache_key[0] == :scope && cache_key[1] == store
110
+ affected_scopes << cache_key[2]
111
+ true
112
+ else
113
+ cache_key[0] == store && (key.nil? || cache_key[1] == key)
114
+ end
115
+ @lru_order.delete(cache_key) if should_delete
116
+ should_delete
117
+ end
118
+
119
+ notify_scopes = affected_scopes.uniq.reject do |scope|
120
+ scope_changes[scope] == :unchanged
121
+ end
122
+
123
+ scope_notifs = notify_scopes.map do |scope|
124
+ [scope, @scope_consumers[[store, scope]].dup]
125
+ end
126
+
127
+ [@consumers[store].dup, scope_notifs]
128
+ end
129
+
130
+ point_targets.each { |t| notify(t, store, key) }
131
+ scope_notifications.each do |scope, targets|
132
+ targets.each { |t| notify_scope(t, store, scope) }
133
+ end
134
+ end
135
+
136
+ def lru_size
137
+ synchronize { @lru_order.size }
138
+ end
139
+
140
+ private
141
+
142
+ def evict_lru_if_needed
143
+ while @lru_order.size > @lru_cap
144
+ oldest_key, = @lru_order.first
145
+ @lru_order.delete(oldest_key)
146
+ @entries.delete(oldest_key)
147
+ end
148
+ end
149
+
150
+ def notify(target, store, key)
151
+ target.call(store, key)
152
+ rescue StandardError
153
+ nil
154
+ end
155
+
156
+ def notify_scope(target, store, scope)
157
+ target.call(store, scope)
158
+ rescue StandardError
159
+ nil
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Store
5
+ class SchemaGraph
6
+ def initialize
7
+ @paths = Hash.new { |hash, key| hash[key] = [] }
8
+ @projections = {}
9
+ @derivations = []
10
+ @scatters = []
11
+ @relations = {}
12
+ @retention = {}
13
+ # Raw protocol descriptor storage (OP2 — metadata export)
14
+ @store_descriptors = {}
15
+ @history_descriptors = {}
16
+ @command_descriptors = {}
17
+ @effect_descriptors = {}
18
+ @subscription_descriptors = {}
19
+ end
20
+
21
+ def register(path)
22
+ @paths[path.store] << path
23
+ self
24
+ end
25
+
26
+ def paths_for(store)
27
+ @paths[store].dup
28
+ end
29
+
30
+ def consumers_for(store)
31
+ @paths[store].flat_map { |path| path.consumers.to_a }.uniq
32
+ end
33
+
34
+ def path_for(store:, scope:)
35
+ @paths[store].find { |path| path.scope == scope }
36
+ end
37
+
38
+ def registered_stores
39
+ @paths.keys
40
+ end
41
+
42
+ # --- Projection registry ---
43
+
44
+ def register_projection(projection_path)
45
+ @projections[projection_path.name] = projection_path
46
+ self
47
+ end
48
+
49
+ def projection_for(name:)
50
+ @projections[name]
51
+ end
52
+
53
+ # All projections whose reads list includes the given store.
54
+ def projections_for_store(store:)
55
+ @projections.values.select { |p| p.reads.include?(store) }
56
+ end
57
+
58
+ # Compact snapshot of all registered projections, keyed by name.
59
+ # Parallel to metadata_snapshot for access paths.
60
+ def projection_snapshot
61
+ @projections.transform_values do |p|
62
+ {
63
+ name: p.name,
64
+ reads: p.reads,
65
+ relations: p.relations,
66
+ consumer_hint: p.consumer_hint,
67
+ reactive: p.reactive,
68
+ store_count: p.reads.size,
69
+ relation_count: p.relations.size
70
+ }
71
+ end
72
+ end
73
+
74
+ # --- Derivation registry ---
75
+
76
+ def register_derivation(derivation_rule)
77
+ @derivations << derivation_rule
78
+ self
79
+ end
80
+
81
+ # All derivation rules whose source_store matches the given store.
82
+ def derivations_for_store(store:)
83
+ @derivations.select { |r| r.source_store == store }
84
+ end
85
+
86
+ # Compact snapshot of registered derivation rules (rule callables omitted).
87
+ def derivation_snapshot
88
+ @derivations.map.with_index do |r, i|
89
+ {
90
+ index: i,
91
+ source_store: r.source_store,
92
+ source_filters: r.source_filters,
93
+ target_store: r.target_store,
94
+ target_key: r.target_key.respond_to?(:call) ? :callable : r.target_key,
95
+ has_rule: true
96
+ }
97
+ end
98
+ end
99
+
100
+ # --- Scatter Derivation registry ---
101
+
102
+ def register_scatter(scatter_rule)
103
+ @scatters << scatter_rule
104
+ self
105
+ end
106
+
107
+ # All scatter rules whose source_store matches the given store.
108
+ def scatters_for_store(store:)
109
+ @scatters.select { |r| r.source_store == store }
110
+ end
111
+
112
+ # Compact snapshot of registered scatter rules (rule callables omitted).
113
+ def scatter_snapshot
114
+ @scatters.map.with_index do |r, i|
115
+ {
116
+ index: i,
117
+ source_store: r.source_store,
118
+ partition_by: r.partition_by,
119
+ target_store: r.target_store,
120
+ has_rule: true
121
+ }
122
+ end
123
+ end
124
+
125
+ # --- Relation registry ---
126
+
127
+ def register_relation(relation_rule)
128
+ @relations[relation_rule.name] = relation_rule
129
+ self
130
+ end
131
+
132
+ def relation_for(name:)
133
+ @relations[name]
134
+ end
135
+
136
+ def registered_relations
137
+ @relations.keys
138
+ end
139
+
140
+ # Compact snapshot of all registered relations (no callables — pure metadata).
141
+ def relation_snapshot
142
+ @relations.transform_values do |r|
143
+ {
144
+ name: r.name,
145
+ source: r.source,
146
+ partition: r.partition,
147
+ target: r.target,
148
+ index_store: :"__rel_#{r.name}"
149
+ }
150
+ end
151
+ end
152
+
153
+ # --- Retention registry ---
154
+
155
+ def register_retention(store, policy)
156
+ @retention[store] = policy
157
+ self
158
+ end
159
+
160
+ # Returns the RetentionPolicy for store, or nil (meaning :permanent / no compaction).
161
+ def retention_for(store:)
162
+ @retention[store]
163
+ end
164
+
165
+ # Stores with an explicitly registered retention policy (any strategy).
166
+ def retention_stores
167
+ @retention.keys
168
+ end
169
+
170
+ # Compact snapshot of all registered retention policies.
171
+ def retention_snapshot
172
+ @retention.transform_values { |p| { strategy: p.strategy, duration: p.duration } }
173
+ end
174
+
175
+ # --- Raw descriptor storage (OP2 — metadata export) ---
176
+
177
+ def register_store_descriptor(descriptor)
178
+ @store_descriptors[descriptor[:name].to_sym] = descriptor
179
+ self
180
+ end
181
+
182
+ def register_history_descriptor(descriptor)
183
+ @history_descriptors[descriptor[:name].to_sym] = descriptor
184
+ self
185
+ end
186
+
187
+ def register_subscription_descriptor(descriptor)
188
+ @subscription_descriptors[descriptor[:name].to_sym] = descriptor
189
+ self
190
+ end
191
+
192
+ def register_command_descriptor(descriptor)
193
+ owner = descriptor[:owner].to_sym
194
+ name = descriptor[:name].to_sym
195
+ @command_descriptors[owner] ||= {}
196
+ @command_descriptors[owner][name] = descriptor
197
+ self
198
+ end
199
+
200
+ def register_effect_descriptor(descriptor)
201
+ owner = descriptor[:owner].to_sym
202
+ name = descriptor[:name].to_sym
203
+ @effect_descriptors[owner] ||= {}
204
+ @effect_descriptors[owner][name] = descriptor
205
+ self
206
+ end
207
+
208
+ def command_snapshot
209
+ @command_descriptors
210
+ end
211
+
212
+ def effect_snapshot
213
+ @effect_descriptors
214
+ end
215
+
216
+ # Snapshot of all raw protocol-level descriptors registered via OP1.
217
+ def descriptor_snapshot
218
+ {
219
+ stores: @store_descriptors,
220
+ histories: @history_descriptors,
221
+ commands: @command_descriptors,
222
+ effects: @effect_descriptors,
223
+ subscriptions: @subscription_descriptors
224
+ }
225
+ end
226
+
227
+ # Returns a compact snapshot of all registered access paths keyed by store.
228
+ # Each entry describes how the engine routes scope queries for that store:
229
+ # scope name, lookup strategy, active filters, cache TTL, and consumer count.
230
+ # Index descriptors (which fields are co-indexed) remain manifest/facade
231
+ # metadata — they are a schema contract, not an engine routing concern.
232
+ def metadata_snapshot
233
+ @paths.each_with_object({}) do |(store, paths), snapshot|
234
+ snapshot[store] = paths.map do |path|
235
+ {
236
+ store: path.store,
237
+ scope: path.scope,
238
+ lookup: path.lookup,
239
+ filters: path.filters,
240
+ cache_ttl: path.cache_ttl,
241
+ consumer_count: path.consumers.to_a.size
242
+ }
243
+ end
244
+ end
245
+ end
246
+ end
247
+ end
248
+ end