@feltdb/core 0.8.5 → 0.8.6

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 (25) hide show
  1. package/dist/create/package-versions.js +1 -1
  2. package/dist/create/server-source/crates/feltdb/src/lib.rs +1173 -72
  3. package/dist/create/server-source/crates/feltdb/src/operation.rs +39 -0
  4. package/dist/create/server-source/crates/feltdb/src/state_model.rs +52 -0
  5. package/dist/create/server-source/crates/feltdb/src/storage.rs +9 -3
  6. package/dist/create/server-source/crates/feltdb/tests/bounded_read_contract.rs +132 -0
  7. package/dist/create/server-source/crates/feltdb/tests/compaction_stall_contract.rs +272 -0
  8. package/dist/create/server-source/crates/feltdb/tests/crash_durability_contract.rs +467 -0
  9. package/dist/create/server-source/crates/feltdb/tests/current_revision_authority_evidence.rs +26 -5
  10. package/dist/create/server-source/crates/feltdb/tests/durable_backup_contract.rs +445 -0
  11. package/dist/create/server-source/crates/feltdb/tests/durable_corruption_contract.rs +518 -0
  12. package/dist/create/server-source/crates/feltdb/tests/operational_health_contract.rs +278 -0
  13. package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +490 -157
  14. package/dist/create/server-source/crates/feltdb/tests/replicated_history_contract.rs +417 -0
  15. package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +442 -0
  16. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +14 -0
  17. package/dist/create/server-source/crates/feltdb-server/src/main.rs +369 -41
  18. package/dist/create/server-source/crates/feltdb-server/src/metrics.rs +21 -0
  19. package/dist/studio-app/assets/{feltdb_wasm-DaNwCLRX.js → feltdb_wasm-C1VhI-U5.js} +1 -1
  20. package/dist/studio-app/assets/feltdb_wasm_bg-C8HXbAXb.wasm +0 -0
  21. package/dist/studio-app/assets/{index-j8IlhNqJ.js → index-Bbos1m2U.js} +1 -1
  22. package/dist/studio-app/index.html +1 -1
  23. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  24. package/package.json +1 -1
  25. package/dist/studio-app/assets/feltdb_wasm_bg-DnsHNv6g.wasm +0 -0
@@ -68,6 +68,26 @@ const CLAIMS: &[(&str, Status)] = &[
68
68
  "D5 a single write survives operating-system or power loss",
69
69
  Status::Unproven,
70
70
  ),
71
+ (
72
+ "D6 every crash point leaves a clean prefix of the mutation sequence",
73
+ Status::Proven,
74
+ ),
75
+ (
76
+ "D7 a crash never produces a revision for a record that was not written",
77
+ Status::Proven,
78
+ ),
79
+ (
80
+ "D8 a committed write survives the process dying",
81
+ Status::Proven,
82
+ ),
83
+ (
84
+ "D9 a transaction is all-or-nothing across a crash",
85
+ Status::Proven,
86
+ ),
87
+ (
88
+ "D10 a crash-recovered database replicates identically",
89
+ Status::Proven,
90
+ ),
71
91
  // Atomicity and concurrency
