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 Sutichaykuna
M4: adaptador soberano con API congelada y contrato honesto
Unifica todo el proyecto tras adapter::SovereignAccount, con las precondiciones
del contrato §H en el centro:
- SubstrateCapabilities + AdapterError: open() REHÚSA (NoDurability /
NoDeviceIdentity) si el sustrato no cumple, en vez de prometer seguridad que
no tiene. La negativa es parte del diseño.
- Compone las piezas: EscrowState (presupuesto con procedencia, T10a) + WAL
durable por dispositivo (T11b) + ruteo edge-local por el nervio (T9b/T10b) +
época de fencing (T11c).
- API: spend (local | rebalanceo | Rejected{spendable}, fail-safe), available,
value/invariant_holds (certificado value≥floor), recover (desde WAL),
reboot (época), sync (Ok | CloneDetected como ALARMA, no recuperación).
M4.md: contrato congelado en tabla (cláusula → cómo se cumple → test), la API
pública, qué compone cada garantía, y las fronteras dichas (no previene clon;
no hay disponibilidad ilimitada bajo partición; no es motor de replicación).
Nerve ahora deriva Clone. 81 tests, clippy limpio.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
diff --git a/M4.md b/M4.md
new file mode 100644
index 0000000..1b76dfe
--- /dev/null
+++ b/M4.md
@@ -0,0 +1,70 @@
+# M4 — el adaptador soberano (API congelada)
+
+El puente a los sistemas soberanos (Tawasuyu/Hammer). Unifica todo el proyecto
+tras una API estable, `adapter::SovereignAccount`, con precondiciones honestas. No
+se declara completo hasta que el contrato de abajo se sostiene bajo los tests que
+lo respaldan (`adapter::tests`).
+
+## El contrato (§H de T9/T10/T11) — escrito en piedra
+
+| Cláusula | Cómo se cumple | Respaldo |
+|---|---|---|
+| **Requiere** WAL durable de `spent` por dispositivo | `open` rehúsa con `NoDurability` si `caps.durable_per_device == false` | `rehusa_sin_durabilidad` |
+| **Requiere** identidad = conjunto de dispositivos | `open` rehúsa con `NoDeviceIdentity` si `caps.identity_as_devices == false` | `rehusa_sin_identidad_por_dispositivo` |
+| **Garantiza** seguridad coordination-free del invariante | `value() ≥ floor` en toda ejecución (Σ b0 conservado, gastos guardados) | `certificado_gastando_todo_el_slack` |
+| **Acota y detecta** (no previene) el clon | `sync` devuelve `CloneDetected`; el daño ya ocurrió, es **alarma** (`BOUNDARY.md`) | `clon_detectado_en_sync` |
+| **Limita** la disponibilidad bajo partición | `spend` devuelve `Rejected { spendable }`; nunca sobregira ni cuelga | `particion_sin_ruta_es_fail_safe` |
+| **Rehúsa** en vez de prometer de más | `open` devuelve `Err(AdapterError)`, no una cuenta insegura | ambos `rehusa_*` |
+
+## La API pública congelada
+
+```rust
+SubstrateCapabilities { durable_per_device, identity_as_devices }
+AdapterError { NoDurability, NoDeviceIdentity, InvalidSplit }
+
+SovereignAccount::open(caps, inv, weights, edges) -> Result<Self, AdapterError>
+ // rehúsa si el sustrato no cumple; reparte el slack en celdas-dispositivo.
+
+ .spend(device, amount) -> SpendOutcome // Local | rebalanceo | Rejected{spendable}
+ .available(device) -> i64 // presupuesto local
+ .total_available() -> i64 // Σ b0 − Σ spent
+ .value() -> i64 // inicial − Σ spent (≥ floor: certificado)
+ .invariant_holds() -> bool
+ .recover(device) // restaura spent desde el WAL tras crash
+ .reboot(device) -> u64 // incrementa la época device-local (fencing)
+ .sync(&other) -> MergeOutcome // Ok | CloneDetected (alarma)
+```
+
+## Qué compone (y de dónde sale cada garantía)
+
+- **Presupuesto con procedencia** — `escrow::EscrowState` (T10a): crash-safe y
+ merge-safe; `Σ avail = Σ b0 − Σ spent` invariante a las transferencias.
+- **Durabilidad** — `durable::WriteAheadLog` (T11b): el `spend` persiste al WAL
+ **antes** del ack; `recover` restaura `≥ último ack`. Sin ella, `open` rehúsa.
+- **Ruteo edge-local** — `nerve` + transferencias `grant` (T9b/T10b): `spend`
+ rebalancea de vecinas con excedente, transaccional (o rechaza, no cuelga).
+- **Réplica-por-dispositivo** — `escrow::Identity` (T11a): la cuenta ES la
+ identidad; cada dispositivo, una celda single-writer.
+- **Fencing / clon** — época device-local + `sync` → `MergeOutcome::CloneDetected`
+ (T11c). Detalle del daño acotado a una celda en `fencing.rs`.
+
+## Lo que M4 NO promete (las fronteras, dichas)
+
+- **No previene el doble gasto por clon.** Lo acota (una celda) y lo delata.
+ `CloneDetected` es una **alarma**, no una recuperación: el dinero ya salió, la
+ compensación vive en la capa de aplicación y puede fracasar (`BOUNDARY.md`).
+- **No hay disponibilidad ilimitada bajo partición.** Cada dispositivo se limita a
+ su presupuesto local; `diffuse` (ruteo proactivo, `router.rs`) lo mitiga, no lo
+ elimina (`AVAILABILITY.md`). CAP: safety > availability.
+- **No es un motor de replicación.** No hay red real ni wire protocol: es la capa
+ de decisión coordination-free que el sustrato soberano invoca. La persistencia y
+ el transporte los provee Tawasuyu/Hammer; el adaptador **exige** que la
+ persistencia sea durable-por-dispositivo, o rehúsa.
+
+## El enunciado, una última vez
+
+**Seguridad coordination-free módulo un supuesto físico nombrado:** un escritor
+durable por celda. Bajo él, el certificado dinámico de T10 aplica sin coordinación.
+Roto (clon), el daño está acotado a una celda por clon no detectado y se detecta al
+reconectar. No "seguro pase lo que pase" — seguro salvo esta condición física, con
+el costo exacto de romperla escrito en `BOUNDARY.md` y `AVAILABILITY.md`.
diff --git a/src/adapter.rs b/src/adapter.rs
new file mode 100644
index 0000000..30527f2
--- /dev/null
+++ b/src/adapter.rs
@@ -0,0 +1,318 @@
+//! `adapter` — el puente a los sistemas soberanos (M4, §H de T9/T10/T11).
+//!
+//! Unifica las piezas del proyecto tras una **API congelada** con precondiciones
+//! honestas. Cablea `EscrowState` (presupuesto con procedencia, crash-safe y
+//! merge-safe, T10a), el WAL durable (T11b), el ruteo por el grafo del nervio
+//! (T9b/T10b) y el fencing por época (T11c) en una cuenta soberana usable.
+//!
+//! **Contrato del adaptador (§H):**
+//! - **Requiere** WAL durable de `spent` por dispositivo (persistir antes del ack).
+//! - **Requiere** identidad-como-conjunto-de-dispositivos (una celda por dispositivo).
+//! - **Garantiza** seguridad coordination-free del invariante bajo el supuesto de
+//! un-escritor-durable-por-celda (certificado dinámico de T10).
+//! - **Acota y detecta** (no previene) el doble gasto por clon; expone
+//! `CloneDetected` como señal para que la aplicación compense (ver `BOUNDARY.md`:
+//! es una alarma, no una recuperación).
+//! - **Limita** la disponibilidad bajo partición al presupuesto local
+//! (`AVAILABILITY.md`).
+//!
+//! Si el sustrato no puede dar durabilidad por dispositivo, `open` **rehúsa** en
+//! vez de prometer seguridad que no tiene. Esa negativa es parte del diseño.
+
+use thiserror::Error;
+
+use crate::durable::WriteAheadLog;
+use crate::escrow::{Budget, EscrowState};
+use crate::fencing::MergeOutcome;
+use crate::invariant::GlobalInvariant;
+use crate::nerve::Nerve;
+use crate::router::SpendOutcome;
+
+/// Lo que el sustrato soberano (Tawasuyu/Hammer) declara poder ofrecer.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct SubstrateCapabilities {
+ /// ¿Persiste `spent` por dispositivo antes de reconocer (WAL/fsync)?
+ pub durable_per_device: bool,
+ /// ¿La identidad es un conjunto de dispositivos, cada uno con su celda?
+ pub identity_as_devices: bool,
+}
+
+impl SubstrateCapabilities {
+ /// Un sustrato que cumple ambas precondiciones del contrato.
+ pub fn full() -> Self {
+ Self {
+ durable_per_device: true,
+ identity_as_devices: true,
+ }
+ }
+}
+
+/// El adaptador rehúsa cuando el sustrato no cumple el contrato (§H).
+#[derive(Debug, Error, PartialEq, Eq)]
+pub enum AdapterError {
+ /// Sin WAL durable por dispositivo no hay garantía: se rehúsa.
+ #[error("el sustrato no ofrece durabilidad por dispositivo: se rehúsa (sin ella, sin garantía)")]
+ NoDurability,
+ /// Sin identidad-como-conjunto-de-dispositivos no se cumple single-writer por celda.
+ #[error("el sustrato no modela la identidad como conjunto de dispositivos: se rehúsa")]
+ NoDeviceIdentity,
+ /// Reparto inválido (excede el slack o negativo).
+ #[error("reparto de presupuesto inválido para el invariante dado")]
+ InvalidSplit,
+}
+
+/// Una cuenta soberana: una identidad con sus celdas-dispositivo, su invariante,
+/// su presupuesto (con procedencia y durabilidad) y su grafo de comunicación.
+#[derive(Debug, Clone)]
+pub struct SovereignAccount {
+ inv: GlobalInvariant,
+ nerve: Nerve,
+ /// Presupuesto con procedencia (crash-safe, merge-safe): `b0` + `granted` + `spent`.
+ state: EscrowState,
+ /// WAL durable por dispositivo: `spent` no puede olvidar.
+ wal: Vec<WriteAheadLog>,
+ /// Época de fencing por dispositivo (device-local, monótona).
+ epoch: Vec<u64>,
+}
+
+impl SovereignAccount {
+ /// **Abre una cuenta, o rehúsa** si el sustrato no cumple el contrato (§H).
+ ///
+ /// `weights` reparte el `slack` del invariante entre los dispositivos
+ /// (`Σ b0 ≤ slack`, garantizado por construcción); `edges` es el grafo de
+ /// comunicación entre dispositivos de la identidad.
+ pub fn open(
+ caps: SubstrateCapabilities,
+ inv: GlobalInvariant,
+ weights: &[u64],
+ edges: &[(usize, usize)],
+ ) -> Result<Self, AdapterError> {
+ if !caps.durable_per_device {
+ return Err(AdapterError::NoDurability);
+ }
+ if !caps.identity_as_devices {
+ return Err(AdapterError::NoDeviceIdentity);
+ }
+ let b0 = Budget::split(&inv, weights).per_replica;
+ let n = b0.len();
+ if b0.iter().any(|&b| b < 0) || b0.iter().sum::<i64>() > inv.slack() {
+ return Err(AdapterError::InvalidSplit);
+ }
+ Ok(Self {
+ inv,
+ nerve: Nerve::from_edges(n, edges),
+ state: EscrowState::new(b0),
+ wal: (0..n).map(|_| WriteAheadLog::new()).collect(),
+ epoch: vec![0; n],
+ })
+ }
+
+ /// Número de dispositivos de la identidad.
+ pub fn devices(&self) -> usize {
+ self.state.replicas()
+ }
+
+ /// Presupuesto disponible del dispositivo.
+ pub fn available(&self, device: usize) -> i64 {
+ self.state.avail(device)
+ }
+
+ /// Presupuesto total disponible de la identidad (`Σ b0 − Σ spent`).
+ pub fn total_available(&self) -> i64 {
+ self.state.total_avail()
+ }
+
+ /// Valor del recurso: `inicial − Σ spent`. Certificado: siempre `≥ floor`.
+ pub fn value(&self) -> i64 {
+ self.inv.initial - self.state.spent.iter().sum::<i64>()
+ }
+
+ /// ¿Se preserva el invariante? (Debe ser cierto en toda ejecución.)
+ pub fn invariant_holds(&self) -> bool {
+ self.inv.holds(self.value())
+ }
+
+ /// Intenta mover `deficit` de presupuesto a `device` desde vecinas con
+ /// excedente. **Transaccional**: si no se cubre, no mueve nada (§D fail-safe).
+ fn route_to(&mut self, device: usize, deficit: i64) -> bool {
+ let neighbors = self.nerve.neighbors(device);
+ let surplus: i64 = neighbors.iter().map(|&j| self.state.avail(j).max(0)).sum();
+ if surplus < deficit {
+ return false; // sin ruta: no aplica nada
+ }
+ let mut need = deficit;
+ for j in neighbors {
+ if need == 0 {
+ break;
+ }
+ let give = self.state.avail(j).max(0).min(need);
+ if give > 0 {
+ self.state.grant(j, device, give); // transferencia CRDT (monótona)
+ need -= give;
+ }
+ }
+ true
+ }
+
+ /// **Gasto guardado, durable y fail-safe** (el núcleo del contrato). Gasta
+ /// local si cabe; si no, rebalancea edge-local desde vecinas; si no hay ruta
+ /// (partición), **rechaza** y reporta cuánto puede gastar localmente. Nunca
+ /// sobregira ni cuelga. El commit al WAL precede al ack (durabilidad).
+ pub fn spend(&mut self, device: usize, amount: i64) -> SpendOutcome {
+ debug_assert!(amount >= 0);
+ let avail = self.state.avail(device);
+ if amount > avail {
+ let deficit = amount - avail;
+ if !self.route_to(device, deficit) {
+ return SpendOutcome::Rejected {
+ spendable: avail.max(0),
+ };
+ }
+ }
+ // Durabilidad: persistir ANTES de reconocer (T11b / §B).
+ self.wal[device].commit_spend(amount);
+ let ok = self.state.spend(device, amount);
+ debug_assert!(ok, "tras rebalancear, el gasto debe caber");
+ SpendOutcome::Spent
+ }
+
+ /// Recuperación tras crash del dispositivo: `spent` se restaura desde el WAL
+ /// durable (`≥ último ack`). Nunca re-gasta lo ya comprometido (T11b).
+ pub fn recover(&mut self, device: usize) {
+ self.state.spent[device] = self.wal[device].committed();
+ }
+
+ /// Arranque de un dispositivo: incrementa y persiste su época (device-local).
+ pub fn reboot(&mut self, device: usize) -> u64 {
+ self.epoch[device] += 1;
+ self.epoch[device]
+ }
+
+ /// **Sincroniza con otra vista de la cuenta** (merge). Detecta un clon si un
+ /// dispositivo aparece con dos épocas: devuelve `CloneDetected` (alarma, no
+ /// recuperación — `BOUNDARY.md`). El merge del presupuesto es CRDT (T10a).
+ pub fn sync(&mut self, other: &SovereignAccount) -> MergeOutcome {
+ let n = self.devices();
+ let mut clon = None;
+ for d in 0..n {
+ if self.epoch[d] != other.epoch[d] {
+ clon = Some(MergeOutcome::CloneDetected {
+ device_id: d as u64,
+ stale_epoch: self.epoch[d].min(other.epoch[d]),
+ live_epoch: self.epoch[d].max(other.epoch[d]),
+ });
+ self.epoch[d] = self.epoch[d].max(other.epoch[d]);
+ }
+ }
+ self.state.merge(&other.state);
+ clon.unwrap_or(MergeOutcome::Ok)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn cuenta_de_100() -> SovereignAccount {
+ // Cuenta de 100, saldo ≥ 0 (slack 100), dos dispositivos [50,50], conectados.
+ SovereignAccount::open(
+ SubstrateCapabilities::full(),
+ GlobalInvariant::new(100, 0),
+ &[1, 1],
+ &[(0, 1)],
+ )
+ .expect("sustrato completo: abre")
+ }
+
+ #[test]
+ fn rehusa_sin_durabilidad() {
+ let caps = SubstrateCapabilities {
+ durable_per_device: false,
+ identity_as_devices: true,
+ };
+ let r = SovereignAccount::open(caps, GlobalInvariant::new(100, 0), &[1, 1], &[(0, 1)]);
+ assert_eq!(r.unwrap_err(), AdapterError::NoDurability);
+ }
+
+ #[test]
+ fn rehusa_sin_identidad_por_dispositivo() {
+ let caps = SubstrateCapabilities {
+ durable_per_device: true,
+ identity_as_devices: false,
+ };
+ let r = SovereignAccount::open(caps, GlobalInvariant::new(100, 0), &[1, 1], &[]);
+ assert_eq!(r.unwrap_err(), AdapterError::NoDeviceIdentity);
+ }
+
+ #[test]
+ fn gasto_local_dentro_de_presupuesto() {
+ let mut c = cuenta_de_100();
+ assert_eq!(c.spend(0, 30), SpendOutcome::Spent);
+ assert_eq!(c.available(0), 20);
+ assert_eq!(c.value(), 70);
+ assert!(c.invariant_holds());
+ }
+
+ #[test]
+ fn gasto_que_excede_lo_local_se_rebalancea() {
+ let mut c = cuenta_de_100();
+ // El dispositivo 0 (50) retira 60: 50 propios + 10 rebalanceados desde 1.
+ assert_eq!(c.spend(0, 60), SpendOutcome::Spent);
+ assert_eq!(c.available(0), 0);
+ assert_eq!(c.available(1), 40);
+ assert_eq!(c.value(), 40);
+ assert!(c.invariant_holds());
+ }
+
+ #[test]
+ fn particion_sin_ruta_es_fail_safe() {
+ // Dos dispositivos [50,50] SIN arista (particionados).
+ let mut c = SovereignAccount::open(
+ SubstrateCapabilities::full(),
+ GlobalInvariant::new(100, 0),
+ &[1, 1],
+ &[],
+ )
+ .unwrap();
+ assert_eq!(c.spend(0, 60), SpendOutcome::Rejected { spendable: 50 });
+ assert_eq!(c.value(), 100, "un rechazo no gasta nada");
+ assert_eq!(c.spend(0, 50), SpendOutcome::Spent, "sus 50 locales sí");
+ assert!(c.invariant_holds());
+ }
+
+ #[test]
+ fn durabilidad_recupera_tras_crash() {
+ let mut c = cuenta_de_100();
+ c.spend(0, 40); // ack durable
+ // "Crash": la contabilidad en memoria se corrompe a un valor viejo.
+ c.state.spent[0] = 0;
+ c.recover(0); // se restaura desde el WAL
+ assert_eq!(c.state.spent[0], 40, "spent recuperado ≥ último ack");
+ assert!(c.invariant_holds());
+ }
+
+ #[test]
+ fn clon_detectado_en_sync() {
+ let mut a = cuenta_de_100();
+ let mut b = a.clone();
+ // El dispositivo 0 de `b` rebootea a una época nueva (linaje bifurcado).
+ b.reboot(0);
+ assert!(matches!(
+ a.sync(&b),
+ MergeOutcome::CloneDetected { device_id: 0, stale_epoch: 0, live_epoch: 1 }
+ ));
+ }
+
+ #[test]
+ fn certificado_gastando_todo_el_slack() {
+ let mut c = cuenta_de_100();
+ // Ambos dispositivos gastan su celda entera: Σ spent = 100 = slack.
+ assert_eq!(c.spend(0, 50), SpendOutcome::Spent);
+ assert_eq!(c.spend(1, 50), SpendOutcome::Spent);
+ assert_eq!(c.value(), 0);
+ assert!(c.invariant_holds(), "valor 0 ≥ piso 0: el certificado aguanta");
+ // Un gasto más no cabe y no hay excedente: fail-safe.
+ assert_eq!(c.spend(0, 1), SpendOutcome::Rejected { spendable: 0 });
+ assert!(c.invariant_holds());
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
index eacc221..0039688 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -11,6 +11,7 @@
//! Pipeline (§5):
//! `cell` → `nerve` → `sheaf` → `cohomology` → `verdict`, validado por `oracle`.
+pub mod adapter;
pub mod cell;
pub mod cohomology;
pub mod durable;
diff --git a/src/nerve.rs b/src/nerve.rs
index 9d5bd7a..5662bed 100644
--- a/src/nerve.rs
+++ b/src/nerve.rs
@@ -18,7 +18,7 @@ pub struct SharedLink {
}
/// El nervio: grafo no dirigido de réplicas y sus enlaces compartidos.
-#[derive(Debug, Default)]
+#[derive(Debug, Default, Clone)]
pub struct Nerve {
/// Grafo no dirigido: vértices = réplicas, aristas = pares que comparten datos.
pub graph: UnGraph<ReplicaId, SharedLink>,