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,1190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "digest"
5
+ require "time"
6
+ require_relative "availability_ledger"
7
+ require_relative "ledger_boundary"
8
+
9
+ module Igniter
10
+ module Store
11
+ module IntelligentLedger
12
+ # Extends AvailabilityLedger with LedgerBoundary lifecycle management.
13
+ #
14
+ # Tracks boundaries in-memory (keyed by boundary_key) and persists
15
+ # closure/settlement/compaction receipts to the store.
16
+ #
17
+ # Additional store layout:
18
+ # :ledger_boundaries — key: boundary_key
19
+ # :ledger_boundary_receipts — key: boundary_key
20
+ # :ledger_boundary_summaries — key: boundary_key (settlement output)
21
+ # :ledger_boundary_metrics — key: boundary_key (settlement output)
22
+ # :ledger_settlement_receipts — key: boundary_key (settlement output)
23
+ # :ledger_cleanup_receipts — key: boundary_key (logical compaction receipt)
24
+ # :ledger_fact_redirects — key: original_fact_id (written at compaction)
25
+ # :ledger_relation_edge_targets — key: to_fact_id (access path; canonical is :ledger_relation_edges)
26
+ # :ledger_cleanup_execution_receipts — key: plan_hash (idempotent execution record)
27
+ # :ledger_physical_purge_receipts — key: plan_hash (physical purge audit record)
28
+ # :late_fact_receipts — key: "late/<boundary_key>/<token>"
29
+ #
30
+ # Proof-known raw stores scanned by resolve_ref(:raw):
31
+ RAW_PROOF_STORES = %i[availability_templates availability_overrides order_events].freeze
32
+
33
+ class AvailabilityBoundaryLedger
34
+ PRODUCER = {
35
+ "system" => "availability_boundary_ledger",
36
+ "version" => LedgerBoundary::RULE_VERSION
37
+ }.freeze
38
+
39
+ def initialize(store:)
40
+ @store = store
41
+ @ledger = AvailabilityLedger.new(store: store)
42
+ @boundaries = {}
43
+ end
44
+
45
+ # Delegate base fact writes to the underlying ledger.
46
+ def write_template(...) = @ledger.write_template(...)
47
+ def write_override(...) = @ledger.write_override(...)
48
+ def write_order_event(...) = @ledger.write_order_event(...)
49
+
50
+ # Opens a new boundary for a technician day (status: :open).
51
+ def open_boundary(company_id:, technician_id:, date:)
52
+ subject = build_subject(company_id, technician_id, date)
53
+ boundary = LedgerBoundary.new(subject: subject)
54
+ @boundaries[boundary.boundary_key] = boundary
55
+ boundary
56
+ end
57
+
58
+ # Returns the in-memory boundary for a given key, or nil.
59
+ def find_boundary(boundary_key)
60
+ @boundaries[boundary_key]
61
+ end
62
+
63
+ # Closes the boundary: derives snapshot, persists boundary records + receipts,
64
+ # transitions boundary to :closed.
65
+ #
66
+ # Returns:
67
+ # { boundary:, snapshot_fact:, receipt_fact:, boundary_fact:, closure_receipt_fact: }
68
+ def close_boundary(company_id:, technician_id:, date:, horizon_days: 1)
69
+ boundary = find_or_open_boundary(company_id: company_id, technician_id: technician_id, date: date)
70
+
71
+ result = @ledger.compute_snapshot(
72
+ technician_id: technician_id,
73
+ horizon_start: coerce_date(date),
74
+ horizon_days: horizon_days
75
+ )
76
+ snapshot_fact = result[:snapshot_fact]
77
+ receipt_fact = result[:receipt_fact]
78
+ source_ids = snapshot_fact.value[:derived_from_fact_ids] || []
79
+ source_refs = snapshot_fact.value[:derived_from_fact_refs] || []
80
+
81
+ boundary.close!(
82
+ output_fact: snapshot_fact,
83
+ receipt_fact: receipt_fact,
84
+ source_fact_ids: source_ids,
85
+ source_fact_refs: source_refs
86
+ )
87
+
88
+ boundary_fact = @store.write(
89
+ store: :ledger_boundaries,
90
+ key: boundary.boundary_key,
91
+ value: boundary_record_value(boundary),
92
+ producer: PRODUCER
93
+ )
94
+
95
+ closure_receipt = @store.write(
96
+ store: :ledger_boundary_receipts,
97
+ key: boundary.boundary_key,
98
+ value: {
99
+ "boundary_key" => boundary.boundary_key,
100
+ "output_fact_id" => boundary.output_fact_id,
101
+ "receipt_fact_id" => boundary.receipt_fact_id,
102
+ "result_hash" => boundary.result_hash,
103
+ "source_fact_ids" => boundary.source_fact_ids,
104
+ "source_fact_refs" => boundary.source_fact_refs,
105
+ "detail_status" => boundary.detail_status.to_s,
106
+ "closed_at" => boundary.closed_at.iso8601(3)
107
+ },
108
+ producer: PRODUCER
109
+ )
110
+
111
+ { boundary: boundary, snapshot_fact: snapshot_fact, receipt_fact: receipt_fact,
112
+ boundary_fact: boundary_fact, closure_receipt_fact: closure_receipt }
113
+ end
114
+
115
+ # Settle a closed boundary: runs pre-compaction transforms (summary, metrics),
116
+ # persists settlement receipt, transitions settlement_status to :settled.
117
+ #
118
+ # Settlement transforms:
119
+ # "availability_summary" — compact summary of the snapshot output
120
+ # "availability_metrics" — derived capacity metrics
121
+ #
122
+ # Returns:
123
+ # { boundary:, summary_fact:, metrics_fact:, settlement_receipt: }
124
+ def settle_boundary(boundary_key)
125
+ boundary = @boundaries[boundary_key]
126
+ raise ArgumentError, "boundary not found: #{boundary_key}" unless boundary
127
+ raise ArgumentError, "boundary must be closed before settlement" unless boundary.status == :closed
128
+ raise ArgumentError, "boundary already settled" if boundary.settled?
129
+
130
+ output = boundary.output_value
131
+ slots = output[:available_slots] || []
132
+ blocked = output[:blocked_intervals] || []
133
+ avail_secs = output[:available_seconds].to_f
134
+ blocked_secs = blocked.sum { |b| b[:end].to_f - b[:start].to_f }
135
+
136
+ # Transform 1: availability summary
137
+ summary_fact = @store.write(
138
+ store: :ledger_boundary_summaries,
139
+ key: boundary_key,
140
+ value: {
141
+ "boundary_key" => boundary_key,
142
+ "summary_type" => "availability",
143
+ "available_seconds" => avail_secs.to_i,
144
+ "available_slot_count" => slots.size,
145
+ "blocked_interval_count" => blocked.size,
146
+ "source_fact_count" => boundary.source_fact_ids.size,
147
+ "result_hash" => boundary.result_hash
148
+ },
149
+ producer: PRODUCER
150
+ )
151
+
152
+ # Transform 2: capacity metrics (capacity_percent uses full 24h day as denominator)
153
+ metrics_fact = @store.write(
154
+ store: :ledger_boundary_metrics,
155
+ key: boundary_key,
156
+ value: {
157
+ "boundary_key" => boundary_key,
158
+ "capacity_percent" => (avail_secs / (24 * 3600.0) * 100).round(2),
159
+ "available_hours" => (avail_secs / 3600.0).round(4),
160
+ "blocked_hours" => (blocked_secs / 3600.0).round(4)
161
+ },
162
+ producer: PRODUCER
163
+ )
164
+
165
+ # Per-transform receipts (embedded in settlement receipt)
166
+ transforms = [
167
+ {
168
+ "transform_name" => "availability_summary",
169
+ "transform_version" => "1.0",
170
+ "input_boundary_key" => boundary_key,
171
+ "input_result_hash" => boundary.result_hash,
172
+ "output_fact_id" => summary_fact.id,
173
+ "status" => "ok"
174
+ },
175
+ {
176
+ "transform_name" => "availability_metrics",
177
+ "transform_version" => "1.0",
178
+ "input_boundary_key" => boundary_key,
179
+ "input_result_hash" => boundary.result_hash,
180
+ "output_fact_id" => metrics_fact.id,
181
+ "status" => "ok"
182
+ }
183
+ ]
184
+
185
+ settlement_receipt = @store.write(
186
+ store: :ledger_settlement_receipts,
187
+ key: boundary_key,
188
+ value: {
189
+ "boundary_key" => boundary_key,
190
+ "settlement_status" => "settled",
191
+ "transform_names" => transforms.map { |t| t["transform_name"] },
192
+ "output_fact_ids" => {
193
+ "availability_summary" => summary_fact.id,
194
+ "availability_metrics" => metrics_fact.id
195
+ },
196
+ "result_hash" => boundary.result_hash,
197
+ "transforms" => transforms,
198
+ "settled_at" => Time.now.iso8601(3)
199
+ },
200
+ producer: PRODUCER
201
+ )
202
+
203
+ boundary.settle!(settlement_receipt_id: settlement_receipt.id)
204
+
205
+ { boundary: boundary, summary_fact: summary_fact,
206
+ metrics_fact: metrics_fact, settlement_receipt: settlement_receipt }
207
+ end
208
+
209
+ # Compact a settled boundary: marks detail_status :purged, writes cleanup receipt,
210
+ # and writes one :ledger_fact_redirects entry per source_fact_id.
211
+ # Settlement is required before compaction.
212
+ # Returns the compaction receipt fact.
213
+ def compact_boundary(boundary_key)
214
+ boundary = @boundaries[boundary_key]
215
+ raise ArgumentError, "boundary not found: #{boundary_key}" unless boundary
216
+ raise ArgumentError, "boundary must be closed before compaction" unless boundary.status == :closed
217
+ raise ArgumentError, "boundary must be settled before compaction" unless boundary.settled?
218
+
219
+ compacted_at = Time.now.iso8601(3)
220
+
221
+ compaction_receipt = @store.write(
222
+ store: :ledger_cleanup_receipts,
223
+ key: boundary_key,
224
+ value: {
225
+ "boundary_key" => boundary_key,
226
+ "output_fact_id" => boundary.output_fact_id,
227
+ "result_hash" => boundary.result_hash,
228
+ "source_fact_ids" => boundary.source_fact_ids,
229
+ "source_fact_refs" => boundary.source_fact_refs,
230
+ "settlement_receipt_id" => boundary.settlement_receipt_id,
231
+ "detail_status_after" => "purged",
232
+ "compacted_at" => compacted_at
233
+ },
234
+ producer: PRODUCER
235
+ )
236
+
237
+ # Use structured refs when available (provides original_store + source_role).
238
+ # Fall back to bare IDs with "unknown" store for old-style boundaries.
239
+ if boundary.source_fact_refs.any?
240
+ boundary.source_fact_refs.each do |ref|
241
+ @store.write(
242
+ store: :ledger_fact_redirects,
243
+ key: ref["id"],
244
+ value: {
245
+ "original_fact_id" => ref["id"],
246
+ "original_store" => ref["store"],
247
+ "source_role" => ref["role"],
248
+ "boundary_key" => boundary_key,
249
+ "boundary_policy" => LedgerBoundary::POLICY_NAME,
250
+ "boundary_output_fact_id" => boundary.output_fact_id,
251
+ "boundary_receipt_id" => boundary.receipt_fact_id,
252
+ "settlement_receipt_id" => boundary.settlement_receipt_id,
253
+ "compaction_receipt_id" => compaction_receipt.id,
254
+ "detail_status" => "purged",
255
+ "reference_role" => "included_in_boundary",
256
+ "compacted_at" => compacted_at
257
+ },
258
+ producer: PRODUCER
259
+ )
260
+ end
261
+ else
262
+ boundary.source_fact_ids.each do |src_id|
263
+ @store.write(
264
+ store: :ledger_fact_redirects,
265
+ key: src_id,
266
+ value: {
267
+ "original_fact_id" => src_id,
268
+ "original_store" => "unknown",
269
+ "boundary_key" => boundary_key,
270
+ "boundary_policy" => LedgerBoundary::POLICY_NAME,
271
+ "boundary_output_fact_id" => boundary.output_fact_id,
272
+ "boundary_receipt_id" => boundary.receipt_fact_id,
273
+ "settlement_receipt_id" => boundary.settlement_receipt_id,
274
+ "compaction_receipt_id" => compaction_receipt.id,
275
+ "detail_status" => "purged",
276
+ "reference_role" => "included_in_boundary",
277
+ "compacted_at" => compacted_at
278
+ },
279
+ producer: PRODUCER
280
+ )
281
+ end
282
+ end
283
+
284
+ boundary.compact!(compaction_receipt_id: compaction_receipt.id)
285
+ compaction_receipt
286
+ end
287
+
288
+ # Boundary replay: returns closed output without scanning source facts.
289
+ # Works regardless of detail_status (even after compaction).
290
+ #
291
+ # Returns:
292
+ # { status: :ok, fidelity: :boundary, output:, boundary_id:, result_hash:, detail_status: }
293
+ # { status: :open, boundary_key: } — if boundary is still open
294
+ # { status: :not_found, boundary_key: } — if boundary unknown
295
+ def replay(boundary_key)
296
+ boundary = @boundaries[boundary_key]
297
+ return { status: :not_found, boundary_key: boundary_key } unless boundary
298
+ return { status: :open, boundary_key: boundary_key } unless boundary.closed?
299
+
300
+ {
301
+ status: :ok,
302
+ fidelity: :boundary,
303
+ output: boundary.output_value,
304
+ boundary_id: boundary_key,
305
+ result_hash: boundary.result_hash,
306
+ detail_status: boundary.detail_status
307
+ }
308
+ end
309
+
310
+ # Full replay: uses all internal source facts.
311
+ # After compaction returns :detail_unavailable.
312
+ #
313
+ # Returns:
314
+ # { status: :ok, fidelity: :full, output:, boundary_id:, detail_status: }
315
+ # { status: :detail_unavailable, boundary_id:, detail_status: :purged, boundary_receipt_id: }
316
+ def full_replay(company_id:, technician_id:, date:, horizon_days: 1)
317
+ boundary_key = LedgerBoundary.key_for(
318
+ company_id: company_id.to_s,
319
+ technician_id: technician_id.to_s,
320
+ date: date.to_s
321
+ )
322
+ boundary = @boundaries[boundary_key]
323
+
324
+ if boundary&.compacted?
325
+ receipt_fact = @store.history(store: :ledger_boundary_receipts, key: boundary_key).last
326
+ return {
327
+ status: :detail_unavailable,
328
+ boundary_id: boundary_key,
329
+ detail_status: :purged,
330
+ boundary_receipt_id: receipt_fact&.id
331
+ }
332
+ end
333
+
334
+ result = @ledger.compute_snapshot(
335
+ technician_id: technician_id,
336
+ horizon_start: coerce_date(date),
337
+ horizon_days: horizon_days
338
+ )
339
+ {
340
+ status: :ok,
341
+ fidelity: :full,
342
+ output: result[:snapshot_fact].value,
343
+ boundary_id: boundary_key,
344
+ detail_status: boundary&.detail_status || :full
345
+ }
346
+ end
347
+
348
+ # Returns a cleanup plan for a given store and time cutoff.
349
+ #
350
+ # :blocked — open boundaries, or closed-but-unsettled boundaries, in the window
351
+ # :ready — all required boundaries are settled; receipts listed for retention
352
+ #
353
+ # blocking_reasons maps each blocking boundary_key to its reason:
354
+ # :open — boundary is still open
355
+ # :settlement_required — boundary is closed but not yet settled
356
+ # Returns a cleanup plan for a given store and time cutoff.
357
+ #
358
+ # :blocked — open boundaries, closed-but-unsettled, or (when
359
+ # require_reference_redirects: true) settled boundaries whose source facts
360
+ # still have raw or unresolved external relation edges.
361
+ # :ready — all in-window boundaries are settled and no blocking reference
362
+ # edges remain.
363
+ #
364
+ # require_reference_redirects: (default false, preserving existing behavior)
365
+ # When true, the plan also checks :ledger_relation_edges. A settled boundary
366
+ # whose source facts are pointed at by raw or unresolved edges is blocked with
367
+ # reason :external_reference_redirect_required.
368
+ def cleanup_plan(store:, before:, fidelity: :boundary, require_reference_redirects: false)
369
+ in_window = @boundaries.values.select { |b| boundary_date_before?(b, before) }
370
+ open_blocking = in_window.select(&:open?)
371
+ unsettled_blocking = in_window.select { |b| b.status == :closed && !b.settled? }
372
+
373
+ reference_blocking = []
374
+ blocking_relation_edges = []
375
+
376
+ if require_reference_redirects
377
+ in_window.select(&:settled?).each do |boundary|
378
+ raw_edges = raw_external_edges_for(boundary)
379
+ unless raw_edges.empty?
380
+ reference_blocking << boundary
381
+ blocking_relation_edges.concat(raw_edges.map { |e|
382
+ { edge_id: e[:edge_id],
383
+ to_fact_id: e[:to_fact_id],
384
+ ref_status: e[:ref_status].to_s.to_sym,
385
+ boundary_key: boundary.boundary_key }
386
+ })
387
+ end
388
+ end
389
+ end
390
+
391
+ all_blocking = open_blocking + unsettled_blocking + reference_blocking
392
+
393
+ if all_blocking.empty?
394
+ receipts = @boundaries.values.filter_map do |b|
395
+ next unless b.closed?
396
+ @store.history(store: :ledger_boundary_receipts, key: b.boundary_key).last&.id
397
+ end
398
+ result = {
399
+ status: :ready,
400
+ store: store,
401
+ before: before.iso8601,
402
+ fidelity: fidelity,
403
+ require_reference_redirects: require_reference_redirects,
404
+ blocking_boundaries: [],
405
+ required_boundary_policies: [LedgerBoundary::POLICY_NAME.to_sym],
406
+ receipts_to_keep: receipts,
407
+ expected_detail_status: fidelity == :boundary ? :purged : :full
408
+ }
409
+ result[:blocking_relation_edges] = [] if require_reference_redirects
410
+ result
411
+ else
412
+ blocking_reasons = {}
413
+ open_blocking.each { |b| blocking_reasons[b.boundary_key] = :open }
414
+ unsettled_blocking.each { |b| blocking_reasons[b.boundary_key] = :settlement_required }
415
+ reference_blocking.each { |b| blocking_reasons[b.boundary_key] = :external_reference_redirect_required }
416
+ result = {
417
+ status: :blocked,
418
+ store: store,
419
+ before: before.iso8601,
420
+ fidelity: fidelity,
421
+ require_reference_redirects: require_reference_redirects,
422
+ blocking_boundaries: all_blocking.map(&:boundary_key),
423
+ blocking_reasons: blocking_reasons,
424
+ required_boundary_policies: [LedgerBoundary::POLICY_NAME.to_sym]
425
+ }
426
+ result[:blocking_relation_edges] = blocking_relation_edges if require_reference_redirects
427
+ result
428
+ end
429
+ end
430
+
431
+ # Rebuilds the in-memory boundary registry from persisted store facts.
432
+ #
433
+ # Reads :ledger_boundaries, :ledger_boundary_receipts,
434
+ # :ledger_settlement_receipts, and :ledger_cleanup_receipts to restore
435
+ # boundary state. Recovers output_value by scanning :availability_snapshots
436
+ # for the fact referenced by output_fact_id (linear scan — acceptable for proof).
437
+ #
438
+ # Idempotent: boundaries already in the registry are skipped.
439
+ # Incomplete records (missing closure receipt) are skipped with a warning.
440
+ #
441
+ # Returns:
442
+ # { status: :ok, hydrated_count:, skipped_count:, warnings: [] }
443
+ def hydrate_boundaries
444
+ hydrated = 0
445
+ skipped = 0
446
+ warnings = []
447
+
448
+ @store.history(store: :ledger_boundaries)
449
+ .group_by(&:key)
450
+ .each do |bk, facts|
451
+ next if @boundaries.key?(bk)
452
+
453
+ br = facts.max_by(&:transaction_time).value
454
+
455
+ closure_facts = @store.history(store: :ledger_boundary_receipts, key: bk)
456
+ if closure_facts.empty?
457
+ skipped += 1
458
+ warnings << "boundary #{bk}: closure receipt missing, skipped"
459
+ next
460
+ end
461
+
462
+ settlement_facts = @store.history(store: :ledger_settlement_receipts, key: bk)
463
+ settlement_receipt_id = settlement_facts.empty? ? nil : settlement_facts.last.id
464
+
465
+ cleanup_facts = @store.history(store: :ledger_cleanup_receipts, key: bk)
466
+ cleanup_receipt = cleanup_facts.last
467
+ compaction_receipt_id = cleanup_receipt&.id
468
+ compacted_at = cleanup_receipt \
469
+ ? safe_parse_time(cleanup_receipt.value[:compacted_at]) : nil
470
+
471
+ output_value = find_snapshot_value(br[:output_fact_id])
472
+
473
+ boundary = LedgerBoundary.from_persisted(
474
+ boundary_record: br,
475
+ output_value: output_value,
476
+ settlement_receipt_id: settlement_receipt_id,
477
+ compaction_receipt_id: compaction_receipt_id,
478
+ compacted_at: compacted_at
479
+ )
480
+
481
+ @boundaries[bk] = boundary
482
+ hydrated += 1
483
+ end
484
+
485
+ { status: :ok, hydrated_count: hydrated, skipped_count: skipped, warnings: warnings }
486
+ end
487
+
488
+ # Resolves a reference to a fact, respecting the required fidelity.
489
+ #
490
+ # fidelity:
491
+ # :raw — return raw fact if accessible; never silently downgrade to boundary
492
+ # evidence. With assume_compacted: true (or when raw is physically
493
+ # absent), returns :detail_unavailable with redirect evidence.
494
+ # :boundary — intentionally follow redirect evidence when raw is compacted.
495
+ # Returns :redirected with boundary evidence.
496
+ # :summary — like :boundary but marks kind: :summary_ref, signals settlement
497
+ # evidence is available via settlement_receipt_id in the redirect.
498
+ #
499
+ # assume_compacted: — for :raw fidelity only. When true, skips raw fact lookup
500
+ # and returns :detail_unavailable if a redirect exists. Useful in tests to
501
+ # simulate physical purge (which this proof does not perform).
502
+ #
503
+ # Returns one of:
504
+ # { status: :ok, kind: :raw_fact, fact: <Fact> }
505
+ # { status: :redirected, kind: :boundary_ref | :summary_ref, ... }
506
+ # { status: :detail_unavailable, original_fact_id:, boundary_key:,
507
+ # required_fidelity: :raw, available_fidelity: :boundary, evidence: }
508
+ # { status: :not_found, original_fact_id: }
509
+ # Raises ArgumentError for unsupported fidelity values.
510
+ def resolve_ref(fact_id, fidelity: :boundary, assume_compacted: false)
511
+ unless %i[raw boundary summary].include?(fidelity)
512
+ raise ArgumentError, "unsupported fidelity: #{fidelity.inspect}"
513
+ end
514
+
515
+ redirect = latest_redirect(fact_id)
516
+
517
+ case fidelity
518
+ when :raw
519
+ return { status: :not_found, original_fact_id: fact_id } unless redirect
520
+ return raw_detail_unavailable(fact_id, redirect) if assume_compacted
521
+
522
+ store_hint = redirect[:original_store]&.to_s
523
+ raw_fact = find_raw_fact(fact_id, store_hint: store_hint)
524
+ raw_fact ? { status: :ok, kind: :raw_fact, fact: raw_fact }
525
+ : raw_detail_unavailable(fact_id, redirect)
526
+
527
+ when :boundary
528
+ return { status: :not_found, original_fact_id: fact_id } unless redirect
529
+ boundary_redirect_response(fact_id, redirect)
530
+
531
+ when :summary
532
+ return { status: :not_found, original_fact_id: fact_id } unless redirect
533
+ summary_redirect_response(fact_id, redirect)
534
+ end
535
+ end
536
+
537
+ # Creates a relation edge from one fact to another, persisting compact metadata.
538
+ #
539
+ # Uses fact_ref(to_fact_id) to populate to_store/to_key from the live index.
540
+ # If the target fact is unknown, the edge is persisted as :unresolved rather
541
+ # than raising an exception.
542
+ #
543
+ # Returns: { edge_id:, edge_fact: }
544
+ def link_fact(from_store:, from_key:, from_fact_id:, to_fact_id:, relation:)
545
+ edge_id = SecureRandom.uuid
546
+ to_ref = @store.fact_ref(to_fact_id)
547
+
548
+ value = if to_ref
549
+ {
550
+ "edge_id" => edge_id,
551
+ "relation" => relation.to_s,
552
+ "from_store" => from_store.to_s,
553
+ "from_key" => from_key.to_s,
554
+ "from_fact_id" => from_fact_id.to_s,
555
+ "to_store" => to_ref[:store].to_s,
556
+ "to_key" => to_ref[:key].to_s,
557
+ "to_fact_id" => to_fact_id.to_s,
558
+ "to_boundary_key" => nil,
559
+ "ref_status" => "raw",
560
+ "fidelity" => "raw",
561
+ "evidence" => {}
562
+ }
563
+ else
564
+ {
565
+ "edge_id" => edge_id,
566
+ "relation" => relation.to_s,
567
+ "from_store" => from_store.to_s,
568
+ "from_key" => from_key.to_s,
569
+ "from_fact_id" => from_fact_id.to_s,
570
+ "to_store" => nil,
571
+ "to_key" => nil,
572
+ "to_fact_id" => to_fact_id.to_s,
573
+ "to_boundary_key" => nil,
574
+ "ref_status" => "unresolved",
575
+ "fidelity" => "raw",
576
+ "evidence" => {}
577
+ }
578
+ end
579
+
580
+ edge_fact = @store.write(
581
+ store: :ledger_relation_edges,
582
+ key: edge_id,
583
+ value: value,
584
+ producer: PRODUCER
585
+ )
586
+
587
+ write_relation_edge_target(
588
+ edge_id: edge_id,
589
+ to_fact_id: to_fact_id.to_s,
590
+ from_store: from_store.to_s,
591
+ from_fact_id: from_fact_id.to_s,
592
+ to_store: value["to_store"],
593
+ to_boundary_key: nil,
594
+ ref_status: value["ref_status"],
595
+ relation: relation.to_s,
596
+ evidence: {}
597
+ )
598
+
599
+ { edge_id: edge_id, edge_fact: edge_fact }
600
+ end
601
+
602
+ # Resolves a relation edge by edge_id, respecting the required fidelity.
603
+ #
604
+ # Delegates to resolve_ref semantics; maps results to edge vocabulary:
605
+ #
606
+ # { status: :ok, ref_status: :raw, fidelity: :raw, to_fact: <Fact> }
607
+ # { status: :ok, ref_status: :redirected, fidelity: :boundary,
608
+ # to_boundary_key:, evidence: }
609
+ # { status: :detail_unavailable, to_fact_id:, evidence: }
610
+ # { status: :unresolved, ref_status: :unresolved, to_fact_id: }
611
+ # { status: :not_found, edge_id: }
612
+ #
613
+ # assume_compacted: — for :raw fidelity; skips live fact check, returns
614
+ # detail_unavailable when redirect exists.
615
+ def resolve_edge(edge_id, fidelity: :boundary, assume_compacted: false)
616
+ edge_facts = @store.history(store: :ledger_relation_edges, key: edge_id)
617
+ return { status: :not_found, edge_id: edge_id } if edge_facts.empty?
618
+
619
+ edge = edge_facts.max_by(&:transaction_time).value
620
+ to_fact_id = edge[:to_fact_id]
621
+
622
+ return { status: :unresolved, ref_status: :unresolved, to_fact_id: to_fact_id } \
623
+ if edge[:ref_status].to_s == "unresolved"
624
+
625
+ # For :raw fidelity without assume_compacted, try live fact lookup first.
626
+ # resolve_ref(:raw) requires a redirect to exist; edges must also resolve
627
+ # when the raw fact is still live and no redirect has been written yet.
628
+ if fidelity == :raw && !assume_compacted
629
+ raw = @store.fact_by_id(to_fact_id)
630
+ return { status: :ok, ref_status: :raw, fidelity: :raw, to_fact: raw } if raw
631
+ end
632
+
633
+ ref_result = resolve_ref(to_fact_id, fidelity: fidelity, assume_compacted: assume_compacted)
634
+
635
+ case ref_result[:status]
636
+ when :ok
637
+ if ref_result[:kind] == :raw_fact
638
+ { status: :ok, ref_status: :raw, fidelity: :raw, to_fact: ref_result[:fact] }
639
+ else
640
+ { status: :ok, ref_status: :redirected, fidelity: :boundary,
641
+ to_boundary_key: ref_result[:boundary_key],
642
+ evidence: ref_result[:evidence] }
643
+ end
644
+ when :redirected
645
+ { status: :ok, ref_status: :redirected, fidelity: :boundary,
646
+ to_boundary_key: ref_result[:boundary_key],
647
+ evidence: ref_result[:evidence] }
648
+ when :detail_unavailable
649
+ { status: :detail_unavailable, to_fact_id: to_fact_id, evidence: ref_result[:evidence] }
650
+ else
651
+ { status: :unresolved, ref_status: :unresolved, to_fact_id: to_fact_id }
652
+ end
653
+ end
654
+
655
+ # Scans all raw relation edges and updates those whose target fact has been
656
+ # compacted (redirect evidence available) to ref_status: "redirected".
657
+ #
658
+ # assume_compacted: — when true, skips live fact check (simulates physical purge).
659
+ # Idempotent: already-redirected and unresolved edges are skipped.
660
+ #
661
+ # Returns: { refreshed_count:, skipped_count:, unresolved_count: }
662
+ def refresh_relation_edges(assume_compacted: false)
663
+ refreshed = 0
664
+ skipped = 0
665
+ unresolved = 0
666
+
667
+ @store.history(store: :ledger_relation_edges)
668
+ .group_by(&:key)
669
+ .each do |edge_id, facts|
670
+ edge = facts.max_by(&:transaction_time).value
671
+ next skipped += 1 unless edge[:ref_status].to_s == "raw"
672
+
673
+ to_fact_id = edge[:to_fact_id]
674
+
675
+ unless assume_compacted
676
+ next skipped += 1 if @store.fact_by_id(to_fact_id)
677
+ end
678
+
679
+ redirect = latest_redirect(to_fact_id)
680
+ next unresolved += 1 unless redirect
681
+
682
+ evidence = {
683
+ "boundary_output_fact_id" => redirect[:boundary_output_fact_id],
684
+ "boundary_receipt_id" => redirect[:boundary_receipt_id],
685
+ "settlement_receipt_id" => redirect[:settlement_receipt_id],
686
+ "compaction_receipt_id" => redirect[:compaction_receipt_id]
687
+ }
688
+
689
+ @store.write(
690
+ store: :ledger_relation_edges,
691
+ key: edge_id,
692
+ value: edge.transform_keys(&:to_s).merge(
693
+ "ref_status" => "redirected",
694
+ "fidelity" => "boundary",
695
+ "to_boundary_key" => redirect[:boundary_key],
696
+ "evidence" => evidence,
697
+ "refreshed_at" => Time.now.iso8601(3)
698
+ ),
699
+ producer: PRODUCER
700
+ )
701
+
702
+ write_relation_edge_target(
703
+ edge_id: edge_id,
704
+ to_fact_id: to_fact_id.to_s,
705
+ from_store: edge[:from_store].to_s,
706
+ from_fact_id: edge[:from_fact_id].to_s,
707
+ to_store: edge[:to_store]&.to_s,
708
+ to_boundary_key: redirect[:boundary_key],
709
+ ref_status: "redirected",
710
+ relation: edge[:relation].to_s,
711
+ evidence: evidence
712
+ )
713
+
714
+ refreshed += 1
715
+ end
716
+
717
+ { refreshed_count: refreshed, skipped_count: skipped, unresolved_count: unresolved }
718
+ end
719
+
720
+ # Records a late fact for a closed boundary without mutating the original.
721
+ # The original result_hash and settlement outputs remain unchanged.
722
+ # Records boundary_status_at_arrival and settlement_status_at_arrival so
723
+ # callers can see whether the boundary was settled or compacted at the time.
724
+ # Returns the late-fact receipt.
725
+ def write_late_fact(boundary_key:, fact_value:, fact_type:)
726
+ boundary = @boundaries[boundary_key]
727
+ raise ArgumentError, "boundary not found: #{boundary_key}" unless boundary
728
+ raise ArgumentError, "boundary is not closed" unless boundary.closed?
729
+
730
+ @store.write(
731
+ store: :late_fact_receipts,
732
+ key: "late/#{boundary_key}/#{SecureRandom.hex(8)}",
733
+ value: {
734
+ "boundary_key" => boundary_key,
735
+ "fact_type" => fact_type.to_s,
736
+ "fact_value" => fact_value,
737
+ "original_result_hash" => boundary.result_hash,
738
+ "boundary_status_at_arrival" => boundary.status.to_s,
739
+ "settlement_status_at_arrival" => boundary.settlement_status.to_s,
740
+ "recorded_at" => Time.now.iso8601(3),
741
+ "disposition" => "correction_boundary"
742
+ },
743
+ producer: PRODUCER
744
+ )
745
+ end
746
+
747
+ # Replays :ledger_relation_edges into :ledger_relation_edge_targets.
748
+ # Use for recovery or proof setup when the target index is missing.
749
+ # Safe to call on a live ledger — idempotent (appends latest state per edge).
750
+ #
751
+ # Returns: { rebuilt_count: }
752
+ def rebuild_relation_edge_target_index
753
+ rebuilt = 0
754
+ @store.history(store: :ledger_relation_edges)
755
+ .group_by(&:key)
756
+ .each do |_edge_id, facts|
757
+ edge = facts.max_by(&:transaction_time).value
758
+ to_fact_id = (edge[:to_fact_id] || edge["to_fact_id"]).to_s
759
+ next if to_fact_id.empty?
760
+
761
+ write_relation_edge_target(
762
+ edge_id: (edge[:edge_id] || edge["edge_id"]).to_s,
763
+ to_fact_id: to_fact_id,
764
+ from_store: (edge[:from_store] || edge["from_store"]).to_s,
765
+ from_fact_id: (edge[:from_fact_id] || edge["from_fact_id"]).to_s,
766
+ to_store: (edge[:to_store] || edge["to_store"])&.to_s,
767
+ to_boundary_key: edge[:to_boundary_key] || edge["to_boundary_key"],
768
+ ref_status: (edge[:ref_status] || edge["ref_status"]).to_s,
769
+ relation: (edge[:relation] || edge["relation"]).to_s,
770
+ evidence: edge[:evidence] || edge["evidence"] || {}
771
+ )
772
+ rebuilt += 1
773
+ end
774
+ { rebuilt_count: rebuilt }
775
+ end
776
+
777
+ # Executes a ready cleanup plan and writes a durable receipt.
778
+ #
779
+ # For a ready plan:
780
+ # - Writes an executed_noop receipt to :ledger_cleanup_execution_receipts.
781
+ # - Is idempotent: a second call for the same plan returns deduplicated: true.
782
+ # - Returns { status: :executed_noop, plan_hash:, receipt_id:, deduplicated:, receipt: }.
783
+ #
784
+ # For a blocked plan:
785
+ # - Does not write a receipt.
786
+ # - Returns { status: :blocked, reason: :plan_not_ready, blocking_boundaries:,
787
+ # blocking_relation_edges: }.
788
+ #
789
+ # No physical deletion is performed in this slice.
790
+ def execute_cleanup_plan(plan)
791
+ unless plan[:status] == :ready
792
+ return {
793
+ status: :blocked,
794
+ reason: :plan_not_ready,
795
+ blocking_boundaries: Array(plan[:blocking_boundaries]),
796
+ blocking_relation_edges: Array(plan[:blocking_relation_edges])
797
+ }
798
+ end
799
+
800
+ hash = stable_plan_hash(plan)
801
+
802
+ # Idempotency check: return existing receipt when plan was already executed.
803
+ existing = @store.history(store: :ledger_cleanup_execution_receipts, key: hash)
804
+ unless existing.empty?
805
+ receipt = existing.last
806
+ return {
807
+ status: :executed_noop,
808
+ plan_hash: hash,
809
+ receipt_id: receipt.id,
810
+ deduplicated: true,
811
+ receipt: receipt.value
812
+ }
813
+ end
814
+
815
+ before_time = Time.parse(plan[:before].to_s)
816
+ in_window_keys = @boundaries.values
817
+ .select { |b| boundary_date_before?(b, before_time) && b.closed? }
818
+ .map(&:boundary_key)
819
+
820
+ require_rr = plan.fetch(:require_reference_redirects, false)
821
+
822
+ receipt_value = {
823
+ "status" => "executed_noop",
824
+ "plan_hash" => hash,
825
+ "store" => plan[:store].to_s,
826
+ "before" => plan[:before].to_s,
827
+ "fidelity" => plan.fetch(:fidelity, :boundary).to_s,
828
+ "require_reference_redirects" => require_rr,
829
+ "expected_detail_status" => plan.fetch(:expected_detail_status, :purged).to_s,
830
+ "boundary_keys" => in_window_keys,
831
+ "receipts_to_keep" => Array(plan[:receipts_to_keep]),
832
+ "blocking_relation_edges_count" => 0,
833
+ "relation_guard" => {
834
+ "checked" => require_rr,
835
+ "raw_edges" => 0,
836
+ "unresolved_edges" => 0,
837
+ "redirected_edges" => 0
838
+ },
839
+ "executed_at" => Time.now.utc.iso8601(3)
840
+ }
841
+
842
+ fact = @store.write(
843
+ store: :ledger_cleanup_execution_receipts,
844
+ key: hash,
845
+ value: receipt_value,
846
+ producer: PRODUCER
847
+ )
848
+
849
+ { status: :executed_noop, plan_hash: hash, receipt_id: fact.id,
850
+ deduplicated: false, receipt: receipt_value }
851
+ end
852
+
853
+ # Physically purges boundary source facts from the store, provided all
854
+ # safety rules pass.
855
+ #
856
+ # Safety rules (all must be true):
857
+ # - cleanup execution receipt exists with status == executed_noop
858
+ # - every boundary named in the receipt is compacted (logical purge done)
859
+ # - every source fact id has a redirect entry in :ledger_fact_redirects
860
+ # - the reference guard (require_reference_redirects: true) is still ready
861
+ # - the store backend supports exact fact pruning
862
+ #
863
+ # dry_run: true — returns what would be pruned, no facts removed.
864
+ # dry_run: false — calls store.prune_fact_ids and writes a physical purge receipt.
865
+ #
866
+ # Idempotent: second call for the same plan_hash returns deduplicated: true.
867
+ #
868
+ # Blocked reasons:
869
+ # :cleanup_execution_receipt_missing, :cleanup_execution_not_successful,
870
+ # :boundary_compaction_required, :fact_redirect_missing,
871
+ # :reference_guard_failed, :store_prune_unsupported
872
+ def purge_cleanup_execution(plan_hash:, dry_run: false)
873
+ # 1 — Find and validate the execution receipt
874
+ exec_facts = @store.history(store: :ledger_cleanup_execution_receipts, key: plan_hash)
875
+ unless exec_facts.any?
876
+ return { status: :blocked, reason: :cleanup_execution_receipt_missing,
877
+ plan_hash: plan_hash }
878
+ end
879
+
880
+ exec_receipt = exec_facts.last.value
881
+ unless exec_receipt[:status].to_s == "executed_noop"
882
+ return { status: :blocked, reason: :cleanup_execution_not_successful,
883
+ plan_hash: plan_hash, receipt_status: exec_receipt[:status] }
884
+ end
885
+
886
+ # 2 — Validate each boundary: compacted? + all redirects present
887
+ boundary_keys = Array(exec_receipt[:boundary_keys])
888
+ source_fact_ids = []
889
+
890
+ boundary_keys.each do |bk|
891
+ boundary = @boundaries[bk]
892
+ unless boundary&.compacted?
893
+ return { status: :blocked, reason: :boundary_compaction_required,
894
+ boundary_key: bk }
895
+ end
896
+
897
+ boundary.source_fact_ids.each do |src_id|
898
+ unless latest_redirect(src_id)
899
+ return { status: :blocked, reason: :fact_redirect_missing,
900
+ fact_id: src_id, boundary_key: bk }
901
+ end
902
+ source_fact_ids << src_id
903
+ end
904
+ end
905
+
906
+ # 3 — Re-run reference guard
907
+ before_time = safe_parse_time(exec_receipt[:before])
908
+ guard_plan = cleanup_plan(
909
+ store: (exec_receipt[:store] || "order_events").to_sym,
910
+ before: before_time || Time.now,
911
+ fidelity: (exec_receipt[:fidelity] || "boundary").to_sym,
912
+ require_reference_redirects: exec_receipt[:require_reference_redirects]
913
+ )
914
+
915
+ if guard_plan[:status] != :ready
916
+ return {
917
+ status: :blocked,
918
+ reason: :reference_guard_failed,
919
+ blocking_boundaries: guard_plan[:blocking_boundaries],
920
+ blocking_relation_edges: Array(guard_plan[:blocking_relation_edges])
921
+ }
922
+ end
923
+
924
+ fact_ids_to_prune = source_fact_ids.uniq
925
+
926
+ # 4 — Dry run: return intent without any deletion
927
+ if dry_run
928
+ return {
929
+ status: :ready,
930
+ dry_run: true,
931
+ fact_ids_to_prune: fact_ids_to_prune,
932
+ boundary_keys: boundary_keys,
933
+ blockers: []
934
+ }
935
+ end
936
+
937
+ # 5 — Idempotency: existing purge receipt for this plan_hash
938
+ existing_purge = @store.history(store: :ledger_physical_purge_receipts, key: plan_hash)
939
+ if existing_purge.any?
940
+ return {
941
+ status: :purged,
942
+ deduplicated: true,
943
+ plan_hash: plan_hash,
944
+ receipt_id: existing_purge.last.id
945
+ }
946
+ end
947
+
948
+ # 6 — Execute physical prune
949
+ prune_result = @store.prune_fact_ids(
950
+ fact_ids: fact_ids_to_prune,
951
+ reason: :boundary_physical_purge,
952
+ metadata: {
953
+ source: "availability_boundary_ledger",
954
+ plan_hash: plan_hash,
955
+ boundary_keys: boundary_keys
956
+ }
957
+ )
958
+
959
+ if prune_result[:status] == :unsupported
960
+ return { status: :blocked, reason: :store_prune_unsupported,
961
+ detail: prune_result }
962
+ end
963
+
964
+ # 7 — Write physical purge receipt
965
+ purge_fact = @store.write(
966
+ store: :ledger_physical_purge_receipts,
967
+ key: plan_hash,
968
+ value: {
969
+ "status" => "purged",
970
+ "plan_hash" => plan_hash,
971
+ "boundary_keys" => boundary_keys,
972
+ "fact_ids_pruned" => fact_ids_to_prune,
973
+ "pruned_count" => prune_result[:pruned_count],
974
+ "missing_count" => prune_result[:missing_count],
975
+ "prune_receipt_id" => prune_result[:receipt_id],
976
+ "purged_at" => Time.now.utc.iso8601(3)
977
+ },
978
+ producer: PRODUCER
979
+ )
980
+
981
+ {
982
+ status: :purged,
983
+ deduplicated: false,
984
+ plan_hash: plan_hash,
985
+ receipt_id: purge_fact.id,
986
+ pruned_count: prune_result[:pruned_count]
987
+ }
988
+ end
989
+
990
+ # Normalized compaction activity for this ledger.
991
+ # Delegates to the underlying store for retention compaction, exact prune,
992
+ # and segment purge entries, then appends boundary physical purge receipts
993
+ # from :ledger_physical_purge_receipts.
994
+ def compaction_activity
995
+ entries = @store.compaction_activity
996
+
997
+ @store.history(store: :ledger_physical_purge_receipts).each do |f|
998
+ v = f.value
999
+ entries << {
1000
+ kind: :boundary_physical_purge,
1001
+ executor: :boundary_ledger,
1002
+ store: nil,
1003
+ status: (v["status"] || v[:status])&.to_sym || :purged,
1004
+ reason: :boundary_physical_purge,
1005
+ fact_count: (v["pruned_count"] || v[:pruned_count]).to_i,
1006
+ receipt_id: f.id,
1007
+ occurred_at: f.transaction_time
1008
+ }
1009
+ end
1010
+
1011
+ entries.sort_by { |e| e[:occurred_at] }
1012
+ end
1013
+
1014
+ private
1015
+
1016
+ # Returns the latest relation edge value for each blocking edge (raw or
1017
+ # unresolved) that targets one of the boundary's source facts.
1018
+ # Uses :ledger_relation_edge_targets index instead of scanning all history.
1019
+ def raw_external_edges_for(boundary)
1020
+ source_ids = boundary.source_fact_ids.map(&:to_s)
1021
+ return [] if source_ids.empty?
1022
+
1023
+ source_ids.flat_map do |fact_id|
1024
+ @store.history(store: :ledger_relation_edge_targets, key: fact_id)
1025
+ .group_by { |f| (f.value[:edge_id] || f.value["edge_id"]).to_s }
1026
+ .filter_map do |_eid, facts|
1027
+ latest = facts.max_by(&:transaction_time).value
1028
+ next unless %w[raw unresolved].include?(latest[:ref_status].to_s)
1029
+ latest
1030
+ end
1031
+ end
1032
+ end
1033
+
1034
+ # Writes a single entry to :ledger_relation_edge_targets.
1035
+ # Called on edge creation and on redirect; accumulates in history per to_fact_id.
1036
+ def write_relation_edge_target(edge_id:, to_fact_id:, from_store:, from_fact_id:,
1037
+ to_store:, to_boundary_key:, ref_status:, relation:, evidence:)
1038
+ @store.write(
1039
+ store: :ledger_relation_edge_targets,
1040
+ key: to_fact_id.to_s,
1041
+ value: {
1042
+ "to_fact_id" => to_fact_id.to_s,
1043
+ "edge_id" => edge_id.to_s,
1044
+ "from_store" => from_store.to_s,
1045
+ "from_fact_id" => from_fact_id.to_s,
1046
+ "to_store" => to_store&.to_s,
1047
+ "to_boundary_key" => to_boundary_key,
1048
+ "ref_status" => ref_status.to_s,
1049
+ "relation" => relation.to_s,
1050
+ "evidence" => evidence || {}
1051
+ },
1052
+ producer: PRODUCER
1053
+ )
1054
+ end
1055
+
1056
+ # Deterministic hash of cleanup plan identity, excluding volatile fields.
1057
+ # Used as the idempotency key for :ledger_cleanup_execution_receipts.
1058
+ def stable_plan_hash(plan)
1059
+ parts = [
1060
+ plan[:store].to_s,
1061
+ plan[:before].to_s,
1062
+ plan.fetch(:fidelity, :boundary).to_s,
1063
+ plan.fetch(:require_reference_redirects, false).to_s,
1064
+ Array(plan[:receipts_to_keep]).sort.join(",")
1065
+ ]
1066
+ Digest::SHA256.hexdigest(parts.join("|"))
1067
+ end
1068
+
1069
+ def find_or_open_boundary(company_id:, technician_id:, date:)
1070
+ key = LedgerBoundary.key_for(
1071
+ company_id: company_id.to_s,
1072
+ technician_id: technician_id.to_s,
1073
+ date: date.to_s
1074
+ )
1075
+ @boundaries[key] || open_boundary(company_id: company_id, technician_id: technician_id, date: date)
1076
+ end
1077
+
1078
+ def build_subject(company_id, technician_id, date)
1079
+ { company_id: company_id.to_s, technician_id: technician_id.to_s, date: date.to_s }
1080
+ end
1081
+
1082
+ def coerce_date(date)
1083
+ date.is_a?(Date) ? date : Date.parse(date.to_s)
1084
+ end
1085
+
1086
+ def boundary_date_before?(boundary, before)
1087
+ d = Date.parse(boundary.subject[:date].to_s)
1088
+ Time.utc(d.year, d.month, d.day) < before
1089
+ rescue ArgumentError
1090
+ false
1091
+ end
1092
+
1093
+ # Returns the value of a snapshot fact by id using the store's fact-id index.
1094
+ # Rejects the result if the indexed fact is not from :availability_snapshots.
1095
+ def find_snapshot_value(fact_id)
1096
+ return nil unless fact_id
1097
+ fact = @store.fact_by_id(fact_id)
1098
+ return nil unless fact && fact.store == :availability_snapshots
1099
+ fact.value
1100
+ end
1101
+
1102
+ def safe_parse_time(val)
1103
+ val ? Time.parse(val.to_s) : nil
1104
+ rescue ArgumentError, TypeError
1105
+ nil
1106
+ end
1107
+
1108
+ def latest_redirect(fact_id)
1109
+ facts = @store.history(store: :ledger_fact_redirects, key: fact_id)
1110
+ facts.empty? ? nil : facts.max_by(&:transaction_time).value
1111
+ end
1112
+
1113
+ # Returns the raw Fact for fact_id using the store's fact-id index.
1114
+ #
1115
+ # When store_hint is a known store name (from redirect provenance):
1116
+ # return nil if the indexed fact is in a different store (mismatch rejection).
1117
+ # When store_hint is nil or "unknown":
1118
+ # return the indexed fact regardless of store.
1119
+ def find_raw_fact(fact_id, store_hint: nil)
1120
+ return nil unless fact_id
1121
+ fact = @store.fact_by_id(fact_id)
1122
+ return nil unless fact
1123
+ if store_hint && store_hint != "unknown"
1124
+ return nil unless fact.store.to_s == store_hint
1125
+ end
1126
+ fact
1127
+ end
1128
+
1129
+ def redirect_evidence(redirect)
1130
+ {
1131
+ boundary_output_fact_id: redirect[:boundary_output_fact_id],
1132
+ boundary_receipt_id: redirect[:boundary_receipt_id],
1133
+ settlement_receipt_id: redirect[:settlement_receipt_id],
1134
+ compaction_receipt_id: redirect[:compaction_receipt_id]
1135
+ }
1136
+ end
1137
+
1138
+ def raw_detail_unavailable(fact_id, redirect)
1139
+ {
1140
+ status: :detail_unavailable,
1141
+ original_fact_id: fact_id,
1142
+ boundary_key: redirect[:boundary_key],
1143
+ required_fidelity: :raw,
1144
+ available_fidelity: :boundary,
1145
+ evidence: redirect_evidence(redirect)
1146
+ }
1147
+ end
1148
+
1149
+ def boundary_redirect_response(fact_id, redirect)
1150
+ {
1151
+ status: :redirected,
1152
+ kind: :boundary_ref,
1153
+ original_fact_id: fact_id,
1154
+ boundary_key: redirect[:boundary_key],
1155
+ detail_status: :purged,
1156
+ evidence: redirect_evidence(redirect)
1157
+ }
1158
+ end
1159
+
1160
+ def summary_redirect_response(fact_id, redirect)
1161
+ {
1162
+ status: :redirected,
1163
+ kind: :summary_ref,
1164
+ original_fact_id: fact_id,
1165
+ boundary_key: redirect[:boundary_key],
1166
+ detail_status: :purged,
1167
+ evidence: redirect_evidence(redirect)
1168
+ }
1169
+ end
1170
+
1171
+ def boundary_record_value(boundary)
1172
+ {
1173
+ "boundary_key" => boundary.boundary_key,
1174
+ "policy_name" => LedgerBoundary::POLICY_NAME,
1175
+ "subject" => boundary.subject.transform_keys(&:to_s),
1176
+ "status" => boundary.status.to_s,
1177
+ "output_fact_id" => boundary.output_fact_id,
1178
+ "receipt_fact_id" => boundary.receipt_fact_id,
1179
+ "result_hash" => boundary.result_hash,
1180
+ "source_fact_ids" => boundary.source_fact_ids,
1181
+ "source_fact_refs" => boundary.source_fact_refs,
1182
+ "detail_status" => boundary.detail_status.to_s,
1183
+ "closed_at" => boundary.closed_at&.iso8601(3),
1184
+ "rule_version" => LedgerBoundary::RULE_VERSION
1185
+ }
1186
+ end
1187
+ end
1188
+ end
1189
+ end
1190
+ end