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:
co-authored by
Claude Opus 4.8
parent
100d09cc73
commit
703ae70235
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user