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 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) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-05-08 17:33:15 +00:00
parent 9420eae0b6
commit f19ca723b6
8 changed files with 238 additions and 12 deletions
+180 -1
View File
@@ -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<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 {
@@ -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<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
// =====================================================================
@@ -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");
}
}