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 11Markdown 1TOML 187 Definiciones
T11a: réplica por dispositivo (cura de F2)
T11 §C: el supuesto oculto de T10 (spent monótono-durable en la fuente) se
rompe con multi-writer. Cura F2: NO modelar la identidad como una réplica —
una identidad es un CONJUNTO de celdas-dispositivo, cada una réplica del
esquema de T10 con su b0[device] y spent[device].
- escrow::Identity { devices, inv }: total_avail/value/invariant_holds sobre
las celdas de la identidad. La disciplina single-writer se cumple a nivel
de celda-dispositivo, no de identidad.
Tests §G.2: dos dispositivos gastan concurrentemente sin pisarse (el gasto de
uno no toca el avail del otro; cada celda un único escritor); rebalanceo entre
dispositivos = grant, conserva total_avail; identidades disjuntas aisladas.
F2 es un no-problema por diseño, coordination-free, sin fencing (eso es solo
la frontera del clon, §D/T11c). 65 tests, clippy limpio.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
diff --git a/T11.md b/T11.md
new file mode 100644
index 0000000..9ed0a21
--- /dev/null
+++ b/T11.md
@@ -0,0 +1,207 @@
+# SDD — Addendum: T11 · Monotonía en la fuente (el supuesto que T10 daba gratis)
+
+**Extiende:** `DESIGN.md` + addenda T5–T7, T8, T9, T10.
+**Estado:** Diseño — habilitado por un "no rotundo" a la pregunta de T10:
+Tawasuyu/Hammer **no** garantizan réplicas single-writer con estado durable.
+**Precondición:** T10 cerrado (`certificado_dinamico` verde bajo el supuesto de fuente monótona-durable).
+**Última edición:** 2026-07-01
+
+---
+
+## A. El supuesto oculto de T10, ahora explícito
+
+El teorema de T10 (`total_avail` invariante ⟹ `valor ≥ K`) asume silenciosamente que cada `spent_i` es
+**durable y monótono en la fuente**: un escritor nunca olvida ni retrocede lo que ya cometió, y no hay dos
+escritores concurrentes sobre la misma celda. La fusión CRDT converge *dado* que cada fuente solo crece.
+Si la fuente misma retrocede o se bifurca, la premisa se viola **antes** de fusionar, y el certificado no
+aplica.
+
+El "no rotundo" rompe justo eso, por dos vías distintas + una frontera:
+
+| Fallo | Qué pasa | Cura | ¿Coordination-free? |
+|---|---|---|---|
+| **F1 · Rollback** | un nodo vuelve de snapshot viejo, `spent_i` baja, re-gasta → doble gasto | durabilidad (WAL, persistir antes del ack) | **sí**, local |
+| **F2 · Multi-writer** | dos dispositivos = "misma réplica", cada uno gasta su `avail` | réplica-por-dispositivo (identidad = conjunto de réplicas) | **sí**, sin fencing |
+| **Frontera · Clon** | un dispositivo clonado a dos copias vivas, misma celda, sin comunicarse | fencing por época: acota + detecta al reconectar | **no del todo** (ver §D) |
+
+Fencing NO es la cura de F1 ni de F2. Es solo la defensa acotada de la frontera del clon.
+
+---
+
+## B. F1 — Durabilidad: `spent` no puede olvidar (coordination-free)
+
+**Contrato:** un gasto se reconoce al usuario (`ack`) **solo después** de que `spent_i` esté persistido
+en almacenamiento durable (write-ahead log, fsync). Un crash puede perder gastos *no reconocidos* (nunca
+prometidos a nadie), jamás uno reconocido.
+
+Con esto, `spent_i` es monótono en la fuente por construcción: tras cualquier crash, al recuperar desde el
+WAL, `spent_i` es ≥ el valor en el último ack. No retrocede por debajo de lo comprometido. Cero
+coordinación — cada nodo fsyncea lo suyo.
+
+**Lo que T11 debe verificar (con dientes):** el modelo de crash de T10 era el *benigno* (un `grant` sin
+propagar). T11 mete el crash *malo*: una réplica que **revierte su propio `spent_i`** por debajo de un
+valor ya reconocido. El test debe demostrar que la capa de durabilidad lo **impide** (la recuperación
+restaura `spent_i` desde el WAL), o que se **detecta y rechaza** en el merge (§D). Un `spent_i` que
+retrocede sin ser atrapado = certificado roto.
+
+---
+
+## C. F2 — Réplica por dispositivo: la identidad es un conjunto (coordination-free)
+
+**No modeles la identidad como una réplica.** Un usuario/identidad es un **conjunto de réplicas-
+dispositivo**, cada una con su propia celda de presupuesto `b0[device]` y su propio `spent[device]`.
+
+Consecuencias:
+- Dos dispositivos de una persona son dos réplicas del esquema de T10, sin cambios. Cada uno gasta su
+ presupuesto local sin serializar con el otro. No hay "dos escritores de la misma celda" → no hay carrera
+ → no hace falta fencing para F2.
+- Un dispositivo seco rebalancea desde otro dispositivo de la misma identidad: es el rebalanceo edge-local
+ de T10b, ya construido.
+- Un dispositivo offline **estranca** su presupuesto (no lo puede gastar nadie más hasta que vuelva): es el
+ costo de disponibilidad honesto de `AVAILABILITY.md`, sin novedad.
+
+Esto convierte F2 en un no-problema por diseño, sin coordinación. La disciplina "single-writer" de T10 se
+cumple **a nivel de celda-dispositivo**, no a nivel de identidad.
+
+---
+
+## D. La frontera del clon (lo que NINGÚN sistema coordination-free puede cerrar)
+
+F2 resuelve *dos dispositivos distintos*. Queda el caso duro: **una celda con dos escritores vivos** — un
+dispositivo restaurado a dos copias (VM snapshot, proceso duplicado) que comparten `device_id` y celda, y
+**no se comunican**. Cada clon puede gastar la celda entera → doble gasto de un presupuesto.
+
+**Teorema de imposibilidad (hay que decirlo).** Ningún mecanismo local puede detectar un clon silencioso
+antes de que los clones se comuniquen. Es el mismo CAP que el sobregiro particionado, ahora dentro de una
+celda: sin comunicación, no hay forma de saber que el presupuesto ya se gastó en otra copia.
+
+**Lo mejor posible — fencing por época device-local, enforced en el merge:**
+- Cada instancia viva de un dispositivo tiene una **época** monótona, incrementada y persistida en cada
+ arranque (`epoch`, en el WAL de §B).
+- Cada gasto lleva `(device_id, epoch, seq)`. `spent` se indexa por `(device_id, epoch)`.
+- El **merge** es el punto de enforcement (no un servidor central, que no existe): al fusionar, si aparecen
+ dos linajes con el mismo `device_id` y épocas distintas, el de **época menor es un clon obsoleto** — sus
+ gastos post-bifurcación se **rechazan/aíslan** y se levanta una alarma (`CloneDetected`).
+- Esto **no previene** el doble gasto mientras los clones están aislados; lo **acota** (a una celda por
+ clon no detectado) y lo **detecta** en cuanto reconectan. Convierte "silencioso e ilimitado" en "acotado
+ y delatado".
+
+**Honestidad (no repetir el sobre-reclamo).** El fencing token clásico (Kleppmann) asume un recurso
+linealizable que rechaza tokens viejos; tu malla no lo tiene, por eso el enforcement vive en el merge y la
+garantía es *bounded-detect*, no *prevent*. Y generar épocas *globalmente* monótonas pediría consenso — por
+eso son **device-local** (cada dispositivo cuenta sus propios arranques), lo que es coordination-free a
+costa de que la detección sea al reconectar, no instantánea.
+
+---
+
+## E. El terminus honesto del proyecto
+
+Tras T11, el enunciado correcto —el que respeta CAP y no promete de más— es:
+
+> **Seguridad coordination-free módulo un supuesto físico nombrado:** cada celda de presupuesto tiene un
+> único escritor durable. Bajo ese supuesto (garantizado por §B durabilidad + §C réplica-por-dispositivo),
+> el certificado de T10 aplica sin coordinación. Cuando el supuesto se rompe (clon, §D), el daño está
+> **acotado a una celda por clon no detectado y se detecta al reconectar** — nunca es silencioso ni
+> ilimitado.
+
+Ese es el sitio honesto: no "seguro pase lo que pase", sino "seguro salvo esta condición física, y aquí
+está exactamente qué pasa cuando se rompe y cuánto cuesta".
+
+---
+
+## F. Modelo de tipos (bocetos)
+
+```rust
+/// spent indexado por (device, epoch): detecta linajes bifurcados en el merge.
+pub struct DurableSpent {
+ pub device_id: DeviceId,
+ pub epoch: u64, // monótona por dispositivo, persistida al arrancar
+ pub entries: BTreeMap<u64, i64>, // seq -> monto, solo crece
+}
+
+pub struct WriteAheadLog { /* persiste spent+epoch ANTES del ack */ }
+impl WriteAheadLog {
+ pub fn commit_spend(&mut self, seq: u64, amount: i64) -> AckToken { /* fsync, luego ack */ }
+ pub fn recover(&self) -> DurableSpent { /* spent_i nunca por debajo del último ack */ }
+}
+
+pub enum MergeOutcome {
+ Ok,
+ CloneDetected { device_id: DeviceId, stale_epoch: u64, live_epoch: u64 },
+}
+
+/// EscrowState de T10, ahora con spent durable por (device, epoch) y detección de clon en merge.
+impl EscrowState {
+ pub fn merge(&mut self, other: &EscrowState) -> MergeOutcome { /* ... */ }
+}
+```
+
+---
+
+## G. Tests (el modelo de crash MALO, no el benigno)
+
+1. **Durabilidad contra rollback (§B).** `proptest`: una réplica gasta y ackea, "crashea" (se descarta su
+ estado en memoria), recupera desde el WAL, y re-opera. Aserción: `spent_i` recuperado ≥ el del último
+ ack; nunca re-gasta lo ya comprometido. Incluye el adversario: intentar restaurar un snapshot *por
+ debajo* del WAL debe ser rechazado, no aceptado.
+2. **Réplica-por-dispositivo (§C).** Dos dispositivos de una identidad, cada uno con su celda, gastan
+ concurrentemente: `total_avail` de la identidad = Σ celdas − Σ spent, invariante. Rebalanceo entre
+ dispositivos conserva. Ningún gasto de un dispositivo pisa el `avail` del otro.
+3. **Detección de clon (§D).** Bifurca un `device_id` en dos épocas, cada clon gasta su celda entera
+ aislado, luego fusiona: el merge devuelve `CloneDetected`, aísla los gastos de la época menor, y
+ reporta el daño acotado (≤ una celda). Aserción: tras la detección, el estado reconciliado no propaga el
+ doble gasto hacia el resto de la malla.
+4. **No-regresión del certificado dinámico de T10** bajo el nuevo modelo de `spent` durable-por-época:
+ sigue verde con fuente monótona; y **falla ruidosamente** (no silenciosamente) cuando se inyecta un
+ rollback no atrapado — para probar que el test de §G.1 tiene dientes.
+
+---
+
+## H. M4 — el contrato, ahora escribible en piedra
+
+Solo con T11 el adaptador a Tawasuyu/Hammer puede declarar sus precondiciones honestamente:
+
+**Contrato del adaptador (`M4`):**
+- **Requiere** WAL durable de `spent` por dispositivo (persistir antes de ack). Sin esto, sin garantía.
+- **Requiere** identidad-como-conjunto-de-dispositivos: cada dispositivo es una réplica con su celda.
+- **Garantiza** seguridad coordination-free del invariante bajo el supuesto de un-escritor-durable-por-celda.
+- **Acota y detecta** (no previene) el doble gasto por clon; expone `CloneDetected` como señal que la capa
+ de aplicación debe manejar (compensación, alarma). Documentado en `BOUNDARY.md`.
+- **Limita** disponibilidad bajo partición al presupuesto local (`AVAILABILITY.md`).
+
+Si Tawasuyu/Hammer no pueden dar durabilidad por dispositivo, el adaptador debe **rehusar** en vez de
+prometer seguridad que no tiene. Esa negativa es parte del diseño, no un fallo.
+
+---
+
+## I. Tickets
+
+**T11a — Réplica por dispositivo (F2).**
+"Refactoriza el modelo de réplica: `identity = conjunto de device-replicas`, cada una con su `b0[device]` y
+`spent[device]`. Ajusta el reparto (`Σ b0 ≤ S` sobre dispositivos) y el rebalanceo (T10b) para operar entre
+dispositivos de una identidad. Test §G.2. Ninguna celda tiene más de un escritor lógico."
+
+**T11b — Durabilidad de `spent` (F1).**
+"Implementa `WriteAheadLog` (§F): `commit_spend` persiste antes de devolver `AckToken`; `recover`
+reconstruye `spent_i` ≥ último ack. Conecta al gasto: no hay ack sin commit durable. Test §G.1, incluido el
+adversario que intenta restaurar por debajo del WAL. `spent_i` monótono en la fuente, garantizado."
+
+**T11c — Fencing por época + detección de clon (frontera §D).**
+"Indexa `spent` por `(device_id, epoch)`; `epoch` monótona por dispositivo, persistida en el WAL al
+arrancar. `merge` devuelve `CloneDetected` cuando halla dos épocas para un `device_id`, aísla la época
+menor y acota el daño a una celda. Escribe `BOUNDARY.md`: el teorema de imposibilidad (no hay detección
+coordination-free de clon silencioso), la garantía real (bounded + detect-on-reconnect), y por qué las
+épocas son device-local (evitar consenso). Tests §G.3 y §G.4. Cuando pase, actualiza `STRONG_RESULT.md`
+con el terminus honesto de §E y declara el contrato de M4 (§H) listo para escribir."
+
+---
+
+## J. Referencias
+
+- M. Kleppmann — *How to do distributed locking* (2016) y *Designing Data-Intensive Applications*, cap. 8–9
+ (fencing tokens; y el punto de que su enforcement clásico necesita un recurso linealizable — de ahí que
+ aquí viva en el merge).
+- P. E. O'Neil 1986; V. Balegas et al. SRDS 2015 (escrow / Bounded Counter, base de T9–T10).
+- E. Brewer — CAP (el teorema que §D y §E respetan: safety > availability bajo partición).
+- (De addenda previos) Shapiro et al. 2011 (CRDT, base de la representación durable-monótona); Bailis et
+ al. 2014; Ghrist–Riess 2022; Hansen–Ghrist 2019; Hellerstein–Alvaro.
diff --git a/src/escrow.rs b/src/escrow.rs
index c6d14f5..fbcb10a 100644
--- a/src/escrow.rs
+++ b/src/escrow.rs
@@ -190,6 +190,48 @@ impl EscrowState {
}
}
+/// Una **identidad** como **conjunto de celdas-dispositivo** (T11a, §C, cura de
+/// F2). No se modela la identidad como una réplica: un usuario es un conjunto de
+/// dispositivos, cada uno una réplica del esquema de T10 con su propia celda de
+/// presupuesto y su propio `spent`. Así la disciplina single-writer se cumple a
+/// nivel de **celda-dispositivo**, no de identidad: dos dispositivos gastan sin
+/// serializar, sin carrera, sin fencing (F2 es un no-problema por diseño).
+///
+/// El invariante (el recurso, p.ej. saldo ≥ 0) es de la identidad; su
+/// disponibilidad y su certificado se computan sobre SUS dispositivos.
+#[derive(Debug, Clone)]
+pub struct Identity {
+ /// Índices de las celdas-dispositivo de esta identidad en el `EscrowState`.
+ pub devices: Vec<usize>,
+ /// El invariante del recurso de esta identidad.
+ pub inv: GlobalInvariant,
+}
+
+impl Identity {
+ /// Crea una identidad con sus dispositivos y su invariante.
+ pub fn new(devices: Vec<usize>, inv: GlobalInvariant) -> Self {
+ Self { devices, inv }
+ }
+
+ /// Presupuesto disponible de la identidad = Σ del `avail` de sus dispositivos.
+ /// Los `grant` **entre** dispositivos de la identidad se cancelan ⟹ solo lo
+ /// mueven `b0` y `spent`: `total_avail = Σ b0[d] − Σ spent[d]`.
+ pub fn total_avail(&self, state: &EscrowState) -> i64 {
+ self.devices.iter().map(|&d| state.avail(d)).sum()
+ }
+
+ /// Valor del recurso de la identidad: `inicial − Σ spent[d]` sobre sus celdas.
+ pub fn value(&self, state: &EscrowState) -> i64 {
+ let gastado: i64 = self.devices.iter().map(|&d| state.spent[d]).sum();
+ self.inv.initial - gastado
+ }
+
+ /// ¿Se preserva el invariante de la identidad?
+ pub fn invariant_holds(&self, state: &EscrowState) -> bool {
+ self.inv.holds(self.value(state))
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -296,6 +338,59 @@ mod tests {
assert_eq!(c2.total_avail(), 30);
}
+ /// **§G.2 — réplica por dispositivo (F2).** Una identidad con dos
+ /// dispositivos (celdas `[50,50]`, cuenta 100). Ambos gastan concurrentemente
+ /// SU celda sin serializar; el gasto de uno **no toca** el `avail` del otro;
+ /// el `total_avail` de la identidad es `Σ celdas − Σ spent`, invariante.
+ #[test]
+ fn t11a_dos_dispositivos_gastan_sin_pisarse() {
+ let inv = GlobalInvariant::new(100, 0);
+ let id = Identity::new(vec![0, 1], inv);
+ let mut state = EscrowState::new(vec![50, 50]);
+
+ assert!(state.spend(0, 40));
+ assert!(state.spend(1, 30));
+ assert_eq!(state.avail(1), 20, "el gasto de 0 no tocó el avail de 1");
+ assert_eq!(id.total_avail(&state), 30, "Σ celdas − Σ spent = 100 − 70");
+ assert!(id.invariant_holds(&state));
+
+ // Cada celda tiene un único escritor lógico: 0 no puede gastar la de 1.
+ assert!(!state.spend(0, 20), "0 ya solo tiene 10 en su propia celda");
+ }
+
+ /// Rebalanceo **entre dispositivos** de una identidad = un `grant`, que
+ /// conserva el `total_avail` (T10a) — el rebalanceo edge-local de T10b.
+ #[test]
+ fn t11a_rebalanceo_entre_dispositivos_conserva() {
+ let inv = GlobalInvariant::new(100, 0);
+ let id = Identity::new(vec![0, 1], inv);
+ let mut state = EscrowState::new(vec![0, 50]);
+ let antes = id.total_avail(&state);
+
+ assert!(state.grant(1, 0, 20), "el dispositivo 1 cede 20 al 0 (seco)");
+ assert_eq!(state.avail(0), 20);
+ assert_eq!(state.avail(1), 30);
+ assert_eq!(id.total_avail(&state), antes, "el rebalanceo conserva");
+ }
+
+ /// Identidades disjuntas no se pisan (F2): la disponibilidad de una identidad
+ /// depende **solo** de sus dispositivos; la actividad de otra no la afecta.
+ #[test]
+ fn t11a_identidades_disjuntas_aisladas() {
+ let inv = GlobalInvariant::new(100, 0);
+ let a = Identity::new(vec![0, 1], inv);
+ let b = Identity::new(vec![2, 3], inv);
+ let mut state = EscrowState::new(vec![50, 50, 50, 50]);
+
+ // Toda la actividad ocurre en la identidad B.
+ assert!(state.spend(2, 40));
+ assert!(state.grant(3, 2, 10));
+
+ assert_eq!(a.total_avail(&state), 100, "A intacta");
+ assert!(a.invariant_holds(&state));
+ assert!(b.invariant_holds(&state));
+ }
+
fn escrow_st() -> impl Strategy<Value = EscrowState> {
(1usize..=4).prop_flat_map(|n| {
let b0 = prop::collection::vec(0i64..50, n);