feat(card): WireCard + extensions — forward-compat sin romper postcard
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<String, serde_json::Value> 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<WireCard> recursivo. Postcard-friendly por construcción. Conversiones From<Card> for WireCard (descarta extensions) y From<WireCard> 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 anterior9420eae). CHANGELOG.md actualizado con esta entrada y con el commit9420eae("probando" del usuario, limpieza de 17 warnings dead-code). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,43 @@ ratio/diff ver `git show <sha>`.
|
|||||||
|
|
||||||
## 2026-05-08
|
## 2026-05-08
|
||||||
|
|
||||||
|
### feat(card): WireCard + extensions — forward-compat sin romper postcard
|
||||||
|
- `Card.extensions: BTreeMap<String, serde_json::Value>` 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<WireCard>` recursivo). Conversiones `From<Card>` y
|
||||||
|
`From<WireCard>` 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
|
### feat(sidecar): WIT al sidecar — módulos conscientes vivos
|
||||||
- `brahman-card::WitInterface` deriva `Serialize`, `Deserialize`,
|
- `brahman-card::WitInterface` deriva `Serialize`, `Deserialize`,
|
||||||
`PartialEq`, `Eq` para cruzar el wire postcard.
|
`PartialEq`, `Eq` para cruzar el wire postcard.
|
||||||
|
|||||||
Generated
+1
@@ -1169,6 +1169,7 @@ dependencies = [
|
|||||||
name = "brahman-card"
|
name = "brahman-card"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"postcard",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
|||||||
@@ -14,3 +14,6 @@ serde_json = { workspace = true }
|
|||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
ulid = { workspace = true }
|
ulid = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
postcard = { workspace = true }
|
||||||
|
|||||||
@@ -18,11 +18,12 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
#![warn(rust_2018_idioms)]
|
#![warn(rust_2018_idioms)]
|
||||||
|
|
||||||
use std::collections::{BTreeSet, HashSet};
|
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value as JsonValue;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use ulid::Ulid;
|
use ulid::Ulid;
|
||||||
|
|
||||||
@@ -128,6 +129,14 @@ pub struct Card {
|
|||||||
/// Hijas a instanciar inmediatamente al encarnar esta Card.
|
/// Hijas a instanciar inmediatamente al encarnar esta Card.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub genesis: Vec<Card>,
|
pub genesis: Vec<Card>,
|
||||||
|
|
||||||
|
/// 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<String, JsonValue>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Card {
|
impl Default for Card {
|
||||||
@@ -150,6 +159,7 @@ impl Default for Card {
|
|||||||
priority: Priority::default(),
|
priority: Priority::default(),
|
||||||
flow: Flows::default(),
|
flow: Flows::default(),
|
||||||
genesis: Vec::new(),
|
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<Ulid>,
|
||||||
|
pub label: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub provides: BTreeSet<Capability>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub requires: BTreeSet<Capability>,
|
||||||
|
#[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<WireCard>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Card> 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<WireCard> 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
|
// Tests
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -827,4 +924,86 @@ mod tests {
|
|||||||
assert_eq!(c.priority, Priority::Normal); // default
|
assert_eq!(c.priority, Priority::Normal); // default
|
||||||
assert_eq!(c.flow.input.len(), 0);
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ impl Client {
|
|||||||
let hello = Hello {
|
let hello = Hello {
|
||||||
schema_version: CARD_SCHEMA_VERSION,
|
schema_version: CARD_SCHEMA_VERSION,
|
||||||
protocol_version: brahman_card::PROTOCOL_VERSION.to_string(),
|
protocol_version: brahman_card::PROTOCOL_VERSION.to_string(),
|
||||||
card,
|
card: card.into(), // Card → WireCard: descarta extensions
|
||||||
wit,
|
wit,
|
||||||
};
|
};
|
||||||
write_frame(&mut stream, &Frame::Hello(hello)).await?;
|
write_frame(&mut stream, &Frame::Hello(hello)).await?;
|
||||||
|
|||||||
@@ -3,25 +3,26 @@
|
|||||||
//! Todos los mensajes que cruzan el wire son variantes de [`Frame`].
|
//! Todos los mensajes que cruzan el wire son variantes de [`Frame`].
|
||||||
|
|
||||||
use brahman_broker::MatchStrategy;
|
use brahman_broker::MatchStrategy;
|
||||||
use brahman_card::{TypeRef, WitInterface};
|
use brahman_card::{TypeRef, WireCard, WitInterface};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use ulid::Ulid;
|
use ulid::Ulid;
|
||||||
|
|
||||||
/// Identificador de sesión emitido por el servidor en `HelloAck`.
|
/// Identificador de sesión emitido por el servidor en `HelloAck`.
|
||||||
pub type SessionId = Ulid;
|
pub type SessionId = Ulid;
|
||||||
|
|
||||||
/// Saludo inicial del módulo. Lleva la Card completa para que el servidor
|
/// Saludo inicial del módulo. Lleva la Card en forma `WireCard`
|
||||||
/// la valide e indexe. Opcionalmente, una `WitInterface` ya extraída — si
|
/// (postcard-friendly: sin extensiones JSON arbitrarias). El servidor
|
||||||
/// está presente, el módulo es "consciente" y el server lo registra como
|
/// la convierte a `Card` para uso interno. Opcionalmente, una
|
||||||
/// `ResolvedCard::from_conscious`; si no, como `from_agnostic`.
|
/// `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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Hello {
|
pub struct Hello {
|
||||||
/// Versión del schema de Card que el cliente sigue.
|
/// Versión del schema de Card que el cliente sigue.
|
||||||
pub schema_version: u16,
|
pub schema_version: u16,
|
||||||
/// Versión del protocolo handshake del cliente.
|
/// Versión del protocolo handshake del cliente.
|
||||||
pub protocol_version: String,
|
pub protocol_version: String,
|
||||||
/// Tarjeta de Presentación.
|
/// Tarjeta de Presentación, proyectada al wire.
|
||||||
pub card: brahman_card::Card,
|
pub card: WireCard,
|
||||||
/// Interfaz WIT extraída por el cliente (típicamente con
|
/// Interfaz WIT extraída por el cliente (típicamente con
|
||||||
/// `brahman-card-wit`). `None` si el módulo es agnóstico.
|
/// `brahman-card-wit`). `None` si el módulo es agnóstico.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|||||||
@@ -367,7 +367,9 @@ impl Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let session_id = Ulid::new();
|
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 {
|
let ack = HelloAck {
|
||||||
server_version: crate::HANDSHAKE_VERSION.to_string(),
|
server_version: crate::HANDSHAKE_VERSION.to_string(),
|
||||||
@@ -416,7 +418,10 @@ impl Session {
|
|||||||
brahman_card::PROTOCOL_VERSION
|
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()));
|
return Some(HandshakeError::InvalidCard(e.to_string()));
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ async fn server_rejects_protocol_mismatch() {
|
|||||||
let hello = Hello {
|
let hello = Hello {
|
||||||
schema_version: CARD_SCHEMA_VERSION,
|
schema_version: CARD_SCHEMA_VERSION,
|
||||||
protocol_version: "999.0.0".into(),
|
protocol_version: "999.0.0".into(),
|
||||||
card: sample_card("future-module"),
|
card: sample_card("future-module").into(),
|
||||||
wit: None,
|
wit: None,
|
||||||
};
|
};
|
||||||
write_frame(&mut stream, &Frame::Hello(hello)).await.unwrap();
|
write_frame(&mut stream, &Frame::Hello(hello)).await.unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user