From f19ca723b62173f0d9d828ff7bfa2e24b45c2bac Mon Sep 17 00:00:00 2001 From: Sergio Date: Fri, 8 May 2026 17:33:15 +0000 Subject: [PATCH] =?UTF-8?q?feat(card):=20WireCard=20+=20extensions=20?= =?UTF-8?q?=E2=80=94=20forward-compat=20sin=20romper=20postcard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restaura el campo extensions de Card que había caído al adoptar postcard (serde_json::Value usa secuencias/maps de longitud dinámica). La solución es separar dos formas: - Card (la rica): para JSON/TOML. Tiene extensions: BTreeMap con #[serde(flatten, skip_serializing_if = is_empty)]. Los campos desconocidos del archivo sobreviven el roundtrip. - WireCard (la slim): para postcard. Mismo schema sin extensions y con genesis: Vec recursivo. Postcard-friendly por construcción. Conversiones From for WireCard (descarta extensions) y From for Card (extensiones quedan vacías post-wire). El contrato es explícito: extensions son anotaciones locales que sobreviven file I/O pero NO cruzan al Init. brahman-handshake::Hello.card cambia de Card a WireCard. Client hace card.into() al enviar; Server hace hello.card.into() para volver a Card antes de validar/registrar. Tests: - 3 nuevos en brahman-card: extensions_preserved_in_json_roundtrip, wire_card_roundtrip_strips_extensions, wire_card_postcard_friendly (verifica que postcard::to_allocvec(&wire) NO falla — caso que rompía con Card.extensions populadas). - 1 ajuste en handshake/tests/handshake.rs (struct-literal de Hello ahora con card: sample_card(...).into()). - brahman-card: postcard como dev-dep. Tests acumulados: 35 (card 11, broker 11, handshake codec+transport 2 + integ 7, card-wit 4, admin 0). 0 errores, 0 warnings (vienen del commit anterior 9420eae). CHANGELOG.md actualizado con esta entrada y con el commit 9420eae ("probando" del usuario, limpieza de 17 warnings dead-code). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 37 ++++ Cargo.lock | 1 + crates/core/brahman-card/Cargo.toml | 3 + crates/core/brahman-card/src/lib.rs | 181 +++++++++++++++++- crates/core/brahman-handshake/src/client.rs | 2 +- crates/core/brahman-handshake/src/messages.rs | 15 +- crates/core/brahman-handshake/src/server.rs | 9 +- .../core/brahman-handshake/tests/handshake.rs | 2 +- 8 files changed, 238 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf902f1..335a036 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,43 @@ ratio/diff ver `git show `. ## 2026-05-08 +### feat(card): WireCard + extensions — forward-compat sin romper postcard +- `Card.extensions: BTreeMap` restaurado con + `#[serde(flatten, default, skip_serializing_if = is_empty)]`. Los + campos JSON/TOML desconocidos sobreviven el roundtrip de archivos. +- Nuevo `WireCard`: proyección postcard-friendly (sin `extensions`, + `genesis: Vec` recursivo). Conversiones `From` y + `From` con descarte/recreación de extensions. +- `brahman-handshake::Hello.card` pasa de `Card` a `WireCard`. Client + hace `card.into()` antes de enviar; Server hace `hello.card.into()` + para volver a Card antes de validar/registrar. +- 3 tests nuevos en brahman-card: + `extensions_preserved_in_json_roundtrip`, + `wire_card_roundtrip_strips_extensions`, + `wire_card_postcard_friendly` (postcard encode/decode efectivo). +- brahman-card gana `postcard` como dev-dep para el último test. +- Contrato documentado: extensions = anotaciones locales que NO cruzan + al Init; sólo viven en archivos. + +### `9420eae` chore: limpia warnings dead-code en arje (commit del usuario) +- `ente-zero/src/events.rs`: `#![allow(dead_code)]` a nivel módulo — + es vocabulario de eventos con variantes/campos reservados para flujos + no cableados aún (CapabilityRequested, ShutdownReason::Signal, + CapabilityGrant::{Granted, Denied, QuotaExceeded}, ExitStatus + fields). +- `ente-zero/src/graph/mod.rs`: comentado el re-export ahora innecesario + de `SHUTDOWN_GRACE`. `DEFAULT_GRANT_TTL` con `#[allow(dead_code)]` + + nota "reservado para capability granting". +- `ente-zero/src/graph/capabilities.rs`: `renew_grant` con + `#[allow(dead_code)]` (capability renewal pendiente). +- `ente-kernel/src/surface.rs`: drop de `use anyhow::Context` (no se + usaba). +- `ente-hostnamed-compat/src/main.rs`: drop de `Connection` (no se + usaba). +- `ente-polkit-compat/src/main.rs`: `PolicyDecision.source` con + `#[allow(dead_code)]` (sólo aparece en `Debug` para logging). +- `cargo check --workspace`: 17 warnings → 0. + ### feat(sidecar): WIT al sidecar — módulos conscientes vivos - `brahman-card::WitInterface` deriva `Serialize`, `Deserialize`, `PartialEq`, `Eq` para cruzar el wire postcard. diff --git a/Cargo.lock b/Cargo.lock index b36595e..066f436 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1169,6 +1169,7 @@ dependencies = [ name = "brahman-card" version = "0.1.0" dependencies = [ + "postcard", "serde", "serde_json", "thiserror 2.0.18", diff --git a/crates/core/brahman-card/Cargo.toml b/crates/core/brahman-card/Cargo.toml index ff8c003..1b863d7 100644 --- a/crates/core/brahman-card/Cargo.toml +++ b/crates/core/brahman-card/Cargo.toml @@ -14,3 +14,6 @@ serde_json = { workspace = true } toml = { workspace = true } thiserror = { workspace = true } ulid = { workspace = true } + +[dev-dependencies] +postcard = { workspace = true } diff --git a/crates/core/brahman-card/src/lib.rs b/crates/core/brahman-card/src/lib.rs index 7052f10..fb5e99e 100644 --- a/crates/core/brahman-card/src/lib.rs +++ b/crates/core/brahman-card/src/lib.rs @@ -18,11 +18,12 @@ #![forbid(unsafe_code)] #![warn(rust_2018_idioms)] -use std::collections::{BTreeSet, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::path::Path; use std::time::Duration; use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; use thiserror::Error; use ulid::Ulid; @@ -128,6 +129,14 @@ pub struct Card { /// Hijas a instanciar inmediatamente al encarnar esta Card. #[serde(default)] pub genesis: Vec, + + /// Campos JSON/TOML desconocidos preservados durante I/O de archivos + /// (forward-compat). **No se transmiten por wire (postcard)** — la + /// proyección a [`WireCard`] los descarta porque `serde_json::Value` + /// no es postcard-friendly. Sirven para anotaciones locales que + /// sobreviven leer/escribir Cards en disco. + #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] + pub extensions: BTreeMap, } impl Default for Card { @@ -150,6 +159,7 @@ impl Default for Card { priority: Priority::default(), flow: Flows::default(), genesis: Vec::new(), + extensions: BTreeMap::new(), } } } @@ -650,6 +660,93 @@ impl ResolvedCard { } } +// ===================================================================== +// WireCard — proyección postcard-friendly de Card +// ===================================================================== + +/// Forma de wire de [`Card`]: idéntica al schema rico **sin** el campo +/// `extensions` (incompatible con postcard porque `serde_json::Value` +/// usa secuencias/maps de longitud dinámica). +/// +/// Conversión: +/// - `WireCard::from(card)` descarta `extensions` y proyecta `genesis` +/// recursivamente. +/// - `Card::from(wire)` recupera todos los campos; `extensions` queda +/// vacío (la información de extensions no cruza el wire). +/// +/// Esta separación implementa el contrato: +/// - **JSON/TOML**: `Card` directa, con extensiones preservadas. +/// - **Wire (postcard)**: `WireCard`, sin extensiones. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WireCard { + pub schema_version: u16, + pub id: Ulid, + #[serde(default)] + pub lineage: Option, + pub label: String, + #[serde(default)] + pub provides: BTreeSet, + #[serde(default)] + pub requires: BTreeSet, + #[serde(default)] + pub permissions: Permissions, + #[serde(default)] + pub soma: SomaSpec, + pub payload: Payload, + pub supervision: Supervision, + #[serde(default)] + pub lifecycle: Lifecycle, + #[serde(default)] + pub priority: Priority, + #[serde(default)] + pub flow: Flows, + #[serde(default)] + pub genesis: Vec, +} + +impl From for WireCard { + fn from(c: Card) -> Self { + Self { + schema_version: c.schema_version, + id: c.id, + lineage: c.lineage, + label: c.label, + provides: c.provides, + requires: c.requires, + permissions: c.permissions, + soma: c.soma, + payload: c.payload, + supervision: c.supervision, + lifecycle: c.lifecycle, + priority: c.priority, + flow: c.flow, + genesis: c.genesis.into_iter().map(WireCard::from).collect(), + } + } +} + +impl From for Card { + fn from(w: WireCard) -> Self { + Self { + schema_version: w.schema_version, + id: w.id, + lineage: w.lineage, + label: w.label, + provides: w.provides, + requires: w.requires, + permissions: w.permissions, + soma: w.soma, + payload: w.payload, + supervision: w.supervision, + lifecycle: w.lifecycle, + priority: w.priority, + flow: w.flow, + genesis: w.genesis.into_iter().map(Card::from).collect(), + extensions: BTreeMap::new(), + } + } +} + // ===================================================================== // Tests // ===================================================================== @@ -827,4 +924,86 @@ mod tests { assert_eq!(c.priority, Priority::Normal); // default assert_eq!(c.flow.input.len(), 0); } + + #[test] + fn extensions_preserved_in_json_roundtrip() { + let src = r#"{ + "schema_version": 1, + "id": "01HQAR53D4M2NBV8KZTYXFGS01", + "label": "x", + "soma": { + "namespaces": {"mount":false,"pid":false,"net":false,"uts":false,"ipc":false,"user":false,"cgroup":false}, + "rlimits": {"mem_bytes":null,"nproc":null,"nofile":null}, + "cgroup": {"path":"x","cpu_weight":null,"io_weight":null}, + "cpu_affinity": null + }, + "payload": "Virtual", + "supervision": "OneShot", + "author": "sergio", + "tags": ["draft", "experimental"] + }"#; + let c = Card::from_json(src).unwrap(); + assert_eq!(c.extensions.get("author").and_then(|v| v.as_str()), Some("sergio")); + assert!(c.extensions.contains_key("tags")); + + // Roundtrip JSON: extensions deben re-emitirse. + let s = c.to_json_pretty().unwrap(); + let c2 = Card::from_json(&s).unwrap(); + assert_eq!(c2.extensions.get("author"), c.extensions.get("author")); + } + + #[test] + fn wire_card_roundtrip_strips_extensions() { + let src = r#"{ + "schema_version": 1, + "id": "01HQAR53D4M2NBV8KZTYXFGS01", + "label": "x", + "soma": { + "namespaces": {"mount":false,"pid":false,"net":false,"uts":false,"ipc":false,"user":false,"cgroup":false}, + "rlimits": {"mem_bytes":null,"nproc":null,"nofile":null}, + "cgroup": {"path":"x","cpu_weight":null,"io_weight":null}, + "cpu_affinity": null + }, + "payload": "Virtual", + "supervision": "OneShot", + "author": "sergio" + }"#; + let c = Card::from_json(src).unwrap(); + assert!(c.extensions.contains_key("author")); + + // Card → WireCard descarta extensions. + let wire: WireCard = c.into(); + assert_eq!(wire.label, "x"); + + // WireCard → Card → extensiones quedan vacías (se perdieron). + let c_back: Card = wire.into(); + assert_eq!(c_back.label, "x"); + assert!(c_back.extensions.is_empty(), "extensions sobreviven al wire"); + } + + #[test] + fn wire_card_postcard_friendly() { + // Validación: WireCard puede ser postcard-encoded sin error. + // Si Card tuviera extensions populadas, el encode rompería con + // "length of a sequence must be known". WireCard las descarta. + let src = r#"{ + "schema_version": 1, + "id": "01HQAR53D4M2NBV8KZTYXFGS01", + "label": "x", + "soma": { + "namespaces": {"mount":false,"pid":false,"net":false,"uts":false,"ipc":false,"user":false,"cgroup":false}, + "rlimits": {"mem_bytes":null,"nproc":null,"nofile":null}, + "cgroup": {"path":"x","cpu_weight":null,"io_weight":null}, + "cpu_affinity": null + }, + "payload": "Virtual", + "supervision": "OneShot", + "author": "sergio" + }"#; + let c = Card::from_json(src).unwrap(); + let wire: WireCard = c.into(); + let bytes = postcard::to_allocvec(&wire).expect("WireCard debe encodear"); + let decoded: WireCard = postcard::from_bytes(&bytes).expect("WireCard debe decodear"); + assert_eq!(decoded.label, "x"); + } } diff --git a/crates/core/brahman-handshake/src/client.rs b/crates/core/brahman-handshake/src/client.rs index cf79435..367e222 100644 --- a/crates/core/brahman-handshake/src/client.rs +++ b/crates/core/brahman-handshake/src/client.rs @@ -64,7 +64,7 @@ impl Client { let hello = Hello { schema_version: CARD_SCHEMA_VERSION, protocol_version: brahman_card::PROTOCOL_VERSION.to_string(), - card, + card: card.into(), // Card → WireCard: descarta extensions wit, }; write_frame(&mut stream, &Frame::Hello(hello)).await?; diff --git a/crates/core/brahman-handshake/src/messages.rs b/crates/core/brahman-handshake/src/messages.rs index 80423a1..bdce4dc 100644 --- a/crates/core/brahman-handshake/src/messages.rs +++ b/crates/core/brahman-handshake/src/messages.rs @@ -3,25 +3,26 @@ //! Todos los mensajes que cruzan el wire son variantes de [`Frame`]. use brahman_broker::MatchStrategy; -use brahman_card::{TypeRef, WitInterface}; +use brahman_card::{TypeRef, WireCard, WitInterface}; use serde::{Deserialize, Serialize}; use ulid::Ulid; /// Identificador de sesión emitido por el servidor en `HelloAck`. pub type SessionId = Ulid; -/// Saludo inicial del módulo. Lleva la Card completa para que el servidor -/// la valide e indexe. Opcionalmente, una `WitInterface` ya extraída — si -/// está presente, el módulo es "consciente" y el server lo registra como -/// `ResolvedCard::from_conscious`; si no, como `from_agnostic`. +/// Saludo inicial del módulo. Lleva la Card en forma `WireCard` +/// (postcard-friendly: sin extensiones JSON arbitrarias). El servidor +/// la convierte a `Card` para uso interno. Opcionalmente, una +/// `WitInterface` ya extraída — si está presente, el módulo es +/// "consciente" y el server lo registra como `ResolvedCard::from_conscious`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Hello { /// Versión del schema de Card que el cliente sigue. pub schema_version: u16, /// Versión del protocolo handshake del cliente. pub protocol_version: String, - /// Tarjeta de Presentación. - pub card: brahman_card::Card, + /// Tarjeta de Presentación, proyectada al wire. + pub card: WireCard, /// Interfaz WIT extraída por el cliente (típicamente con /// `brahman-card-wit`). `None` si el módulo es agnóstico. #[serde(default)] diff --git a/crates/core/brahman-handshake/src/server.rs b/crates/core/brahman-handshake/src/server.rs index 454dfc4..3220119 100644 --- a/crates/core/brahman-handshake/src/server.rs +++ b/crates/core/brahman-handshake/src/server.rs @@ -367,7 +367,9 @@ impl Session { } let session_id = Ulid::new(); - self.register_session(session_id, hello.card, hello.wit).await; + // WireCard → Card: extensiones quedan vacías post-wire (es el contrato). + let card: Card = hello.card.into(); + self.register_session(session_id, card, hello.wit).await; let ack = HelloAck { server_version: crate::HANDSHAKE_VERSION.to_string(), @@ -416,7 +418,10 @@ impl Session { brahman_card::PROTOCOL_VERSION ))); } - if let Err(e) = hello.card.validate() { + // Validamos contra Card (la rica) — convertir es barato y centraliza + // la lógica de validación en un solo lugar. + let as_card: Card = Card::from(hello.card.clone()); + if let Err(e) = as_card.validate() { return Some(HandshakeError::InvalidCard(e.to_string())); } None diff --git a/crates/core/brahman-handshake/tests/handshake.rs b/crates/core/brahman-handshake/tests/handshake.rs index 076b4ad..ac20ff9 100644 --- a/crates/core/brahman-handshake/tests/handshake.rs +++ b/crates/core/brahman-handshake/tests/handshake.rs @@ -123,7 +123,7 @@ async fn server_rejects_protocol_mismatch() { let hello = Hello { schema_version: CARD_SCHEMA_VERSION, protocol_version: "999.0.0".into(), - card: sample_card("future-module"), + card: sample_card("future-module").into(), wit: None, }; write_frame(&mut stream, &Frame::Hello(hello)).await.unwrap();