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>
This commit is contained in:
Sergio
2026-07-02 00:01:52 +00:00
co-authored by Claude Opus 4.8
parent 100d09cc73
commit 703ae70235
4 changed files with 390 additions and 1 deletions
+70
View File
@@ -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`.
+318
View File
@@ -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());
}
}
+1
View File
@@ -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;
+1 -1
View File
@@ -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>,