tawasuyu

tawasuyu/hifas

Motor en Rust para decidir, con demostración y no a ojo, cuándo un conjunto de réplicas local-first puede operar sin coordinación y cuándo no — y, cuando la respuesta es "sí, dentro de límites", proveer el mecanismo que lo garantiza.

Rust 24Markdown 15TOML 1358 Definiciones

T11b: durabilidad de spent (cura de F1)

T11 §B: el supuesto oculto de T10 (spent monótono en la fuente) se hace real
con un write-ahead log. Un gasto se ackea SOLO tras persistirse; un crash
pierde gastos no reconocidos, jamás uno reconocido.

- durable::WriteAheadLog (committed solo crece; commit_spend persiste antes de
  devolver AckToken) + DurableSpent (wal durable + live volátil; recover
  reconstruye live desde el WAL ⟹ nunca por debajo del último ack).
- try_restore_snapshot rechaza restaurar por debajo del WAL (adversario de
  rollback → doble gasto).

Tests §G.1 (el crash MALO, no el benigno): el WAL sobrevive al crash y no
olvida; el adversario que intenta revertir por debajo del compromiso durable
es rechazado. Con dientes: proptest sobre secuencias de gastos+crashes
(value nunca cae bajo el ack) y rollbacks (siempre rechazados).

Combinado con el merge por max de T10, garantiza la monotonía que el
certificado exige. 69 tests, clippy limpio.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

f3f7c4fc1c23b50d484ee03bccfa967c949c53c9 · por Sergio · (hace 3 meses) · padre b4bd2e19b · ver el árbol en este commit

Delta semántico · 16

src/durable.rs

src/lib.rs

2 archivos · +178 −0

