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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 15baa7aa1060cade9f02f3265551529d8b35bc36de43587d454ab0f2eceaee93
4
+ data.tar.gz: cdd1f1b480166e014417473d28aba24ad019d33217cf047761eae6bfde52faa1
5
+ SHA512:
6
+ metadata.gz: 5f8d61e119598a18e148a4d876965bee268e86d1627c6d178e0a8200234a8adfcab13cf7c6a32f836e7898f71be907a5a9d5bfa1b62d0a00ca13c0fc5cc3f3cb
7
+ data.tar.gz: 4d9fdaad822758f15f3367574ba316d472c53410e9dc8c1ec56ae6c063236eb4c736b9a7e04461dbdd0ea194ab5f3d8335f09177f76c0a381efe3efe03b32733
data/README.md ADDED
@@ -0,0 +1,481 @@
1
+ # igniter-ledger
2
+
3
+ Pre-v1 Ledger substrate for Igniter facts, histories, receipts, replay, and
4
+ protocol-facing storage surfaces.
5
+
6
+ Status: active platform lane, still POC/pre-v1. APIs, storage formats, and
7
+ transport contracts may change before v1.
8
+
9
+ ## Compatibility Note
10
+
11
+ New code should use `igniter-ledger`, `require "igniter-ledger"`,
12
+ `Igniter::Ledger::LedgerStore`, and `igniter-ledger-server`.
13
+
14
+ This package was previously exposed as `igniter-store`. During the pre-v1 rename
15
+ window, compatibility shims remain for `require "igniter-store"`,
16
+ `igniter-store-server`, and the `Igniter::Store` constants. The internal Ruby
17
+ namespace and file path still use `Igniter::Store` / `lib/igniter/store/**`;
18
+ treat that as implementation structure until a later deep-rename track.
19
+
20
+ ## Purpose
21
+
22
+ `igniter-ledger` is broader than persistence. It is the hot fact engine behind
23
+ Ledger-backed companion systems:
24
+
25
+ ```text
26
+ write/append fact
27
+ -> immutable fact log
28
+ -> current and time-travel reads
29
+ -> indexes, access paths, relations, projections
30
+ -> changefeed / replay / receipts
31
+ -> compaction activity and LedgerBoundary proofs
32
+ -> Ledger Open Protocol / LedgerServer / MCP / SSE reads
33
+ ```
34
+
35
+ The package intentionally sits below the application-facing `Record` /
36
+ `History` facade in `igniter-durable-model`. App code should usually begin
37
+ there; this package owns the fact substrate, protocol, and operational storage
38
+ model.
39
+
40
+ ## Current Surface
41
+
42
+ - immutable content-addressed facts with stable `id` and value hash
43
+ - record-like `Store[T]` and append-only `History[T]` semantics
44
+ - fact-id causation chains for unambiguous temporal history
45
+ - transaction time, valid time, producer, and derivation metadata
46
+ - current reads, time-travel reads, and replay windows
47
+ - scope access paths, relation rules, projection descriptors, derivation rules,
48
+ scatter rules, and metadata snapshots
49
+ - CRC32-framed WAL, snapshot checkpoint/replay, segmented storage hardening, and
50
+ durability policy work
51
+ - retention, compaction lifecycle, prune/purge executors, compaction activity,
52
+ and LedgerBoundary cleanup/provenance/redirect proofs
53
+ - bounded changefeed with replay cursors, SSE `/v1/events`, async fan-out,
54
+ delivery policy, diagnostics, and server config
55
+ - Ledger Open Protocol interpreter, wire envelope, LedgerServer, HTTP status,
56
+ MCP adapter surface, and sync/replay profiles
57
+
58
+ ## Does Not Own
59
+
60
+ - public contract persistence DSL (`persist`, `history`) as a stable user API
61
+ - `Record` / `History` application ergonomics; that belongs to
62
+ `igniter-durable-model`
63
+ - SQL schema generation, ORM semantics, or migration execution
64
+ - arbitrary application workflows or side effects inside storage
65
+ - cluster consensus or deployment guarantees
66
+ - AI/agent authority decisions
67
+
68
+ ## Docs
69
+
70
+ Start with:
71
+
72
+ - [docs/README.md](docs/README.md) — package documentation index
73
+ - [docs/progress.md](docs/progress.md) — compact current status
74
+ - [docs/pre-v1-core-model-proposal.md](docs/pre-v1-core-model-proposal.md) —
75
+ core fact model proposal before v1
76
+ - [docs/open-protocol.md](docs/open-protocol.md) — Ledger Open Protocol
77
+ - [docs/server-api-proposal.md](docs/server-api-proposal.md) — server/API layer
78
+ above the protocol
79
+ - [docs/intelligent-ledger/README.md](docs/intelligent-ledger/README.md) —
80
+ inference, derivation, routes, and boundary research horizon
81
+ - [docs/tracks/](docs/tracks/) — completed and active implementation slices
82
+ - [docs/research/](docs/research/) — older compressed iteration history
83
+
84
+ ## Strategic Position
85
+
86
+ `igniter-ledger` began as `igniter-store`, a persistence proof. The model has
87
+ grown toward Ledger semantics: append-only facts, causation, receipts, replay,
88
+ boundaries, compaction, explainability, and protocol reads.
89
+
90
+ The likely product-language migration is:
91
+
92
+ ```text
93
+ Store package name in older docs
94
+ -> igniter-ledger package
95
+ -> Store[T] / History[T] typed capability semantics
96
+ -> Durable Model Record/History app facade
97
+ ```
98
+
99
+ Do not collapse these layers into one object model. `persist` and `history` in
100
+ future contract DSL should remain sugar lowerable to Store/History capability
101
+ manifests.
102
+
103
+ ## Example
104
+
105
+ ```ruby
106
+ require "igniter-ledger"
107
+
108
+ store = Igniter::Ledger::LedgerStore.new
109
+
110
+ store.write(
111
+ store: :reminders,
112
+ key: "r1",
113
+ value: { title: "Buy milk", status: :open }
114
+ )
115
+
116
+ store.read(store: :reminders, key: "r1")
117
+ ```
118
+
119
+ ## Contractable Receipt Sink
120
+
121
+ `ContractableReceiptSink` is a durable store adapter for Embed contractable
122
+ observation/event receipts. Wire it as the `store:` option on any contractable:
123
+
124
+ ```ruby
125
+ require "igniter-ledger"
126
+
127
+ sink = Igniter::Ledger::ContractableReceiptSink.new(
128
+ store: Igniter::Ledger::LedgerStore.new
129
+ )
130
+
131
+ # Pass as store adapter to any igniter-embed contractable:
132
+ runner = Igniter::Embed.contractable(:marketing_executor) do |config|
133
+ config.primary LegacyExecutor
134
+ config.candidate ContractExecutor
135
+ config.async false
136
+ config.store sink
137
+ config.normalize_primary ExecutorNormalizer
138
+ config.normalize_candidate ExecutorNormalizer
139
+ config.redact_inputs ->(**inputs) { inputs.slice(:request_id) }
140
+ end
141
+
142
+ runner.call(request_id: "r1", provider_token: "secret")
143
+
144
+ # Query:
145
+ sink.observation("obs_abc123") # current state by id
146
+ sink.events_for("obs_abc123") # all events in commit order
147
+ sink.observations(status: :diverged, limit: 20) # recent diverged observations
148
+ sink.error_events(limit: 10) # recent error-severity events
149
+ ```
150
+
151
+ Registers `contractable_observations` (store) and `contractable_events`
152
+ (history) protocol descriptors on construction. Custom store names:
153
+
154
+ ```ruby
155
+ sink = Igniter::Ledger::ContractableReceiptSink.new(
156
+ store: Igniter::Ledger::LedgerStore.new,
157
+ observations_store: :spark_observations,
158
+ events_store: :spark_events,
159
+ producer: { type: :embed, name: :spark_sink }
160
+ )
161
+ ```
162
+
163
+ The sink can also use the protocol boundary through `igniter-ledger-client`
164
+ instead of depending on the embedded store API:
165
+
166
+ ```ruby
167
+ require "igniter-ledger"
168
+ require "igniter-ledger-client"
169
+
170
+ ledger = Igniter::Ledger::LedgerStore.new
171
+ client = Igniter::LedgerClient.wrap(ledger.protocol)
172
+
173
+ sink = Igniter::Ledger::ContractableReceiptSink.new(client: client)
174
+ sink.record_observation(receipt)
175
+ sink.events_for("obs_abc123")
176
+ ```
177
+
178
+ This is the preferred direction for packages that should talk to Ledger through
179
+ a stable client/protocol boundary.
180
+
181
+ Run the POC smoke:
182
+
183
+ ```bash
184
+ ruby -I packages/igniter-ledger/lib packages/igniter-ledger/examples/store_poc.rb
185
+ ```
186
+
187
+ Run package specs:
188
+
189
+ ```bash
190
+ bundle exec rspec packages/igniter-ledger/spec
191
+ ```
192
+
193
+ ## Model Decisions & Pressure Log
194
+
195
+ ### [2026-04-30] Causation: fact.id, not fact.value_hash
196
+
197
+ **Change**: `IgniterStore#write` now sets `causation: previous&.id` (UUID) instead
198
+ of `causation: previous&.value_hash`.
199
+
200
+ **Why**: `value_hash` is a *content address* — it identifies what a fact *contains*.
201
+ `causation` is a *temporal pointer* — it identifies which fact *came before*. Using
202
+ `value_hash` for causation creates an ambiguous chain: if the same value is written
203
+ twice, `f2.causation == f2.value_hash` (self-referential), and following the chain
204
+ by hash lookup returns multiple candidates. `fact.id` (UUID) is an unambiguous
205
+ pointer to one specific event.
206
+
207
+ **Impact on consumers**: `causation_chain` entries now include `id:` and show the
208
+ full UUID causation instead of a truncated hash prefix. The Durable Model package
209
+ passes `causation_chain(...).length` — count is unaffected.
210
+
211
+ **Candidate pressure on `igniter-durable-model`**: the `WriteReceipt` currently
212
+ forwards `fact.causation` to app receipts. Now causation is a UUID; if the app
213
+ ever exposes it, document as a temporal pointer to a fact identity, not a
214
+ content address.
215
+
216
+ ---
217
+
218
+ ### [2026-04-30] WAL format v2: length-prefix + CRC32 framing
219
+
220
+ **Change**: `FileBackend` replaced JSON-Lines (`puts + readlines`) with a binary
221
+ framed format:
222
+
223
+ ```
224
+ [4-byte BE uint32: body_len][body_len bytes: JSON][4-byte BE uint32: CRC32(body)]
225
+ ```
226
+
227
+ **Why**: JSON-Lines is silently lossy on truncation. A process killed mid-`puts`
228
+ leaves a partial line that is indistinguishable from a valid-but-empty line, and
229
+ was previously dropped with `rescue JSON::ParserError` — the write appeared
230
+ committed but the fact was lost on replay.
231
+
232
+ The framed format makes truncation *detectable*: a partial frame has a wrong or
233
+ missing CRC. Replay stops at the first integrity failure and returns all facts
234
+ from complete frames. The last incomplete frame is treated as an uncommitted write.
235
+
236
+ **Breaking change**: existing v1 JSONL WAL files are not readable by the v2 reader.
237
+ This is acceptable at POC stage. A migration path (detect v1 by absence of valid
238
+ frame header, warn and skip) can be added under app pressure.
239
+
240
+ **Candidate pressure on Rust FileBackend** (from plan): the planned Rust FileBackend
241
+ uses MessagePack + CRC32 — same framing principle, binary body instead of JSON.
242
+ The v2 Ruby format is a stepping stone to that target; the framing structure is
243
+ intentionally compatible.
244
+
245
+ ---
246
+
247
+ ### [2026-04-30] Materialized scope index + scope-aware invalidation
248
+
249
+ **Change**: `IgniterStore` now maintains a per-scope materialized index in
250
+ `@scope_index: { [store, scope] => Set<key> }`, initialized lazily on the
251
+ first `query` call for each scope and maintained on every subsequent `write`.
252
+
253
+ **Before**: `query_scope` scanned O(all keys in store) on every call. Any write
254
+ to a store invalidated ALL scope caches and notified ALL scope consumers —
255
+ a thundering herd even when the write touched an unrelated scope.
256
+
257
+ **After**:
258
+ - `query` (non–time-travel): O(matched keys) — reads the Set, fetches latest fact
259
+ per key. Full scan only on the very first call.
260
+ - `write` evaluates scope predicates for the written key only, updating the Set
261
+ in O(registered scopes) per write.
262
+ - `ReadCache.invalidate` now accepts `scope_changes: { scope => :changed | :unchanged | :unknown }`.
263
+ Consumers are skipped for `:unchanged` scopes — their membership did not change.
264
+ `:unknown` (index not yet warm) fires conservatively; `:changed` fires normally.
265
+
266
+ **Time-travel** (`as_of:` non-nil) bypasses the scope index and still does a full
267
+ log scan — the index reflects current state only.
268
+
269
+ **Evidence**: 8 new specs covering index accuracy, lazy init, scope entry/exit,
270
+ and suppressed false-positive notifications.
271
+
272
+ ---
273
+
274
+ ### [2026-04-30] History partition index
275
+
276
+ **Change**: `IgniterStore` now maintains a per-(store, partition_key) materialized index
277
+ `@partition_index: { [store, partition_key] => { partition_value => [fact, ...] } }`.
278
+ A new `#history_partition` method provides O(partition slice) reads instead of O(total events).
279
+ `#append` accepts an optional `partition_key:` parameter; when provided and the index is warm,
280
+ the new fact is appended to the correct partition bucket in O(1).
281
+
282
+ **Before**: `DurableModel::Store#replay(partition:)` called `@inner.history(...)` (full scan of
283
+ all events in the store), then filtered in Ruby. For a store with N total events split across P
284
+ partitions, each `replay` was O(N) regardless of partition size.
285
+
286
+ **After**:
287
+ - First `history_partition` call for a (store, partition_key) pair: O(N) full scan that builds
288
+ the index — one-time cost identical to the old path.
289
+ - Subsequent `history_partition` calls: O(partition slice) — read the pre-grouped bucket directly.
290
+ - New `append` calls: O(1) bucket append when the index is already warm.
291
+ - `since:` / `as_of:` time filters applied at read time over the cached slice; they do NOT
292
+ prevent the index from being used.
293
+
294
+ **Durable Model impact**: `DurableModel::Store#append` now passes `partition_key: history_class._partition_key`
295
+ to `@inner.append`; `#replay(partition:)` delegates to `@inner.history_partition` when a
296
+ partition key is declared. The public API of Durable Model is unchanged.
297
+
298
+ **Index correctness edge**: appends without `partition_key:` (or where the event does not
299
+ contain the partition field) do NOT update the index. The caller is responsible for passing
300
+ `partition_key:` consistently — Durable Model always does so via `_partition_key`.
301
+
302
+ ---
303
+
304
+ ### [2026-04-30] Read cache LRU cap for time-travel entries
305
+
306
+ **Change**: `ReadCache` now accepts `lru_cap:` (default: 1 000). All time-travel
307
+ cache entries — point reads and scope reads with `as_of: non-nil` — are tracked
308
+ in an ordered `@lru_order` hash and evicted LRU when the count exceeds the cap.
309
+
310
+ **Before**: every unique `as_of` timestamp produced a permanent cache entry.
311
+ A workload running time-travel queries across N timestamps (e.g. animation,
312
+ audit replay) would accumulate O(N) entries that were never freed, growing
313
+ unboundedly until the process restarted.
314
+
315
+ **After**:
316
+ - Time-travel entries are evicted LRU when `@lru_order.size > lru_cap`.
317
+ - Accessed entries are promoted to MRU (delete + reinsert in the ordered hash)
318
+ so frequently re-read checkpoints are not the first to be evicted.
319
+ - Current-state entries (`as_of: nil`) are **not** counted against the LRU cap
320
+ and are never evicted by this mechanism — they live until `invalidate` is
321
+ called by a normal write, which is the correct existing behaviour.
322
+ - `invalidate` removes evicted keys from `@lru_order` so the tracker stays
323
+ consistent when writes race with time-travel reads.
324
+
325
+ **Tuning**: pass `lru_cap:` to `IgniterStore.new` or `IgniterStore.open` to
326
+ override the default. Example: `IgniterStore.new(lru_cap: 5_000)`.
327
+
328
+ **Candidate pressure on igniter-durable-model**: `DurableModel::Store.new` could
329
+ expose `lru_cap:` as a top-level option and forward it to the inner store.
330
+ Not done here — defer under app pressure.
331
+
332
+ ---
333
+
334
+ ### [2026-04-30] Schema version coercion hook
335
+
336
+ **Change**: `IgniterStore#register_coercion(store_name) { |value, schema_version| ... }` registers
337
+ a read-path migration block. On every read — `read`, `time_travel`, `query`, `history`,
338
+ `history_partition` — the block is called with the raw stored value and its `schema_version`.
339
+ The return value replaces the value seen by the caller. Raw facts are never mutated.
340
+
341
+ When a coercion changes the value, the fact is wrapped in a `CoercedFact` struct that
342
+ delegates all identity fields (`id`, `key`, `timestamp`, `causation`, `value_hash`,
343
+ `schema_version`) to the underlying fact. If the coercion returns the same object
344
+ (`equal?`), the original fact is returned unchanged (zero allocation on no-op coercions).
345
+
346
+ **Why on the read path, not write path**: the schema_version field was written at insert time
347
+ and is correct for that version. Mutating facts on write would require migrating all existing
348
+ WAL entries and invalidating causation chains. A read-path transform is zero-cost for
349
+ unchanged facts and allows progressive migration.
350
+
351
+ **Pattern — field rename across schema versions**:
352
+ ```ruby
353
+ store.register_coercion(:tasks) do |value, schema_version|
354
+ next value if schema_version >= 2
355
+ # v1 stored :title; v2 renamed to :name
356
+ value.merge(name: value.delete(:title))
357
+ end
358
+ ```
359
+
360
+ **Candidate pressure on igniter-durable-model**: `DurableModel::Store` should expose
361
+ `register_coercion` as a passthrough to the inner store, possibly mapped from
362
+ schema class migration declarations. Not done here — defer under app pressure.
363
+
364
+ ---
365
+
366
+ ### [2026-04-30] Snapshot checkpoint
367
+
368
+ **Change**: `IgniterStore#checkpoint` writes all current facts from `FactLog#all_facts`
369
+ to a snapshot file (`<wal_path>.snap`) via `FileBackend#write_snapshot`. On subsequent
370
+ `IgniterStore.open`, `FileBackend#replay` loads the snapshot first, deduplicates WAL
371
+ facts by ID against the snapshot set, and returns `snapshot_facts + delta_wal_facts`
372
+ sorted by timestamp. Startup cost is O(snapshot_size + delta) instead of O(total_facts).
373
+
374
+ **Snapshot file format** (`<wal_path>.snap`):
375
+ ```
376
+ [header frame: JSON { type: "snapshot_header", fact_count: N, written_at: T }]
377
+ [fact frame 1] ... [fact frame N]
378
+ ```
379
+ Same CRC32-framed format as the WAL — a corrupt snapshot is detected by a bad CRC on
380
+ the header frame and the backend falls back to full WAL replay automatically.
381
+
382
+ **Atomicity**: `write_snapshot` writes to a `.tmp` file and renames atomically, so a
383
+ process kill mid-checkpoint never corrupts an existing snapshot.
384
+
385
+ **Deduplication**: WAL facts whose `id` is already in the snapshot set are skipped,
386
+ not by byte offset or fact count. This tolerates WAL facts that were written
387
+ concurrently with snapshot creation.
388
+
389
+ **Scope / partition indices**: not included in the snapshot — they are rebuilt lazily on
390
+ first query after reopen, which is already the lazy-init behaviour.
391
+
392
+ **Availability**:
393
+ - Ruby fallback (`NATIVE = false`): fully implemented and tested (6 specs).
394
+ - NATIVE (`NATIVE = true`): Rust `FactLog` does not yet expose `all_facts` — `checkpoint`
395
+ is a no-op. `FileBackend#write_snapshot` is also not implemented in the Rust backend.
396
+ Both are candidate pressures for the Rust tier.
397
+
398
+ **Candidate pressure on Rust backend**:
399
+ - `RubyFactLog`: add `all_facts()` → `Vec<RubyFact>` method exposed to Ruby
400
+ - `RubyFileBackend`: add `write_snapshot(facts)` using the same frame format (body = MessagePack)
401
+ - Match the snapshot header record structure so Ruby-written snapshots are readable by Rust and vice versa
402
+
403
+ ---
404
+
405
+ ### [2026-04-30] NetworkBackend + LedgerServer (Phase 1 transport abstraction)
406
+
407
+ **Change**: Three new pure-Ruby classes implement the first step of the client-server
408
+ projection model:
409
+
410
+ - `WireProtocol` — shared CRC32-framed encoding module (included by `FileBackend`,
411
+ `NetworkBackend`, and `LedgerServer`).
412
+ - `NetworkBackend` — client-side backend implementing the same `write_fact` / `replay` /
413
+ `write_snapshot` interface as `FileBackend`, but transmitting calls over a TCP or
414
+ Unix socket connection.
415
+ - `LedgerServer` — minimal TCP/Unix server wrapping durable storage (`:memory` or `:file`).
416
+ Each incoming connection is handled in a separate thread; writes are serialised by
417
+ `@write_mutex`; reads snapshot `@in_memory_facts` under the same lock.
418
+
419
+ **Usage** — swap the backend without changing application code:
420
+
421
+ ```ruby
422
+ # Server process (or background thread)
423
+ server = Igniter::Ledger::LedgerServer.new(
424
+ address: "127.0.0.1:7400", backend: :file, path: "/var/lib/igniter/store.wal"
425
+ )
426
+ server.start_async
427
+
428
+ # Application process — identical API to :memory and :file
429
+ store = Igniter::DurableModel::Store.new(
430
+ backend: :network, address: "127.0.0.1:7400"
431
+ )
432
+ store.register(Task)
433
+ store.write(Task, key: "t1", title: "Hello", status: :open)
434
+ ```
435
+
436
+ **Wire protocol**: CRC32-framed JSON, one request frame + one response frame per RPC.
437
+ Reuses the same framing as the WAL file format — the same `WireProtocol` module is
438
+ shared across both, ensuring consistency.
439
+
440
+ **Replay on reconnect**: a new `NetworkBackend` client sends a `replay` request on
441
+ first use (explicitly called by `DurableModel::Store.new(backend: :network)`). The
442
+ `IgniterStore` on the client side rebuilds all in-memory indices (scope index,
443
+ partition index, cache) from the replayed facts — identical to the `:file` path.
444
+
445
+ **Availability**:
446
+ - Ruby fallback (`NATIVE = false`): fully implemented. 8 specs (all skipped when NATIVE).
447
+ - NATIVE (`NATIVE = true`): `NetworkBackend` and `LedgerServer` both have NATIVE guards —
448
+ they are skipped because `Fact.new(**h)` is not available with the Rust extension.
449
+ Phase 2 will add Rust-native fact deserialisation.
450
+
451
+ **Playground**: demo 07 (`07_network.rb`) exercises the full two-client round-trip
452
+ (write via client 1, reconnect as client 2, verify fact visibility and scope queries).
453
+
454
+ **Candidate pressure on Rust backend**:
455
+ - `RubyFact`: expose a class-level `deserialize(hash)` method that constructs a Fact
456
+ from existing id/timestamp/value_hash fields (without re-generating them via `build`).
457
+ This is the only blocker for NATIVE NetworkBackend support.
458
+
459
+ ---
460
+
461
+ ## Historical Pressure Log Tail
462
+
463
+ The entries above preserve useful early implementation pressure from the Store
464
+ POC. Several items that were once listed as "open" have since moved into
465
+ completed LedgerServer, changefeed, protocol, and compaction tracks.
466
+
467
+ For current status, use:
468
+
469
+ - [docs/progress.md](docs/progress.md)
470
+ - [docs/README.md](docs/README.md)
471
+ - [docs/tracks/](docs/tracks/)
472
+
473
+ ---
474
+
475
+ ## Research Track
476
+
477
+ - [Contract-Native Store Research](docs/research/store-iterations.md)
478
+ - [Contract-Native Store POC](docs/poc-specification.md)
479
+ - [Contract-Native Store Sync Hub](docs/research/sync-hub-iterations.md)
480
+ - [Contract Persistence Development Track](../../playgrounds/docs/research-horizon/contract-persistence-development-track.md)
481
+ - [Contract-Native Store: Server Model](docs/server-model.md)