//! `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, /// Época de fencing por dispositivo (device-local, monótona). epoch: Vec, } 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 { 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::() > 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::() } /// ¿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()); } }