diff --git a/src/durable.rs b/src/durable.rs
new file mode 100644
index 0000000..8deee2f
--- /dev/null
+++ b/src/durable.rs
@@ -0,0 +1,177 @@
+//! `durable` — que `spent` no pueda olvidar (T11b, F1 / §B).
+//!
+//! T10 asumía en silencio que `spent_i` es **monótono en la fuente**: un escritor
+//! nunca retrocede lo que ya cometió. Aquí ese supuesto se hace real con un
+//! **write-ahead log**: un gasto se reconoce al usuario (`ack`) **solo después**
+//! de persistirse. Un crash puede perder gastos *no reconocidos* (nunca
+//! prometidos), jamás uno reconocido.
+//!
+//! Modelo: el WAL es lo durable (sobrevive al crash); el `live` en memoria es
+//! volátil. `recover` reconstruye `live` desde el WAL ⟹ nunca por debajo del
+//! último ack. Cero coordinación: cada nodo fsyncea lo suyo. Combinado con el
+//! `merge` por máximo de T10, garantiza la monotonía que el certificado exige.
+
+/// Prueba de que un gasto quedó comprometido de forma durable antes del ack.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct AckToken {
+    /// Total comprometido (durable) en el momento del ack.
+    pub committed_total: i64,
+}
+
+/// El log durable de gastos de una celda-dispositivo. Solo crece.
+#[derive(Debug, Clone, Default)]
+pub struct WriteAheadLog {
+    committed: i64,
+}
+
+impl WriteAheadLog {
+    /// WAL vacío.
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    /// Compromete un gasto (`amount ≥ 0`): **persiste primero** ("fsync") y solo
+    /// entonces devuelve el `AckToken`. Monótono: `committed` solo crece.
+    pub fn commit_spend(&mut self, amount: i64) -> AckToken {
+        debug_assert!(amount >= 0, "los gastos son no negativos");
+        self.committed += amount;
+        AckToken {
+            committed_total: self.committed,
+        }
+    }
+
+    /// Total comprometido y durable. Nunca retrocede.
+    pub fn committed(&self) -> i64 {
+        self.committed
+    }
+}
+
+/// El gasto de una celda-dispositivo, respaldado por un WAL durable.
+#[derive(Debug, Clone, Default)]
+pub struct DurableSpent {
+    wal: WriteAheadLog,
+    /// Valor en memoria (volátil): se pierde en un crash, se rehace desde el WAL.
+    live: i64,
+}
+
+impl DurableSpent {
+    /// Celda de gasto durable en cero.
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    /// Gasta `amount`: compromete al WAL (durable) y **luego** actualiza el valor
+    /// vivo y ackea. No hay ack sin commit durable.
+    pub fn spend(&mut self, amount: i64) -> AckToken {
+        let ack = self.wal.commit_spend(amount);
+        self.live = self.wal.committed();
+        ack
+    }
+
+    /// El gasto reconocido de la celda.
+    pub fn value(&self) -> i64 {
+        self.live
+    }
+
+    /// Total durable (lo que el WAL garantiza).
+    pub fn committed(&self) -> i64 {
+        self.wal.committed()
+    }
+
+    /// **Recuperación tras crash:** el estado en memoria se pierde; `live` se
+    /// reconstruye desde el WAL. Resultado: `value() == committed() ≥ último ack`.
+    /// Nunca re-gasta lo ya comprometido.
+    pub fn recover(&mut self) {
+        self.live = self.wal.committed();
+    }
+
+    /// **Defensa contra el adversario (§G.1):** un intento de restaurar un
+    /// snapshot *por debajo* del WAL (rollback → doble gasto) se **rechaza**.
+    /// Solo se acepta un valor `≥ committed` (que no retrocede). Devuelve si se
+    /// aceptó.
+    pub fn try_restore_snapshot(&mut self, snapshot: i64) -> bool {
+        if snapshot >= self.wal.committed() {
+            self.live = snapshot;
+            true
+        } else {
+            false // rollback por debajo del compromiso durable: RECHAZADO
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use proptest::prelude::*;
+
+    /// §G.1 — el WAL no olvida: se gasta y ackea, "crashea" (se pierde el estado
+    /// en memoria), recupera desde el WAL, y el `spent` recuperado es ≥ el último
+    /// ack. Nunca re-gasta lo ya comprometido.
+    #[test]
+    fn el_wal_sobrevive_al_crash() {
+        let mut d = DurableSpent::new();
+        d.spend(40);
+        let ack = d.spend(30);
+        assert_eq!(ack.committed_total, 70);
+
+        // "Crash": el valor en memoria se corrompe/pierde a un estado viejo...
+        d.live = 40; // (simula memoria stale que olvidó los últimos 30)
+        // ...pero la recuperación lo restaura desde el WAL durable.
+        d.recover();
+        assert_eq!(d.value(), 70, "recuperado ≥ último ack; no olvida");
+    }
+
+    /// §G.1 (adversario) — restaurar un snapshot por debajo del WAL se rechaza;
+    /// por encima (que no retrocede) se acepta.
+    #[test]
+    fn adversario_rollback_es_rechazado() {
+        let mut d = DurableSpent::new();
+        d.spend(70);
+        assert!(!d.try_restore_snapshot(50), "50 < 70 comprometido: rollback rechazado");
+        assert_eq!(d.value(), 70, "el rechazo no tocó el valor");
+        assert!(d.try_restore_snapshot(80), "80 ≥ 70: no retrocede, aceptado");
+    }
+
+    proptest! {
+        /// §G.1 (con dientes) — sobre cualquier secuencia de gastos y crashes, el
+        /// valor recuperado nunca cae por debajo del último ack comprometido.
+        #[test]
+        fn spent_nunca_cae_por_debajo_del_ack(
+            ops in prop::collection::vec((any::<bool>(), 0i64..100), 0..40)
+        ) {
+            let mut d = DurableSpent::new();
+            for (crash, amount) in ops {
+                if crash {
+                    // Se pierde memoria (a un estado arbitrario viejo) y se recupera.
+                    d.live = 0;
+                    d.recover();
+                } else {
+                    d.spend(amount);
+                }
+                prop_assert!(d.value() >= d.committed(), "por debajo del WAL");
+                prop_assert_eq!(d.value(), d.committed(), "value se ancla al WAL");
+            }
+        }
+
+        /// El adversario nunca logra revertir por debajo del compromiso durable.
+        #[test]
+        fn rollback_por_debajo_del_wal_siempre_rechazado(
+            gastos in prop::collection::vec(0i64..50, 1..8),
+            snapshot in -20i64..300,
+        ) {
+            let mut d = DurableSpent::new();
+            for g in gastos {
+                d.spend(g);
+            }
+            let committed = d.committed();
+            let aceptado = d.try_restore_snapshot(snapshot);
+            if snapshot < committed {
+                prop_assert!(!aceptado);
+                prop_assert_eq!(d.value(), committed, "rechazo no retrocede");
+            } else {
+                prop_assert!(aceptado);
+                prop_assert!(d.value() >= committed);
+            }
+        }
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 04898e1..54e2d7d 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -13,6 +13,7 @@
 
 pub mod cell;
 pub mod cohomology;
+pub mod durable;
 pub mod error;
 pub mod escrow;
 pub mod gf2;