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.
- checksums.yaml +7 -0
- data/README.md +481 -0
- data/examples/intelligent_ledger/availability_boundary_ledger.rb +1190 -0
- data/examples/intelligent_ledger/availability_deriver.rb +150 -0
- data/examples/intelligent_ledger/availability_ledger.rb +197 -0
- data/examples/intelligent_ledger/ledger_boundary.rb +180 -0
- data/examples/store_poc.rb +45 -0
- data/exe/igniter-ledger-server +111 -0
- data/exe/igniter-store-server +6 -0
- data/ext/igniter_store_native/Cargo.toml +28 -0
- data/ext/igniter_store_native/extconf.rb +6 -0
- data/ext/igniter_store_native/src/fact.rs +303 -0
- data/ext/igniter_store_native/src/fact_log.rs +180 -0
- data/ext/igniter_store_native/src/file_backend.rs +91 -0
- data/ext/igniter_store_native/src/lib.rs +55 -0
- data/lib/igniter/ledger.rb +7 -0
- data/lib/igniter/store/access_path.rb +84 -0
- data/lib/igniter/store/change_event.rb +65 -0
- data/lib/igniter/store/changefeed_buffer.rb +585 -0
- data/lib/igniter/store/codecs.rb +253 -0
- data/lib/igniter/store/contractable_receipt_sink.rb +172 -0
- data/lib/igniter/store/fact.rb +121 -0
- data/lib/igniter/store/fact_log.rb +103 -0
- data/lib/igniter/store/file_backend.rb +269 -0
- data/lib/igniter/store/http_adapter.rb +413 -0
- data/lib/igniter/store/igniter_store.rb +838 -0
- data/lib/igniter/store/mcp_adapter.rb +403 -0
- data/lib/igniter/store/native.rb +80 -0
- data/lib/igniter/store/network_backend.rb +159 -0
- data/lib/igniter/store/protocol/handlers/access_path_handler.rb +38 -0
- data/lib/igniter/store/protocol/handlers/command_handler.rb +59 -0
- data/lib/igniter/store/protocol/handlers/derivation_handler.rb +27 -0
- data/lib/igniter/store/protocol/handlers/effect_handler.rb +65 -0
- data/lib/igniter/store/protocol/handlers/history_handler.rb +24 -0
- data/lib/igniter/store/protocol/handlers/projection_handler.rb +41 -0
- data/lib/igniter/store/protocol/handlers/relation_handler.rb +43 -0
- data/lib/igniter/store/protocol/handlers/store_handler.rb +24 -0
- data/lib/igniter/store/protocol/handlers/subscription_handler.rb +24 -0
- data/lib/igniter/store/protocol/interpreter.rb +447 -0
- data/lib/igniter/store/protocol/receipt.rb +96 -0
- data/lib/igniter/store/protocol/sync_profile.rb +53 -0
- data/lib/igniter/store/protocol/wire_envelope.rb +214 -0
- data/lib/igniter/store/protocol.rb +27 -0
- data/lib/igniter/store/read_cache.rb +163 -0
- data/lib/igniter/store/schema_graph.rb +248 -0
- data/lib/igniter/store/segmented_file_backend.rb +699 -0
- data/lib/igniter/store/server_config.rb +55 -0
- data/lib/igniter/store/server_logger.rb +64 -0
- data/lib/igniter/store/server_metrics.rb +222 -0
- data/lib/igniter/store/store_server.rb +597 -0
- data/lib/igniter/store/subscription_registry.rb +73 -0
- data/lib/igniter/store/tbackend_adapter_descriptor.rb +307 -0
- data/lib/igniter/store/tcp_adapter.rb +127 -0
- data/lib/igniter/store/wire_protocol.rb +42 -0
- data/lib/igniter/store.rb +64 -0
- data/lib/igniter-ledger.rb +4 -0
- data/lib/igniter-store.rb +5 -0
- 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
|