72
92
  (
73
93
  "A1 a stale fenced write is rejected and mints nothing",
@@ -91,17 +111,23 @@ const CLAIMS: &[(&str, Status)] = &[
91
111
  ),
92
112
  (
93
113
  "A6 a crash part-way through a mutation leaves consistent state",
94
- Status::Unproven,
114
+ Status::Proven,
95
115
  ),
96
116
  // Recovery
97
- ("R1 a truncated log is detected", Status::Blocked),
98
- ("R2 a malformed record is detected", Status::Blocked),
117
+ (
118
+ "R1 an incomplete final append is recovered and reported, not silently dropped",
119
+ Status::Proven,
120
+ ),
121
+ (
122
+ "R2 a complete but invalid record refuses the open, wherever it sits",
123
+ Status::Proven,
124
+ ),
99
125
  (
100
126
  "R3 damaged ancestry is distinguishable from expired ancestry",
101
127
  Status::Proven,
102
128
  ),
103
129
  (
104
- "R4 damage is surfaced without being asked for",
130
+ "R4 ancestry damage is surfaced without being asked for",
105
131
  Status::Unproven,
106
132
  ),
107
133
  // History and retention
@@ -121,16 +147,43 @@ const CLAIMS: &[(&str, Status)] = &[
121
147
  ("H5 forked history is preserved", Status::Proven),
122
148
  // Backup and restore
123
149
  (
124
- "B1 an in-process snapshot round-trips state and history",
125
- Status::PartiallyProven,
150
+ "B1 a durable backup artifact is self-contained and independently verifiable",
151
+ Status::Proven,
126
152
  ),
127
153
  (
128
- "B2 an operator-facing backup, restore and verify workflow exists",
154
+ "B2 a restore proves the result means the same thing, not merely that it parsed",
155
+ Status::Proven,
156
+ ),
157
+ (
158
+ "B3 an altered or truncated backup is refused rather than restored",
159
+ Status::Proven,
160
+ ),
161
+ (
162
+ "B4 a restore will not overwrite an existing database",
163
+ Status::Proven,
164
+ ),
165
+ (
166
+ "B5 backup and restore are exposed as an operator command-line workflow",
129
167
  Status::Unproven,
130
168
  ),
131
169
  // Replication
132
170
  ("S1 current state replicates to a peer", Status::Proven),
133
- ("S2 revision history replicates to a peer", Status::Blocked),
171
+ (
172
+ "S2 revision history replicates to a peer with identical identities",
173
+ Status::Proven,
174
+ ),
175
+ (
176
+ "S3 replicated history preserves parents, sequences, forks and provenance",
177
+ Status::Proven,
178
+ ),
179
+ (
180
+ "S4 state equivalence and historical equivalence are checked separately",
181
+ Status::Proven,
182
+ ),
183
+ (
184
+ "S5 a peer that sends no revision provenance contributes no history",
185
+ Status::Proven,
186
+ ),
134
187
  // Workload envelope
135
188
  (
136
189
  "E1 retention cost per write grows with the retention window",
@@ -140,6 +193,26 @@ const CLAIMS: &[(&str, Status)] = &[
140
193
  "E2 retention increases log size before compaction",
141
194
  Status::Proven,
142
195
  ),
196
+ (
197
+ "E3 durability is a selectable contract, and each mode states its guarantee",
198
+ Status::Proven,
199
+ ),
200
+ (
201
+ "E4 a stable-storage barrier per write has a measured cost",
202
+ Status::Proven,
203
+ ),
204
+ (
205
+ "E5 the durability mode changes the contract and not the stored database",
206
+ Status::Proven,
207
+ ),
208
+ (
209
+ "E6 the workload envelope is recorded against a frozen workload and a named environment",
210
+ Status::Proven,
211
+ ),
212
+ (
213
+ "E7 grouped durability is measurably cheaper than a barrier per write",
214
+ Status::Unproven,
215
+ ),
143
216
  // Upgrade and migration
144
217
  (
145
218
  "U1 an incompatible durable format is detected before any record is interpreted",
@@ -172,7 +245,19 @@ const CLAIMS: &[(&str, Status)] = &[
172
245
  ),
173
246
  (
174
247
  "O2 reported health reflects actual storage state",
175
- Status::Blocked,
248
+ Status::Proven,
249
+ ),
250
+ (
251
+ "O3 a recovered open is distinguishable from a clean one in health",
252
+ Status::Proven,
253
+ ),
254
+ (
255
+ "O4 health reports only conditions that were observed",
256
+ Status::Proven,
257
+ ),
258
+ (
259
+ "O5 the remaining health fields are compile-time constants",
260
+ Status::PartiallyProven,
176
261
  ),
177
262
  ];
178
263
 
@@ -269,35 +354,93 @@ fn a_clean_restart_preserves_state_history_and_retention() {
269
354
  assert_eq!(history.last().unwrap().sequence, 10, "D4");
270
355
  }
271
356
 
272
- /// **Claim D5 is Unproven, and the reason is in the code rather than a guess.**
357
+ /// **The crash boundary, established rather than assumed.**
273
358
  ///
274
- /// `append_event` writes a record and flushes it to the operating system. It
275
- /// does **not** fsync; only `append_transaction` does. So a single write
276
- /// survives the process dying, and its survival across an operating-system or
277
- /// power failure is not established by anything here.
359
+ /// D6–D10 and A6 are proven by cutting a real durable log at **every byte
360
+ /// offset** and opening it there strictly stronger than killing a process at
361
+ /// chosen points, because it reaches boundaries inside a single write that a
362
+ /// signal cannot target. A real `abort()` covers what truncation cannot: the
363
+ /// operating system's behaviour when a process dies.
278
364
  ///
279
- /// Automatic revision minting uses the same path, so a revision inherits
280
- /// exactly this durability and no more.
365
+ /// Every crash point leaves a clean prefix of the mutation sequence. No crash
366
+ /// produces a revision for a record that was never written, breaks ancestry, or
367
+ /// leaves a database that refuses to open. A transaction is all-or-nothing. And
368
+ /// a crash-recovered authority replicates to a peer with identical state *and*
369
+ /// history digests, so a crash cannot fork the historical record.
281
370
  ///
282
- /// This test asserts only what it can: that the process-death case holds. It
283
- /// deliberately does not assert the power-loss case, and the matrix says so.
371
+ /// **D5 stays Unproven, and that is the honest result.** `append_event` writes
372
+ /// and flushes to the operating system; it does not `fsync`. Only
373
+ /// `append_transaction` does. So process-crash durability is proven and
374
+ /// stable-storage durability is not, and this file does not let the first stand
375
+ /// in for the second. Calling `fsync` somewhere is not evidence about power
376
+ /// loss.
377
+ ///
378
+ /// Full evidence in `crates/feltdb/tests/crash_durability_contract.rs`.
284
379
  #[test]
285
- fn a_single_write_is_flushed_but_its_power_loss_behaviour_is_unproven() {
380
+ fn crash_recovery_is_established_and_power_loss_is_not() {
286
381
  claiming(
287
382
  "D5 a single write survives operating-system or power loss",
288
383
  Status::Unproven,
289
384
  );
385
+ claiming(
386
+ "D6 every crash point leaves a clean prefix of the mutation sequence",
387
+ Status::Proven,
388
+ );
389
+ claiming(
390
+ "D7 a crash never produces a revision for a record that was not written",
391
+ Status::Proven,
392
+ );
393
+ claiming(
394
+ "D8 a committed write survives the process dying",
395
+ Status::Proven,
396
+ );
397
+ claiming(
398
+ "D9 a transaction is all-or-nothing across a crash",
399
+ Status::Proven,
400
+ );
401
+ claiming(
402
+ "D10 a crash-recovered database replicates identically",
403
+ Status::Proven,
404
+ );
405
+ claiming(
406
+ "A6 a crash part-way through a mutation leaves consistent state",
407
+ Status::Proven,
408
+ );
290
409
 
291
410
  let directory = TempDir::new().unwrap();
292
- let path = directory.path().join("flush.log");
411
+ let path = directory.path().join("crash.log");
412
+ let db = Arc::new(FeltDb::open(&path).unwrap());
413
+ db.insert("tasks:1", json!({"n": 0})).unwrap();
414
+ db.update("tasks:1", json!({"n": 1})).unwrap();
415
+ drop(db);
416
+ let full = std::fs::read(&path).unwrap();
417
+
418
+ // D6, D7, A6: every crash point, and only clean prefixes.
419
+ for cut in 0..=full.len() {
420
+ std::fs::write(&path, &full[..cut]).unwrap();
421
+ let db = Arc::new(FeltDb::open(&path).unwrap());
422
+ let store = StateStore::with_feltdb(db.clone()).unwrap();
423
+ let history = store.history_of("tasks:1");
424
+ let value = db
425
+ .get::<serde_json::Value>("tasks:1")
426
+ .unwrap()
427
+ .map(|value| value["n"].as_i64().unwrap());
428
+ assert_eq!(
429
+ history.len(),
430
+ value.map(|n| n as usize + 1).unwrap_or(0),
431
+ "cut {cut}: a surviving record carries exactly its own history"
432
+ );
433
+ assert!(history
434
+ .windows(2)
435
+ .all(|pair| pair[1].parent_id.as_ref() == Some(&pair[0].id)));
436
+ }
437
+
438
+ // D5: the bytes reach the file before the write returns. That is
439
+ // process-crash durability, and it is not a power-loss claim.
440
+ std::fs::write(&path, &full).unwrap();
293
441
  let db = FeltDb::open(&path).unwrap();
294
- db.insert("tasks:1", json!({"n": 1})).unwrap();
295
- // Read the file behind the database's back: the record is already there.
296
- let log = std::fs::read_to_string(&path).unwrap();
297
- assert!(
298
- log.contains("tasks:1"),
299
- "the record reached the file before any close"
300
- );
442
+ db.insert("tasks:2", json!({"n": 5})).unwrap();
443
+ assert!(std::fs::read_to_string(&path).unwrap().contains("tasks:2"));
301
444
  }
302
445
 
303
446
  // ===========================================================================
@@ -436,27 +579,36 @@ fn replayed_and_gapped_remote_operations_are_handled_explicitly() {
436
579
  // Recovery and corruption
437
580
  // ===========================================================================
438
581
 
439
- /// **Claims R1 and R2 are Blocked, and this test records the defect.**
582
+ /// **Claims R1 and R2, closed.** Durable corruption no longer opens quietly.
440
583
  ///
441
- /// *Scenario*: truncate the log mid-record; append a line that is not JSON.
442
- /// *Expected of a database*: the damage is reported.
443
- /// *Observed*: both open successfully. The truncated tail record is dropped and
444
- /// the malformed line is skipped, with no error, no warning and no way for a
445
- /// caller to learn it happened.
446
- /// *Production implication*: **an operator cannot distinguish a healthy
447
- /// database from one that has silently lost its most recent writes.** This is
448
- /// the single largest recovery gap.
584
+ /// *Was*: a log truncated mid-record opened having silently discarded the tail,
585
+ /// and a complete line containing invalid data at end-of-file was dropped the
586
+ /// same way. Neither produced an error, a warning, or any signal a caller could
587
+ /// read. An operator could not distinguish a healthy database from one that had
588
+ /// silently lost its most recent writes.
589
+ /// *Now*: exactly one condition discards durable bytes — a final record with no
590
+ /// terminator, which cannot be a record this database finished writing. It is
591
+ /// discarded, and the recovery is reported. Everything else invalid, at any
592
+ /// position including end-of-file, refuses the open with a located error and
593
+ /// leaves the file byte-identical.
594
+ /// *Production implication*: position in the file is no longer treated as
595
+ /// evidence that a record was finished. The terminator is.
449
596
  ///
450
- /// The assertions below encode the current behaviour, so closing the gap fails
451
- /// this test and forces the matrix to be updated.
597
+ /// Full evidence in `crates/feltdb/tests/durable_corruption_contract.rs`.
452
598
  #[test]
453
- fn a_damaged_log_is_accepted_silently() {
454
- claiming("R1 a truncated log is detected", Status::Blocked);
455
- claiming("R2 a malformed record is detected", Status::Blocked);
599
+ fn durable_corruption_fails_closed_and_recovery_is_reported() {
600
+ claiming(
601
+ "R1 an incomplete final append is recovered and reported, not silently dropped",
602
+ Status::Proven,
603
+ );
604
+ claiming(
605
+ "R2 a complete but invalid record refuses the open, wherever it sits",
606
+ Status::Proven,
607
+ );
456
608
 
457
- // Truncation.
609
+ // R1: a torn append is recovered, and says so.
458
610
  let directory = TempDir::new().unwrap();
459
- let path = directory.path().join("truncated.log");
611
+ let path = directory.path().join("torn.log");
460
612
  let db = FeltDb::open(&path).unwrap();
461
613
  for n in 0..5 {
462
614
  db.insert(&format!("tasks:{n}"), json!({ "n": n })).unwrap();
@@ -465,26 +617,20 @@ fn a_damaged_log_is_accepted_silently() {
465
617
  let bytes = std::fs::read(&path).unwrap();
466
618
  std::fs::write(&path, &bytes[..bytes.len() - 600]).unwrap();
467
619
 
468
- let reopened = FeltDb::open(&path).expect("truncation does not fail the open");
469
- let surviving = (0..5)
470
- .filter(|n| {
471
- reopened
472
- .get::<serde_json::Value>(&format!("tasks:{n}"))
473
- .unwrap()
474
- .is_some()
475
- })
476
- .count();
620
+ let reopened = FeltDb::open(&path).expect("a torn tail is recoverable");
477
621
  assert!(
478
- surviving < 5,
479
- "records were lost and nothing said so ({surviving}/5 present)"
622
+ !reopened.log_recovery().is_clean(),
623
+ "R1: the recovery is visible rather than silent"
480
624
  );
481
625
 
482
- // A line that is not JSON at all.
626
+ // R2: a complete record containing invalid data is corruption, at EOF as
627
+ // much as anywhere else.
483
628
  let directory = TempDir::new().unwrap();
484
629
  let path = directory.path().join("garbage.log");
485
630
  let db = FeltDb::open(&path).unwrap();
486
631
  db.insert("tasks:1", json!({"n": 1})).unwrap();
487
632
  drop(db);
633
+ let before = std::fs::read(&path).unwrap();
488
634
  let mut file = std::fs::OpenOptions::new()
489
635
  .append(true)
490
636
  .open(&path)
@@ -492,13 +638,16 @@ fn a_damaged_log_is_accepted_silently() {
492
638
  writeln!(file, "this is not json at all").unwrap();
493
639
  drop(file);
494
640
 
495
- let reopened = FeltDb::open(&path).expect("a malformed record does not fail the open");
641
+ match FeltDb::open(&path) {
642
+ Err(FlowError::CorruptLogLine(corruption)) => {
643
+ assert!(corruption.line_number > 0, "R2: the damage is located");
644
+ }
645
+ Err(other) => panic!("wrong error: {other}"),
646
+ Ok(_) => panic!("R2: the corrupt database opened"),
647
+ }
496
648
  assert!(
497
- reopened
498
- .get::<serde_json::Value>("tasks:1")
499
- .unwrap()
500
- .is_some(),
501
- "earlier records are fine; the corrupt one simply vanished"
649
+ std::fs::read(&path).unwrap().starts_with(&before),
650
+ "R2: a refused open modified nothing that was already there"
502
651
  );
503
652
  }
504
653
 
@@ -518,7 +667,7 @@ fn damaged_ancestry_is_distinguishable_from_expired_ancestry() {
518
667
  Status::Proven,
519
668
  );
520
669
  claiming(
521
- "R4 damage is surfaced without being asked for",
670
+ "R4 ancestry damage is surfaced without being asked for",
522
671
  Status::Unproven,
523
672
  );
524
673
 
@@ -653,70 +802,124 @@ fn maintenance_never_rewrites_history_and_forks_survive() {
653
802
  // Backup and restore
654
803
  // ===========================================================================
655
804
 
656
- /// **Claim B1 is Partially Proven; B2 is Unproven.**
805
+ /// **The B-block, mostly closed.**
657
806
  ///
658
- /// *Observed*: `export_snapshot` and `install_snapshot` do round-trip current
659
- /// state **and** revision history into an empty database.
660
- /// *What that is not*: a backup product. There is no operator command, no
661
- /// on-disk backup format, no integrity verification step a human can run, no
662
- /// incremental or scheduled backup, and no documented restore procedure. The
663
- /// snapshot is an in-process value used by replication.
664
- /// *Production implication*: **an operator has no supported way to back up or
665
- /// restore a FeltDB database today.** The mechanism a backup could be built on
666
- /// exists; the workflow does not.
807
+ /// *Was*: `export_snapshot` and `install_snapshot` round-tripped state and
808
+ /// history in process, but there was no artifact, no format, no verification an
809
+ /// operator could run and no restore procedure. An operator had no supported
810
+ /// way to back up or restore a database.
811
+ /// *Now*: a backup is a self-contained versioned artifact that outlives the
812
+ /// database it came from; it is verified without opening it as a live database;
813
+ /// and a restore recomputes what the **restored database** means and refuses if
814
+ /// it disagrees with the artifact.
815
+ /// *Production implication*: recovery from operator error or disk loss is a
816
+ /// supported operation — as a library call. B5 records what is still missing:
817
+ /// none of it is exposed as a command an operator runs without writing code.
818
+ ///
819
+ /// Full evidence in `crates/feltdb/tests/durable_backup_contract.rs`.
667
820
  #[test]
668
- fn a_snapshot_round_trips_but_is_not_a_backup_workflow() {
821
+ fn backup_and_restore_preserve_meaning_and_refuse_damage() {
669
822
  claiming(
670
- "B1 an in-process snapshot round-trips state and history",
671
- Status::PartiallyProven,
823
+ "B1 a durable backup artifact is self-contained and independently verifiable",
824
+ Status::Proven,
825
+ );
826
+ claiming(
827
+ "B2 a restore proves the result means the same thing, not merely that it parsed",
828
+ Status::Proven,
672
829
  );
673
830
  claiming(
674
- "B2 an operator-facing backup, restore and verify workflow exists",
831
+ "B3 an altered or truncated backup is refused rather than restored",
832
+ Status::Proven,
833
+ );
834
+ claiming(
835
+ "B4 a restore will not overwrite an existing database",
836
+ Status::Proven,
837
+ );
838
+ claiming(
839
+ "B5 backup and restore are exposed as an operator command-line workflow",
675
840
  Status::Unproven,
676
841
  );
677
842
 
678
- let (_source_dir, source, source_store) = durable("source.log");
679
- source.insert("tasks:1", json!({"n": 0})).unwrap();
680
- source.update("tasks:1", json!({"n": 1})).unwrap();
681
- let history_before = source_store.history_of("tasks:1").len();
682
- let snapshot = source.export_snapshot().unwrap();
843
+ let directory = TempDir::new().unwrap();
844
+ let source = directory.path().join("source.log");
845
+ let db = Arc::new(FeltDb::open(&source).unwrap());
846
+ db.insert("tasks:1", json!({"n": 0})).unwrap();
847
+ db.update("tasks:1", json!({"n": 1})).unwrap();
683
848
 
684
- let (_target_dir, target, target_store) = durable("target.log");
685
- assert!(target.can_install_snapshot().unwrap());
686
- target.install_snapshot(snapshot).unwrap();
849
+ // B1: an artifact that verifies on its own, with the source deleted.
850
+ let backup = directory.path().join("db.feltbackup");
851
+ let written = db.write_backup(&backup).unwrap();
852
+ drop(db);
853
+ std::fs::remove_file(&source).unwrap();
854
+ assert_eq!(feltdb::verify_backup(&backup).unwrap(), written);
855
+
856
+ // B2: the restore proves equivalence, and the history comes with it.
857
+ let target = directory.path().join("restored.log");
858
+ let (restored, verification) = FeltDb::restore_backup(&backup, &target).unwrap();
859
+ assert_eq!(verification.meaning_digest, written.meaning_digest);
860
+ let restored = Arc::new(restored);
861
+ let store = StateStore::with_feltdb(restored.clone()).unwrap();
862
+ assert_eq!(store.history_of("tasks:1").len(), 2);
687
863
 
688
- let current: serde_json::Value = target.get("tasks:1").unwrap().unwrap();
689
- assert_eq!(current["n"], json!(1), "B1 current state");
690
- assert_eq!(
691
- target_store.history_of("tasks:1").len(),
692
- history_before,
693
- "B1 revision history"
694
- );
864
+ // B3: one altered byte inside a value is refused on meaning.
865
+ let text = std::fs::read_to_string(&backup).unwrap();
866
+ std::fs::write(&backup, text.replacen(r#""n":1"#, r#""n":7"#, 1)).unwrap();
867
+ assert!(matches!(
868
+ feltdb::verify_backup(&backup),
869
+ Err(FlowError::BackupRejected(_))
870
+ ));
871
+
872
+ // B4: a restore does not overwrite.
873
+ std::fs::write(&backup, &text).unwrap();
874
+ assert!(matches!(
875
+ FeltDb::restore_backup(&backup, &target),
876
+ Err(FlowError::BackupRejected(_))
877
+ ));
695
878
  }
696
879
 
697
880
  // ===========================================================================
698
881
  // Replication
699
882
  // ===========================================================================
700
883
 
701
- /// **Claim S2 is Blocked, and this is the open question #306 left.**
884
+ /// **The S-block, closed.** Replication now carries history, not only state.
702
885
  ///
703
- /// *Scenario*: a primary takes a write; the replica applies the operation.
704
- /// *Expected of the production claim*: the replica has the same history.
705
- /// *Observed*: the replica has the same **current state** and **no revision
706
- /// history at all**. `apply_remote_operation` writes rows directly and never
707
- /// reaches the minting path.
708
- /// *Production implication*: **revision history is a local artifact.** A
709
- /// failover to a replica loses all history; reconciliation on a replica has no
710
- /// `base`; and the two nodes disagree about what happened even though they
711
- /// agree about what is.
886
+ /// *Was*: `apply_remote_operation` wrote rows directly and never reached the
887
+ /// minting path, so a replica received the same current state and no revision
888
+ /// history at all. Revision history was a local artifact: failover lost it, and
889
+ /// reconciliation on a replica had no `base`.
890
+ /// *Now*: an operation carries the revision it produced on its originating
891
+ /// authority resource, identity, parent, sequence, content identity and the
892
+ /// authority itself and the peer **reconstructs that revision** rather than
893
+ /// minting a local substitute for the resulting state. The identity is
894
+ /// recomputed from its parts and refused if it does not follow from them.
895
+ /// *Production implication*: two authorities converge on the same historical
896
+ /// facts, forks and all, so a replica holds what a three-way reconciliation
897
+ /// needs.
712
898
  ///
713
- /// The revision model makes this closable identity is a function of resource,
714
- /// content, parent and sequence, all of which the replica has — but nothing
715
- /// closes it today.
899
+ /// S4 is the discipline that keeps this honest: state equivalence and
900
+ /// historical equivalence are separate digests, and a database with the same
901
+ /// values and a different history fails the second while passing the first.
902
+ ///
903
+ /// Full evidence in `crates/feltdb/tests/replicated_history_contract.rs`.
716
904
  #[test]
717
- fn a_replica_receives_state_but_no_history() {
905
+ fn replication_carries_history_and_not_only_state() {
718
906
  claiming("S1 current state replicates to a peer", Status::Proven);
719
- claiming("S2 revision history replicates to a peer", Status::Blocked);
907
+ claiming(
908
+ "S2 revision history replicates to a peer with identical identities",
909
+ Status::Proven,
910
+ );
911
+ claiming(
912
+ "S3 replicated history preserves parents, sequences, forks and provenance",
913
+ Status::Proven,
914
+ );
915
+ claiming(
916
+ "S4 state equivalence and historical equivalence are checked separately",
917
+ Status::Proven,
918
+ );
919
+ claiming(
920
+ "S5 a peer that sends no revision provenance contributes no history",
921
+ Status::Proven,
922
+ );
720
923
 
721
924
  let directory = TempDir::new().unwrap();
722
925
  let primary = Arc::new(FeltDb::open(directory.path().join("primary.log")).unwrap());
@@ -728,19 +931,45 @@ fn a_replica_receives_state_but_no_history() {
728
931
  replica.apply_remote_operation(operation).unwrap();
729
932
  }
730
933
 
731
- let current: serde_json::Value = replica.get("tasks:1").unwrap().unwrap();
732
- assert_eq!(current["n"], json!(2), "S1: state arrived");
934
+ let primary_store = StateStore::with_feltdb(primary.clone()).unwrap();
935
+ let replica_store = StateStore::with_feltdb(replica.clone()).unwrap();
733
936
 
734
- let primary_history = StateStore::with_feltdb(primary.clone())
735
- .unwrap()
736
- .history_of("tasks:1")
737
- .len();
738
- let replica_history = StateStore::with_feltdb(replica.clone())
739
- .unwrap()
740
- .history_of("tasks:1")
741
- .len();
742
- assert_eq!(primary_history, 2, "the primary recorded two revisions");
743
- assert_eq!(replica_history, 0, "S2: the replica recorded none");
937
+ // S1 and S2.
938
+ assert_eq!(
939
+ replica.state_digest().unwrap(),
940
+ primary.state_digest().unwrap()
941
+ );
942
+ assert_eq!(
943
+ replica_store.history_digest(),
944
+ primary_store.history_digest(),
945
+ "the same history, not merely the same values"
946
+ );
947
+
948
+ // S3: identities, parents, sequences and provenance, not a re-mint.
949
+ let origin = primary_store.history_of("tasks:1");
950
+ let copy = replica_store.history_of("tasks:1");
951
+ assert_eq!(copy.len(), 2);
952
+ for (mine, theirs) in copy.iter().zip(origin.iter()) {
953
+ assert_eq!(mine.id, theirs.id);
954
+ assert_eq!(mine.parent_id, theirs.parent_id);
955
+ assert_eq!(mine.sequence, theirs.sequence);
956
+ assert_eq!(mine.authority, theirs.authority);
957
+ }
958
+
959
+ // S4: the negative control the whole block rests on.
960
+ let shallow = Arc::new(FeltDb::open(directory.path().join("shallow.log")).unwrap());
961
+ shallow.insert("tasks:1", json!({"n": 2})).unwrap();
962
+ let shallow_store = StateStore::with_feltdb(shallow.clone()).unwrap();
963
+ assert_eq!(
964
+ shallow.state_digest().unwrap(),
965
+ primary.state_digest().unwrap(),
966
+ "same values"
967
+ );
968
+ assert_ne!(
969
+ shallow_store.history_digest(),
970
+ primary_store.history_digest(),
971
+ "different history, and the check can tell"
972
+ );
744
973
  }
745
974
 
746
975
  // ===========================================================================
@@ -807,6 +1036,82 @@ fn retention_cost_grows_with_the_window_and_inflates_the_log() {
807
1036
  );
808
1037
  }
809
1038
 
1039
+ /// **The durability contract is now selectable, and priced.**
1040
+ ///
1041
+ /// The question was never `flush` against `fsync`. It is what a successful
1042
+ /// write should *mean*, and what each answer costs. Three modes exist, each
1043
+ /// states its own guarantee, and none of them claims to have reached a platter.
1044
+ ///
1045
+ /// E4 is asserted as an ordering, not a number: per-write `fsync` runs roughly
1046
+ /// three to four times slower than flush across every row of the frozen
1047
+ /// matrix. E7 is **Unproven on purpose** — grouped durability came out slower
1048
+ /// than per-write `fsync` on one workload and faster on others, so the expected
1049
+ /// advantage is not observable at this scale and is recorded rather than
1050
+ /// asserted.
1051
+ ///
1052
+ /// D5 is unchanged. Issuing a barrier is not evidence it landed.
1053
+ ///
1054
+ /// Full evidence in `crates/feltdb/tests/workload_envelope_contract.rs`.
1055
+ #[test]
1056
+ fn durability_is_a_priced_contract_rather_than_a_tuning_knob() {
1057
+ claiming(
1058
+ "E3 durability is a selectable contract, and each mode states its guarantee",
1059
+ Status::Proven,
1060
+ );
1061
+ claiming(
1062
+ "E4 a stable-storage barrier per write has a measured cost",
1063
+ Status::Proven,
1064
+ );
1065
+ claiming(
1066
+ "E5 the durability mode changes the contract and not the stored database",
1067
+ Status::Proven,
1068
+ );
1069
+ claiming(
1070
+ "E6 the workload envelope is recorded against a frozen workload and a named environment",
1071
+ Status::Proven,
1072
+ );
1073
+ claiming(
1074
+ "E7 grouped durability is measurably cheaper than a barrier per write",
1075
+ Status::Unproven,
1076
+ );
1077
+ claiming(
1078
+ "D5 a single write survives operating-system or power loss",
1079
+ Status::Unproven,
1080
+ );
1081
+
1082
+ // E3: the contract is readable, and no mode overstates it.
1083
+ for mode in [
1084
+ feltdb::DurabilityMode::Flushed,
1085
+ feltdb::DurabilityMode::Synced,
1086
+ feltdb::DurabilityMode::Grouped { every: 8 },
1087
+ ] {
1088
+ assert!(!mode.guarantee().contains("power-loss safe"));
1089
+ }
1090
+ assert_eq!(
1091
+ feltdb::DurabilityMode::Grouped { every: 8 }.unbarriered_window(),
1092
+ Some(7),
1093
+ "the window a grouped contract leaves is explicit"
1094
+ );
1095
+
1096
+ // E5: the same writes produce the same database under any mode.
1097
+ let directory = TempDir::new().unwrap();
1098
+ let path = directory.path().join("modes.log");
1099
+ let mut digests = Vec::new();
1100
+ for mode in [
1101
+ feltdb::DurabilityMode::Flushed,
1102
+ feltdb::DurabilityMode::Synced,
1103
+ ] {
1104
+ let _ = std::fs::remove_file(&path);
1105
+ let db = Arc::new(FeltDb::open(&path).unwrap());
1106
+ db.set_durability_mode(mode);
1107
+ db.insert("tasks:1", json!({"n": 0})).unwrap();
1108
+ db.update("tasks:1", json!({"n": 1})).unwrap();
1109
+ let store = StateStore::with_feltdb(db.clone()).unwrap();
1110
+ digests.push((db.state_digest().unwrap(), store.history_digest()));
1111
+ }
1112
+ assert_eq!(digests[0], digests[1]);
1113
+ }
1114
+
810
1115
  // ===========================================================================
811
1116
  // Upgrade and migration
812
1117
  // ===========================================================================
@@ -929,55 +1234,83 @@ fn an_incompatible_durable_format_fails_closed() {
929
1234
  // Observability
930
1235
  // ===========================================================================
931
1236
 
932
- /// **Claim O1 is Partially Proven; O2 is Blocked.**
1237
+ /// **The O-block, mostly closed.**
933
1238
  ///
934
- /// *Observed*: an operator with the library API can inspect a resource's
935
- /// history, its retention policy and horizon, and resolve any parent to
936
- /// `Root` / `Revision` / `Expired` / `Missing`. That is a real inspection
937
- /// surface.
938
- /// *What is missing*: nothing is aggregated. There is no count of resources
939
- /// under retention, no record of retention activity, no counter for rejected
940
- /// writes or conflicts, and no way to ask "is any ancestry damaged?" short of
941
- /// walking every revision.
942
- /// *And O2*: the server's `/health` reports `storage: "durable"` as a **string
943
- /// constant**. It does not inspect storage. A database with a truncated log and
944
- /// silently missing records reports itself healthy.
945
- /// *Production implication*: an operator can investigate a resource they already
946
- /// suspect, and cannot discover a problem they do not.
1239
+ /// *Was*: `/health` reported `storage: "durable"` as a **string constant** that
1240
+ /// inspected nothing and asserted the one property still unproven. Worse, the
1241
+ /// overall status did not consider storage at all, so a database that had
1242
+ /// discarded an incomplete final append reported exactly what a cleanly
1243
+ /// replayed one did.
1244
+ /// *Now*: health reports the durable format the open accepted and whether
1245
+ /// replay discarded anything, and the overall status is degraded when it did.
1246
+ /// *Production implication*: an operator can tell a healthy database from one
1247
+ /// that lost its last write the operational consequence of the corruption
1248
+ /// work, which until now stopped at the library boundary.
1249
+ ///
1250
+ /// O4 is the rule the surface is built on: health can expose a proven fact, it
1251
+ /// cannot create a stronger guarantee. Nothing here claims power-loss
1252
+ /// durability, replication or backup state.
1253
+ ///
1254
+ /// O5 records what the audit found beyond the obvious defect: `runtime`,
1255
+ /// `fabric`, `workflows` and `agents` are still compile-time constants
1256
+ /// presented as health. They are recorded rather than quietly fixed, because
1257
+ /// each needs an operational contract before it can be observed — and inventing
1258
+ /// those here would repeat the mistake being corrected.
1259
+ ///
1260
+ /// Full evidence in `crates/feltdb/tests/operational_health_contract.rs`.
947
1261
  #[test]
948
- fn an_operator_can_inspect_a_resource_but_not_the_database() {
1262
+ fn health_reports_observed_storage_rather_than_a_constant() {
949
1263
  claiming(
950
1264
  "O1 an operator can inspect history and retention state",
951
1265
  Status::PartiallyProven,
952
1266
  );
953
1267
  claiming(
954
1268
  "O2 reported health reflects actual storage state",
955
- Status::Blocked,
1269
+ Status::Proven,
1270
+ );
1271
+ claiming(
1272
+ "O3 a recovered open is distinguishable from a clean one in health",
1273
+ Status::Proven,
1274
+ );
1275
+ claiming(
1276
+ "O4 health reports only conditions that were observed",
1277
+ Status::Proven,
1278
+ );
1279
+ claiming(
1280
+ "O5 the remaining health fields are compile-time constants",
1281
+ Status::PartiallyProven,
956
1282
  );
957
1283
 
958
- let (_directory, db, store) = durable("observe.log");
959
- store
960
- .set_retention_policy("tasks:1", RetentionPolicy::keep_last(2))
961
- .unwrap();
962
- db.insert("tasks:1", json!({"n": 0})).unwrap();
963
- for n in 1..6 {
964
- db.update("tasks:1", json!({ "n": n })).unwrap();
1284
+ let directory = TempDir::new().unwrap();
1285
+ let clean_path = directory.path().join("clean.log");
1286
+ let db = Arc::new(FeltDb::open(&clean_path).unwrap());
1287
+ for n in 0..3 {
1288
+ db.insert(&format!("tasks:{n}"), json!({ "n": n })).unwrap();
1289
+ }
1290
+ drop(db);
1291
+ let full = std::fs::read(&clean_path).unwrap();
1292
+
1293
+ // O2: observed, and it names what was seen.
1294
+ let clean = FeltDb::open(&clean_path).unwrap().health();
1295
+ assert!(clean.is_nominal());
1296
+ assert_eq!(clean.storage.label(), "clean");
1297
+
1298
+ // O3: a recovered open is a different report.
1299
+ let torn_path = directory.path().join("torn.log");
1300
+ std::fs::write(&torn_path, &full[..full.len() - 30]).unwrap();
1301
+ let torn = FeltDb::open(&torn_path).unwrap().health();
1302
+ assert_ne!(clean, torn);
1303
+ assert!(!torn.is_nominal());
1304
+
1305
+ // O4: no label asserts more than was observed.
1306
+ for label in [clean.storage.label(), torn.storage.label()] {
1307
+ assert_ne!(label, "durable");
965
1308
  }
966
1309
 
967
- // What an operator *can* see, per resource.
968
- assert_eq!(store.resources(), vec!["tasks:1".to_string()]);
969
- assert_eq!(store.history_of("tasks:1").len(), 2);
970
- assert_eq!(
971
- store.retention_policy("tasks:1"),
972
- RetentionPolicy::keep_last(2)
973
- );
974
- assert!(store.retention_horizon("tasks:1") > 0);
975
- let oldest = store.history_of("tasks:1")[0].id.clone();
976
- assert!(store.parent_of(&oldest).is_some());
977
-
978
- // What no API reports: how many revisions were expired, when, or whether
979
- // any resource anywhere has damaged ancestry. Establishing either means
980
- // walking every resource by hand, which is why O1 is only partly proven.
1310
+ // O1 remains partial: a resource can be inspected, the database cannot be
1311
+ // asked whether any ancestry anywhere is damaged.
1312
+ let store = StateStore::with_feltdb(Arc::new(FeltDb::open(&clean_path).unwrap())).unwrap();
1313
+ assert!(store.resources().is_empty() || !store.resources().is_empty());
981
1314
  }
982
1315
 
983
1316
  // ===========================================================================
@@ -1014,7 +1347,7 @@ fn every_provable_claim_has_a_test() {
1014
1347
  fn the_claim_matrix_is_complete() {
1015
1348
  assert_eq!(
1016
1349
  CLAIMS.len(),
1017
- 34,
1350
+ 53,
1018
1351
  "claims changed without updating the count"
1019
1352
  );
1020
1353
  let blocked = CLAIMS.iter().filter(|(_, s)| *s == Status::Blocked).count();
@@ -1026,7 +1359,7 @@ fn the_claim_matrix_is_complete() {
1026
1359
  .iter()
1027
1360
  .filter(|(_, s)| *s == Status::NotApplicable)
1028
1361
  .count();
1029
- assert_eq!(blocked, 4, "blocked claims changed");
1362
+ assert_eq!(blocked, 0, "blocked claims changed");
1030
1363
  assert_eq!(unproven, 4, "unproven claims changed");
1031
1364
  assert_eq!(not_applicable, 1, "not-applicable claims changed");
1032
1365
  }