tawasuyu

tawasuyu/brahman

Rust 463TOML 180Markdown 49JSON 176353 Sutichaykuna

⎇ main crates/protocol/brahman-card/src/lib.rs Rust · 1290 siq'ikuna · 44.5 KB Ñawpa willay Pi imata llamkarqan ch'uya

refactor(naming): A1 — ente→arje, vista→revista, pluma→fana b83d40a83 · sergio ·

1//! `brahman-card` — Tarjeta de Presentación canónica de Brahman.
2//!
3//! Híbrida del `EntityCard` de arje (identidad ULID, capacidades tipadas,
4//! `Payload`/`SomaSpec`/`Supervision`/`genesis` recursivo) con flujos tipados,
5//! permisos enumerados explícitos y nivel de confianza derivado del modelo
6//! que veníamos diseñando en `core_protocol`. Una sola tarjeta sirve a:
7//!
8//! - **El Init** (encarnación): `payload` + `soma` + `supervision` + `genesis`.
9//! - **El Admin** (matching): `provides`/`requires` + `flow` + `permissions`.
10//! - **El runtime** (sandbox): `permissions` enumerados → seccomp / namespaces.
11//!
12//! Forward-compat: cualquier campo desconocido se preserva en `extensions`
13//! (raíz) o en `extra` (sub-secciones).
14//!
15//! Formatos soportados: JSON (canónico, compatible con arje) y TOML
16//! (humano-legible). Auto-detección por extensión.
17
18#![forbid(unsafe_code)]
19#![warn(rust_2018_idioms)]
20
21use std::collections::{BTreeMap, BTreeSet, HashSet};
22use std::path::{Path, PathBuf};
23use std::time::Duration;
24
25use serde::{Deserialize, Serialize};
26use serde_json::Value as JsonValue;
27use thiserror::Error;
28use ulid::Ulid;
29
30// Re-export para que los consumidores no necesiten depender de `ulid`
31// directamente.
32pub use ::ulid;
33
34/// Versión del esquema de la Card.
35pub const CARD_SCHEMA_VERSION: u16 = 1;
36
37/// Versión del protocolo Brahman.
38pub const PROTOCOL_VERSION: &str = "0.1.0";
39
40/// Errores de parseo o validación de la Card.
41#[derive(Debug, Error)]
42pub enum CardError {
43    #[error("schema version mismatch: got {got}, expected {expected}")]
44    SchemaMismatch { got: u16, expected: u16 },
45    #[error("label vacío")]
46    EmptyLabel,
47    #[error("label demasiado largo: {0} bytes (máx 256)")]
48    LabelTooLong(usize),
49    #[error("capacidad presente en provides Y requires: {0:?}")]
50    SelfDependency(Capability),
51    #[error("payload Native/Legacy con exec vacío")]
52    EmptyExec,
53    #[error("payload Wasm con sha256 sentinela (todo ceros)")]
54    SentinelWasmHash,
55    #[error("rlimit inválido: {0}")]
56    InvalidRlimit(&'static str),
57    #[error("cgroup weight fuera de [1,10000]: {0}")]
58    InvalidCgroupWeight(&'static str),
59    #[error("flujo {section}: nombre duplicado '{name}'")]
60    DuplicateFlowName {
61        section: &'static str,
62        name: String,
63    },
64    #[error("JSON inválido: {0}")]
65    Json(#[from] serde_json::Error),
66    #[error("TOML inválido: {0}")]
67    Toml(#[from] toml::de::Error),
68    #[error("E/S leyendo card: {0}")]
69    Io(#[from] std::io::Error),
70    #[error("formato desconocido (extensiones esperadas: .json, .toml)")]
71    UnknownFormat,
72}
73
74// =====================================================================
75// Card raíz
76// =====================================================================
77
78/// Tarjeta de Presentación de un módulo Brahman.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct Card {
81    /// Versión del esquema. Cambiar = romper compatibilidad del fractal.
82    pub schema_version: u16,
83
84    /// Identidad opaca, única en el grafo del fractal.
85    pub id: Ulid,
86
87    /// Ancestro del que esta Card desciende (genealogía).
88    #[serde(default)]
89    pub lineage: Option<Ulid>,
90
91    /// Nombre humano-legible. Único por convención, no por validación.
92    pub label: String,
93
94    /// Capacidades del sistema que esta Card ofrece a otros.
95    #[serde(default)]
96    pub provides: BTreeSet<Capability>,
97
98    /// Capacidades que necesita resolver el Init antes de encarnarla.
99    #[serde(default)]
100    pub requires: BTreeSet<Capability>,
101
102    /// Permisos sandbox declarativos (más alto nivel que `Capability`).
103    /// El Admin los compila a seccomp/namespaces/cgroups concretos.
104    #[serde(default)]
105    pub permissions: Permissions,
106
107    /// Spec runtime Linux (namespaces, cgroups, rlimits, cpu_affinity).
108    #[serde(default)]
109    pub soma: SomaSpec,
110
111    /// Qué encarnar: WASM, ELF nativo, virtual, o legacy con shims.
112    pub payload: Payload,
113
114    /// Política de supervisión (restart con backoff, oneshot, delegada).
115    pub supervision: Supervision,
116
117    /// Modelo de ejecución (eje ortogonal a `supervision`).
118    #[serde(default)]
119    pub lifecycle: Lifecycle,
120
121    /// Prioridad de scheduling.
122    #[serde(default)]
123    pub priority: Priority,
124
125    /// Contratos de flujo de datos: qué consume, qué produce.
126    #[serde(default)]
127    pub flow: Flows,
128
129    /// Si la entidad expone un socket Unix de servicio (data plane,
130    /// distinto al socket del Init), declara aquí su path. Los
131    /// consumidores que reciban un `MatchEvent` con este Card como
132    /// productor pueden conectar directo al socket sin discovery
133    /// adicional.
134    #[serde(default)]
135    pub service_socket: Option<PathBuf>,
136
137    /// Referencias a otras Cards: "soy procesado por X", "poseo Y",
138    /// etc. Forma el grafo de relaciones del fractal. Cada Card las
139    /// declara unilateralmente; los consumidores pueden cruzarlas para
140    /// reconstruir vínculos bidireccionales.
141    #[serde(default)]
142    pub references: Vec<CardReference>,
143
144    /// Naturaleza de la entidad detrás de la Card. Por defecto `Ente`
145    /// para mantener compatibilidad con Cards existentes.
146    #[serde(default)]
147    pub kind: CardKind,
148
149    /// Faceta de datos cuando `kind != Ente`. `None` para entes
150    /// runtime; `Some(...)` para Mónadas, índices, etc.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub data: Option<DataFacet>,
153
154    /// Hijas a instanciar inmediatamente al encarnar esta Card.
155    #[serde(default)]
156    pub genesis: Vec<Card>,
157
158    /// Biases per-contexto. La key es el nombre del contexto (p. ej.
159    /// `"test"`, `"prod"`, `"foreground"`). Cuando el broker está
160    /// configurado bajo ese contexto, el bias se aplica. Sin contexto
161    /// activo o sin entrada matching, este campo no afecta el ranking.
162    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
163    pub priority_contexts: BTreeMap<String, ContextBias>,
164
165    /// Campos JSON/TOML desconocidos preservados durante I/O de archivos
166    /// (forward-compat). **No se transmiten por wire (postcard)** — la
167    /// proyección a [`WireCard`] los descarta porque `serde_json::Value`
168    /// no es postcard-friendly. Sirven para anotaciones locales que
169    /// sobreviven leer/escribir Cards en disco.
170    #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")]
171    pub extensions: BTreeMap<String, JsonValue>,
172}
173
174impl Default for Card {
175    /// Default determinista pensado para el patrón `..Default::default()`
176    /// en struct-literals donde el caller sobreescribe `id` y `label`.
177    ///
178    /// **Trap conocida**: `id` queda en `Ulid::nil()`. Si construís una
179    /// Card "viva" para registrar en el broker, NUNCA dejes el `id`
180    /// derivado de `Default` — todas las Cards default-construidas
181    /// colisionarían bajo el mismo `00000000000000000000000000`. Para
182    /// Cards frescas usá [`Card::new`], que asigna `Ulid::new()`.
183    /// `Ulid::nil()` queda reservado para patterns de búsqueda y
184    /// sentinel values en serialización.
185    fn default() -> Self {
186        Self {
187            schema_version: CARD_SCHEMA_VERSION,
188            id: Ulid::nil(),
189            lineage: None,
190            label: String::new(),
191            provides: BTreeSet::new(),
192            requires: BTreeSet::new(),
193            permissions: Permissions::default(),
194            soma: SomaSpec::default(),
195            payload: Payload::Virtual,
196            supervision: Supervision::OneShot,
197            lifecycle: Lifecycle::default(),
198            priority: Priority::default(),
199            flow: Flows::default(),
200            genesis: Vec::new(),
201            service_socket: None,
202            references: Vec::new(),
203            kind: CardKind::default(),
204            data: None,
205            priority_contexts: BTreeMap::new(),
206            extensions: BTreeMap::new(),
207        }
208    }
209}
210
211// =====================================================================
212// Capacidades — heredadas de arje, tipadas, no strings
213// =====================================================================
214
215/// Capacidad del sistema. Identificadores tipados, no strings libres.
216#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
217pub enum Capability {
218    /// Provee un punto de montaje root para Cards hijas.
219    FilesystemRoot,
220    /// Acceso a una familia netlink del kernel.
221    KernelNetlink(NetlinkFamily),
222    /// Endpoint del bus interno — equivalente tipado de un nombre D-Bus.
223    Endpoint {
224        interface: InterfaceId,
225        version: u16,
226    },
227    /// Reemplazo del shim de systemd-logind. Solo el ente compat lo provee.
228    LegacyLogind,
229    /// Acceso crudo a una clase de dispositivo. Capacidad escalada.
230    Device { class: DeviceClass },
231    /// Permiso de instanciar Cards hijas. Por defecto solo PID 1 lo tiene.
232    Spawn,
233    /// Acceso a logging estructurado del fractal.
234    Journal,
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
238pub enum NetlinkFamily {
239    Uevent,
240    Route,
241    Generic,
242    Audit,
243}
244
245#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
246pub enum DeviceClass {
247    Block,
248    Tty,
249    Input,
250    Drm,
251    Net,
252    Hidraw,
253}
254
255/// Identificador de interfaz del bus interno (UUID, no string libre).
256/// Para extender el protocolo, se genera un UUID nuevo y se versiona.
257#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
258pub struct InterfaceId(pub [u8; 16]);
259
260// =====================================================================
261// Permisos sandbox — más alto nivel que Capability
262// =====================================================================
263
264/// Permisos declarativos. El Admin los traduce a seccomp/namespaces.
265#[derive(Debug, Clone, Default, Serialize, Deserialize)]
266pub struct Permissions {
267    #[serde(default)]
268    pub networking: NetworkingPolicy,
269    #[serde(default)]
270    pub filesystem: FsPolicy,
271    #[serde(default)]
272    pub ipc: IpcPolicy,
273    /// Capacidad de spawnear sub-procesos. Implica `TrustLevel::System`.
274    #[serde(default)]
275    pub processes: bool,
276}
277
278#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
279#[serde(rename_all = "kebab-case")]
280pub enum NetworkingPolicy {
281    #[default]
282    None,
283    Loopback,
284    Outbound,
285    Full,
286}
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
289#[serde(rename_all = "kebab-case")]
290pub enum FsPolicy {
291    #[default]
292    None,
293    ReadOnly,
294    ReadWrite,
295}
296
297#[derive(Debug, Clone, Default, Serialize, Deserialize)]
298pub struct IpcPolicy {
299    /// Protocolos IPC permitidos (p. ej. `"wit-v1"`, `"shm-v1"`).
300    #[serde(default)]
301    pub allow: Vec<String>,
302}
303
304// =====================================================================
305// SomaSpec — runtime Linux (heredado de arje sin cambios)
306// =====================================================================
307
308#[derive(Debug, Clone, Default, Serialize, Deserialize)]
309pub struct SomaSpec {
310    pub namespaces: NamespaceSet,
311    pub rlimits: ResourceLimits,
312    pub cgroup: CgroupSpec,
313    pub cpu_affinity: Option<Vec<u32>>,
314}
315
316#[derive(Debug, Clone, Default, Serialize, Deserialize)]
317pub struct NamespaceSet {
318    pub mount: bool,
319    pub pid: bool,
320    pub net: bool,
321    pub uts: bool,
322    pub ipc: bool,
323    pub user: bool,
324    pub cgroup: bool,
325}
326
327#[derive(Debug, Clone, Default, Serialize, Deserialize)]
328pub struct ResourceLimits {
329    pub mem_bytes: Option<u64>,
330    pub nproc: Option<u32>,
331    pub nofile: Option<u32>,
332}
333
334#[derive(Debug, Clone, Default, Serialize, Deserialize)]
335pub struct CgroupSpec {
336    #[serde(default)]
337    pub path: String,
338    pub cpu_weight: Option<u32>,
339    pub io_weight: Option<u32>,
340}
341
342// =====================================================================
343// Payload — qué encarnar (heredado de arje)
344// =====================================================================
345
346#[derive(Debug, Clone, Serialize, Deserialize)]
347pub enum Payload {
348    Wasm {
349        module_sha256: [u8; 32],
350        entry: String,
351    },
352    Native {
353        exec: String,
354        argv: Vec<String>,
355        envp: Vec<(String, String)>,
356    },
357    /// Sin proceso. Nodo lógico del grafo (agregadores, mediators).
358    Virtual,
359    /// Wrapper de daemon legacy. `fakes` activa shims D-Bus / sd_notify.
360    Legacy {
361        exec: String,
362        argv: Vec<String>,
363        fakes: BTreeSet<LegacyFacade>,
364    },
365}
366
367#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
368pub enum LegacyFacade {
369    SystemdLogind,
370    SystemdHostnamed,
371    SystemdNotify,
372}
373
374// =====================================================================
375// Supervisión (heredada de arje)
376// =====================================================================
377
378#[derive(Debug, Clone, Serialize, Deserialize)]
379pub enum Supervision {
380    Restart {
381        #[serde(with = "duration_millis")]
382        initial: Duration,
383        #[serde(with = "duration_millis")]
384        max: Duration,
385    },
386    OneShot,
387    Delegate,
388}
389
390mod duration_millis {
391    use serde::{Deserialize, Deserializer, Serializer};
392    use std::time::Duration;
393
394    pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
395        s.serialize_u64(d.as_millis() as u64)
396    }
397
398    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
399        let ms = u64::deserialize(d)?;
400        Ok(Duration::from_millis(ms))
401    }
402}
403
404// =====================================================================
405// Lifecycle / Priority (del modelo brahman)
406// =====================================================================
407
408/// Modelo de ejecución (rol). Ortogonal a `Supervision` (política de restart).
409#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
410#[serde(rename_all = "lowercase")]
411pub enum Lifecycle {
412    /// Servicio de larga duración.
413    #[default]
414    Daemon,
415    /// Una sola ejecución; sale al terminar su tarea.
416    Oneshot,
417    /// Componente UI gestionado por el motor de widgets.
418    Widget,
419}
420
421/// Tipo de relación entre dos Cards.
422#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
423#[serde(rename_all = "kebab-case")]
424pub enum RelationshipKind {
425    /// Esta Card administra/posee al target (Ente sobre Mónada).
426    Owns,
427    /// Esta Card es administrada/poseída por el target (Mónada bajo Ente).
428    OwnedBy,
429    /// Esta Card procesa al target (Ente que consume Mónada).
430    Processes,
431    /// Esta Card es procesada por el target (Mónada siendo consumida).
432    ProcessedBy,
433    /// Relación lateral genérica.
434    Sibling,
435}
436
437/// Referencia desde una Card a otra. Forma el grafo de relaciones del
438/// fractal: "el Engine X posee la Mónada Y", "el Worker A procesa la
439/// Tarea B", etc.
440///
441/// Es responsabilidad del que declara mantener `target_id` apuntando a
442/// una Card que existe (o existió) en el ecosistema. El `target_label`
443/// es redundante con el lookup en runtime, pero se incluye para que la
444/// UI pueda renderear sin resolver.
445#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
446pub struct CardReference {
447    pub kind: RelationshipKind,
448    pub target_id: Ulid,
449    /// Label humano del target en el momento de declararse la
450    /// referencia (cache; el target real puede haber cambiado de label).
451    #[serde(default)]
452    pub target_label: String,
453}
454
455/// Naturaleza de la entidad detrás de la Card.
456///
457/// La función de presentarse es la misma para todos: tener identidad,
458/// resumen, capacidades, y poder ser encontrada por otros. Pero NO todas
459/// las entidades son procesos — algunas son agrupaciones de datos
460/// (Mónadas de Nouser, índices, streams).
461///
462/// El kind permite a consumidores (UI, broker, observadores) discriminar
463/// sólo cuando importa, pero todos hablan el mismo protocolo de Card.
464#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
465#[serde(rename_all = "lowercase")]
466pub enum CardKind {
467    /// Entidad runtime con `payload`/`soma`/`supervision` activos
468    /// (proceso, módulo, daemon).
469    #[default]
470    Ente,
471    /// Agrupación de datos sin proceso detrás (Mónadas Nouser, índices,
472    /// resultados cacheados). `payload` típicamente `Virtual`.
473    Data,
474}
475
476/// Faceta de datos: campos relevantes cuando `Card.kind != Ente`.
477///
478/// Optimizada para el wire — incluye sólo metadatos de presentación, NO
479/// listas pesadas (los miembros, embeddings completos, etc. se consultan
480/// al daemon dueño bajo demanda). El "presentation_hint" es un string
481/// libre que la UI mapea a su lente (p. ej. `"code"` → editor de código).
482#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
483pub struct DataFacet {
484    /// Resumen humano (1-2 oraciones). Generado por el daemon dueño.
485    #[serde(default)]
486    pub summary: String,
487    /// Tokens dominantes / palabras clave (5-10 típicamente).
488    #[serde(default)]
489    pub keywords: Vec<String>,
490    /// Centroide vectorial. Vacío si no hay embeddings calculados.
491    #[serde(default)]
492    pub centroid: Vec<f32>,
493    /// Cantidad de elementos contenidos (archivos, registros, ...).
494    #[serde(default)]
495    pub member_count: u32,
496    /// Métrica de dispersión interna [0, 1] (típicamente entropía).
497    #[serde(default)]
498    pub dispersion: f32,
499    /// Hint de presentación. Strings libres como `"code"`, `"gallery"`,
500    /// `"markdown"`, `"database"`, `"grid"`, `"tree"`. La UI los mapea.
501    #[serde(default)]
502    pub presentation_hint: String,
503}
504
505/// Prioridad de scheduling. Orden: `Low < Normal < High < Critical` —
506/// usable como tiebreaker en el broker (mayor priority gana).
507#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize)]
508#[serde(rename_all = "lowercase")]
509pub enum Priority {
510    Low,
511    #[default]
512    Normal,
513    High,
514    Critical,
515}
516
517/// Override per-contexto sobre los matches del broker.
518///
519/// La Card declara biases bajo `priority_contexts.<nombre>` que se
520/// activan cuando el broker corre bajo ese contexto. Aplicación según rol:
521///
522/// - **Como consumidor**: `pin_to` sobreescribe el `pin_to` estático del
523///   `Flow.pin_to` durante la búsqueda de productores.
524/// - **Como productor**: `priority_offset` se suma a la priority base
525///   (saturando en `[Low, Critical]`) para el ranking de candidatos.
526///
527/// Casos de uso típicos: test↔prod (mock vs real), foreground↔background
528/// (latencia vs costo), trust gates (sólo productores con cierto nivel).
529#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
530pub struct ContextBias {
531    /// Override del `pin_to` estático cuando el broker está en este
532    /// contexto y la Card actúa como consumidor.
533    ///
534    /// **No se usa `skip_serializing_if` aquí**: postcard requiere
535    /// layout fijo. La verbosidad extra en JSON (campos null/cero
536    /// emitidos) es el costo aceptado para compatibilidad de wire.
537    #[serde(default)]
538    pub pin_to: Option<String>,
539
540    /// Modifica la priority efectiva del Card como productor.
541    /// `+1` lo eleva, `-1` lo baja. El resultado se clampa al rango de
542    /// `Priority` ([Low, Critical]).
543    #[serde(default)]
544    pub priority_offset: i8,
545}
546
547// =====================================================================
548// Flujos tipados (del modelo brahman)
549// =====================================================================
550
551#[derive(Debug, Clone, Default, Serialize, Deserialize)]
552pub struct Flows {
553    #[serde(default)]
554    pub input: Vec<Flow>,
555    #[serde(default)]
556    pub output: Vec<Flow>,
557}
558
559#[derive(Debug, Clone, Serialize, Deserialize)]
560pub struct Flow {
561    /// Nombre único dentro de su dirección.
562    pub name: String,
563    /// Tipo de los datos que viajan por el flujo.
564    #[serde(rename = "type")]
565    pub ty: TypeRef,
566    /// Sugerencia de productor/consumidor concreto. El broker la respeta
567    /// como pista; cae en matching por tipo si no es resoluble.
568    #[serde(default)]
569    pub pin_to: Option<String>,
570}
571
572/// Referencia a un tipo, discriminada para distinguir primitivas de tipos WIT.
573///
574/// **Wire format (JSON / TOML / postcard):** externally-tagged. Ejemplo JSON:
575/// ```json
576/// { "primitive": { "name": "string" } }
577/// { "wit": { "package": "brahman:dht", "name": "entity-result" } }
578/// ```
579/// Se eligió externally-tagged por compatibilidad con postcard, que no
580/// soporta `#[serde(tag = "...")]` (internally-tagged) en formatos no
581/// self-describing.
582#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
583#[serde(rename_all = "lowercase")]
584pub enum TypeRef {
585    /// Tipo primitivo del runtime.
586    Primitive { name: String },
587    /// Tipo declarado en un paquete WIT.
588    Wit {
589        package: String,
590        #[serde(default)]
591        interface: Option<String>,
592        name: String,
593    },
594}
595
596// =====================================================================
597// API: parseo y validación
598// =====================================================================
599
600impl Card {
601    /// Construye una Card "viva" lista para registrarse en el broker:
602    /// `id = Ulid::new()` (único), `label` provisto, todo lo demás en
603    /// los defaults seguros (Payload::Virtual, Supervision::OneShot,
604    /// CardKind::Ente, etc.).
605    ///
606    /// Diseñada para usarse en struct-literals con override parcial,
607    /// igual que `Default` pero sin la trap de `Ulid::nil()`:
608    ///
609    /// ```ignore
610    /// let card = Card {
611    ///     kind: CardKind::Data,
612    ///     payload: Payload::Embedded(serde_json::json!({"foo": 1})),
613    ///     ..Card::new("mi-modulo.algo")
614    /// };
615    /// ```
616    ///
617    /// Para Cards de búsqueda/sentinel donde `nil` es semánticamente
618    /// significativo, usá `Card::default()` directamente.
619    pub fn new(label: impl Into<String>) -> Self {
620        Self {
621            id: Ulid::new(),
622            label: label.into(),
623            ..Self::default()
624        }
625    }
626
627    /// Deserializa una Card desde JSON y valida.
628    pub fn from_json(src: &str) -> Result<Self, CardError> {
629        let c: Self = serde_json::from_str(src)?;
630        c.validate()?;
631        Ok(c)
632    }
633
634    /// Deserializa una Card desde TOML y valida.
635    pub fn from_toml(src: &str) -> Result<Self, CardError> {
636        let c: Self = toml::from_str(src)?;
637        c.validate()?;
638        Ok(c)
639    }
640
641    /// Carga una Card desde disco. Auto-detecta formato por extensión
642    /// (`.json` o `.toml`).
643    pub fn from_path(path: impl AsRef<Path>) -> Result<Self, CardError> {
644        let p = path.as_ref();
645        let src = std::fs::read_to_string(p)?;
646        match p.extension().and_then(|e| e.to_str()) {
647            Some("json") => Self::from_json(&src),
648            Some("toml") => Self::from_toml(&src),
649            _ => Err(CardError::UnknownFormat),
650        }
651    }
652
653    /// Re-serializa la Card a JSON con indentación.
654    pub fn to_json_pretty(&self) -> Result<String, CardError> {
655        Ok(serde_json::to_string_pretty(self)?)
656    }
657
658    /// Validación semántica exhaustiva, recursiva sobre `genesis`.
659    pub fn validate(&self) -> Result<(), CardError> {
660        if self.schema_version != CARD_SCHEMA_VERSION {
661            return Err(CardError::SchemaMismatch {
662                got: self.schema_version,
663                expected: CARD_SCHEMA_VERSION,
664            });
665        }
666        if self.label.is_empty() {
667            return Err(CardError::EmptyLabel);
668        }
669        if self.label.len() > 256 {
670            return Err(CardError::LabelTooLong(self.label.len()));
671        }
672        for cap in &self.requires {
673            if self.provides.contains(cap) {
674                return Err(CardError::SelfDependency(cap.clone()));
675            }
676        }
677        validate_payload(&self.payload)?;
678        validate_rlimits(&self.soma.rlimits)?;
679        validate_cgroup(&self.soma.cgroup)?;
680        check_unique_flow_names(&self.flow.input, "flow.input")?;
681        check_unique_flow_names(&self.flow.output, "flow.output")?;
682        for child in &self.genesis {
683            child.validate()?;
684        }
685        Ok(())
686    }
687}
688
689fn validate_payload(p: &Payload) -> Result<(), CardError> {
690    match p {
691        Payload::Native { exec, .. } | Payload::Legacy { exec, .. } => {
692            if exec.trim().is_empty() {
693                return Err(CardError::EmptyExec);
694            }
695        }
696        Payload::Wasm { module_sha256, .. } => {
697            if module_sha256.iter().all(|&b| b == 0) {
698                return Err(CardError::SentinelWasmHash);
699            }
700        }
701        Payload::Virtual => {}
702    }
703    Ok(())
704}
705
706fn validate_rlimits(rl: &ResourceLimits) -> Result<(), CardError> {
707    if let Some(m) = rl.mem_bytes {
708        if m == 0 {
709            return Err(CardError::InvalidRlimit("mem_bytes=0"));
710        }
711        if m > 1u64 << 40 {
712            return Err(CardError::InvalidRlimit("mem_bytes>1TiB"));
713        }
714    }
715    if let Some(n) = rl.nproc {
716        if n == 0 || n > 65535 {
717            return Err(CardError::InvalidRlimit("nproc fuera de [1,65535]"));
718        }
719    }
720    if let Some(n) = rl.nofile {
721        if n == 0 || n > 1_048_576 {
722            return Err(CardError::InvalidRlimit("nofile fuera de [1,1M]"));
723        }
724    }
725    Ok(())
726}
727
728fn validate_cgroup(cg: &CgroupSpec) -> Result<(), CardError> {
729    if let Some(w) = cg.cpu_weight {
730        if !(1..=10000).contains(&w) {
731            return Err(CardError::InvalidCgroupWeight("cpu_weight"));
732        }
733    }
734    if let Some(w) = cg.io_weight {
735        if !(1..=10000).contains(&w) {
736            return Err(CardError::InvalidCgroupWeight("io_weight"));
737        }
738    }
739    Ok(())
740}
741
742fn check_unique_flow_names(flows: &[Flow], section: &'static str) -> Result<(), CardError> {
743    let mut seen = HashSet::new();
744    for f in flows {
745        if !seen.insert(f.name.as_str()) {
746            return Err(CardError::DuplicateFlowName {
747                section,
748                name: f.name.clone(),
749            });
750        }
751    }
752    Ok(())
753}
754
755// =====================================================================
756// Trust derivado
757// =====================================================================
758
759/// Nivel de confianza derivado de los permisos. **No es un campo declarado** —
760/// se calcula. Una sola fuente de verdad: lo que el Admin concede.
761#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
762pub enum TrustLevel {
763    /// Sin permisos — sandbox total.
764    Untrusted,
765    /// Permisos menores (loopback, FS read-only, IPC).
766    Sandboxed,
767    /// Permisos amplios (red saliente o FS read-write).
768    Privileged,
769    /// Capacidad de spawnear procesos.
770    System,
771}
772
773impl TrustLevel {
774    /// Política de derivación:
775    /// - `processes = true` ⇒ `System`.
776    /// - FS `read-write` o networking `outbound`/`full` ⇒ `Privileged`.
777    /// - FS `read-only`, networking `loopback`, o cualquier IPC ⇒ `Sandboxed`.
778    /// - Sin permisos ⇒ `Untrusted`.
779    pub fn derive(p: &Permissions) -> Self {
780        if p.processes {
781            return Self::System;
782        }
783        if matches!(p.filesystem, FsPolicy::ReadWrite)
784            || matches!(
785                p.networking,
786                NetworkingPolicy::Outbound | NetworkingPolicy::Full
787            )
788        {
789            return Self::Privileged;
790        }
791        if matches!(p.filesystem, FsPolicy::ReadOnly)
792            || matches!(p.networking, NetworkingPolicy::Loopback)
793            || !p.ipc.allow.is_empty()
794        {
795            return Self::Sandboxed;
796        }
797        Self::Untrusted
798    }
799}
800
801// =====================================================================
802// Identidad runtime (Card + WIT extraído + trust)
803// =====================================================================
804
805/// Resumen de la interfaz WIT extraída del componente WASM/WIT.
806/// Vacío para módulos agnósticos (sin contrato WIT).
807#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
808pub struct WitInterface {
809    pub package: String,
810    pub world: String,
811    pub exports: Vec<String>,
812    pub imports: Vec<String>,
813}
814
815/// Card resuelta a runtime: schema + WIT opcional + trust derivado.
816/// Es lo que el Admin indexa.
817#[derive(Debug, Clone)]
818pub struct ResolvedCard {
819    pub card: Card,
820    /// `Some` si el módulo es consciente (expone WIT), `None` si es agnóstico.
821    pub wit: Option<WitInterface>,
822    pub trust: TrustLevel,
823}
824
825impl ResolvedCard {
826    /// Construye una Card resuelta sin información WIT.
827    pub fn from_agnostic(card: Card) -> Self {
828        let trust = TrustLevel::derive(&card.permissions);
829        Self {
830            card,
831            wit: None,
832            trust,
833        }
834    }
835
836    /// Construye una Card resuelta con interfaz WIT extraída.
837    pub fn from_conscious(card: Card, wit: WitInterface) -> Self {
838        let trust = TrustLevel::derive(&card.permissions);
839        Self {
840            card,
841            wit: Some(wit),
842            trust,
843        }
844    }
845}
846
847// =====================================================================
848// WireCard — proyección postcard-friendly de Card
849// =====================================================================
850
851/// Forma de wire de [`Card`]: idéntica al schema rico **sin** el campo
852/// `extensions` (incompatible con postcard porque `serde_json::Value`
853/// usa secuencias/maps de longitud dinámica).
854///
855/// Conversión:
856/// - `WireCard::from(card)` descarta `extensions` y proyecta `genesis`
857///   recursivamente.
858/// - `Card::from(wire)` recupera todos los campos; `extensions` queda
859///   vacío (la información de extensions no cruza el wire).
860///
861/// Esta separación implementa el contrato:
862/// - **JSON/TOML**: `Card` directa, con extensiones preservadas.
863/// - **Wire (postcard)**: `WireCard`, sin extensiones.
864#[derive(Debug, Clone, Serialize, Deserialize)]
865pub struct WireCard {
866    pub schema_version: u16,
867    pub id: Ulid,
868    #[serde(default)]
869    pub lineage: Option<Ulid>,
870    pub label: String,
871    #[serde(default)]
872    pub provides: BTreeSet<Capability>,
873    #[serde(default)]
874    pub requires: BTreeSet<Capability>,
875    #[serde(default)]
876    pub permissions: Permissions,
877    #[serde(default)]
878    pub soma: SomaSpec,
879    pub payload: Payload,
880    pub supervision: Supervision,
881    #[serde(default)]
882    pub lifecycle: Lifecycle,
883    #[serde(default)]
884    pub priority: Priority,
885    #[serde(default)]
886    pub flow: Flows,
887    #[serde(default)]
888    pub genesis: Vec<WireCard>,
889    #[serde(default)]
890    pub service_socket: Option<PathBuf>,
891    #[serde(default)]
892    pub references: Vec<CardReference>,
893    #[serde(default)]
894    pub kind: CardKind,
895    #[serde(default)]
896    pub data: Option<DataFacet>,
897    #[serde(default)]
898    pub priority_contexts: BTreeMap<String, ContextBias>,
899}
900
901impl From<Card> for WireCard {
902    fn from(c: Card) -> Self {
903        Self {
904            schema_version: c.schema_version,
905            id: c.id,
906            lineage: c.lineage,
907            label: c.label,
908            provides: c.provides,
909            requires: c.requires,
910            permissions: c.permissions,
911            soma: c.soma,
912            payload: c.payload,
913            supervision: c.supervision,
914            lifecycle: c.lifecycle,
915            priority: c.priority,
916            flow: c.flow,
917            genesis: c.genesis.into_iter().map(WireCard::from).collect(),
918            service_socket: c.service_socket,
919            references: c.references,
920            kind: c.kind,
921            data: c.data,
922            priority_contexts: c.priority_contexts,
923        }
924    }
925}
926
927impl From<WireCard> for Card {
928    fn from(w: WireCard) -> Self {
929        Self {
930            schema_version: w.schema_version,
931            id: w.id,
932            lineage: w.lineage,
933            label: w.label,
934            provides: w.provides,
935            requires: w.requires,
936            permissions: w.permissions,
937            soma: w.soma,
938            payload: w.payload,
939            supervision: w.supervision,
940            lifecycle: w.lifecycle,
941            priority: w.priority,
942            flow: w.flow,
943            genesis: w.genesis.into_iter().map(Card::from).collect(),
944            service_socket: w.service_socket,
945            references: w.references,
946            kind: w.kind,
947            data: w.data,
948            priority_contexts: w.priority_contexts,
949            extensions: BTreeMap::new(),
950        }
951    }
952}
953
954// =====================================================================
955// Tests
956// =====================================================================
957
958#[cfg(test)]
959mod tests {
960    use super::*;
961
962    fn sample_card_json() -> &'static str {
963        r#"{
964            "schema_version": 1,
965            "id": "01HQAR53D4M2NBV8KZTYXFGS01",
966            "lineage": null,
967            "label": "brahman.semantic_dht",
968            "provides": ["Spawn", "Journal"],
969            "requires": [],
970            "permissions": {
971                "networking": "loopback",
972                "filesystem": "read-only",
973                "ipc": { "allow": ["wit-v1"] },
974                "processes": false
975            },
976            "soma": {
977                "namespaces": {
978                    "mount": false, "pid": false, "net": false,
979                    "uts": false, "ipc": false, "user": false, "cgroup": false
980                },
981                "rlimits": { "mem_bytes": null, "nproc": null, "nofile": null },
982                "cgroup": { "path": "ente.slice/dht", "cpu_weight": null, "io_weight": null },
983                "cpu_affinity": null
984            },
985            "payload": "Virtual",
986            "supervision": { "Restart": { "initial": 100, "max": 30000 } },
987            "lifecycle": "daemon",
988            "priority": "high",
989            "flow": {
990                "input": [
991                    { "name": "search-query", "type": { "primitive": { "name": "string" } } }
992                ],
993                "output": [
994                    { "name": "dht-results",
995                      "type": { "wit": { "package": "brahman:dht", "name": "entity-result" } } }
996                ]
997            },
998            "genesis": []
999        }"#
1000    }
1001
1002    #[test]
1003    fn parses_full_json() {
1004        let c = Card::from_json(sample_card_json()).unwrap();
1005        assert_eq!(c.label, "brahman.semantic_dht");
1006        assert_eq!(c.lifecycle, Lifecycle::Daemon);
1007        assert_eq!(c.priority, Priority::High);
1008        assert_eq!(c.permissions.filesystem, FsPolicy::ReadOnly);
1009        assert_eq!(c.permissions.networking, NetworkingPolicy::Loopback);
1010        assert_eq!(c.permissions.ipc.allow, vec!["wit-v1".to_string()]);
1011        assert_eq!(c.flow.input.len(), 1);
1012        assert_eq!(c.flow.output.len(), 1);
1013        match &c.flow.output[0].ty {
1014            TypeRef::Wit { package, name, .. } => {
1015                assert_eq!(package, "brahman:dht");
1016                assert_eq!(name, "entity-result");
1017            }
1018            _ => panic!("expected Wit"),
1019        }
1020    }
1021
1022    #[test]
1023    fn json_roundtrip_preserves_shape() {
1024        let c1 = Card::from_json(sample_card_json()).unwrap();
1025        let s = c1.to_json_pretty().unwrap();
1026        let c2 = Card::from_json(&s).unwrap();
1027        assert_eq!(c1.label, c2.label);
1028        assert_eq!(c1.flow.input.len(), c2.flow.input.len());
1029    }
1030
1031    #[test]
1032    fn trust_derivation() {
1033        let mut p = Permissions::default();
1034        assert_eq!(TrustLevel::derive(&p), TrustLevel::Untrusted);
1035        p.filesystem = FsPolicy::ReadOnly;
1036        assert_eq!(TrustLevel::derive(&p), TrustLevel::Sandboxed);
1037        p.networking = NetworkingPolicy::Outbound;
1038        assert_eq!(TrustLevel::derive(&p), TrustLevel::Privileged);
1039        p.processes = true;
1040        assert_eq!(TrustLevel::derive(&p), TrustLevel::System);
1041    }
1042
1043    #[test]
1044    fn duplicate_flow_names_rejected() {
1045        let mut c: Card = serde_json::from_str(sample_card_json()).unwrap();
1046        c.flow.input.push(c.flow.input[0].clone());
1047        assert!(matches!(
1048            c.validate(),
1049            Err(CardError::DuplicateFlowName { .. })
1050        ));
1051    }
1052
1053    #[test]
1054    fn self_dependency_rejected() {
1055        let mut c: Card = serde_json::from_str(sample_card_json()).unwrap();
1056        c.requires.insert(Capability::Spawn);
1057        assert!(matches!(c.validate(), Err(CardError::SelfDependency(_))));
1058    }
1059
1060    #[test]
1061    fn invalid_genesis_propagates() {
1062        let parent_src = r#"{
1063            "schema_version": 1,
1064            "id": "01HQAR53D4M2NBV8KZTYXFGS01",
1065            "label": "parent",
1066            "provides": [], "requires": [],
1067            "soma": {
1068                "namespaces": {"mount":false,"pid":false,"net":false,"uts":false,"ipc":false,"user":false,"cgroup":false},
1069                "rlimits": {"mem_bytes":null,"nproc":null,"nofile":null},
1070                "cgroup": {"path":"x","cpu_weight":null,"io_weight":null},
1071                "cpu_affinity": null
1072            },
1073            "payload": "Virtual",
1074            "supervision": "OneShot",
1075            "genesis": [{
1076                "schema_version": 1,
1077                "id": "01HQAR53D4M2NBV8KZTYXFGS02",
1078                "label": "",
1079                "provides": [], "requires": [],
1080                "soma": {
1081                    "namespaces": {"mount":false,"pid":false,"net":false,"uts":false,"ipc":false,"user":false,"cgroup":false},
1082                    "rlimits": {"mem_bytes":null,"nproc":null,"nofile":null},
1083                    "cgroup": {"path":"x","cpu_weight":null,"io_weight":null},
1084                    "cpu_affinity": null
1085                },
1086                "payload": "Virtual",
1087                "supervision": "OneShot",
1088                "genesis": []
1089            }]
1090        }"#;
1091        assert!(matches!(
1092            Card::from_json(parent_src),
1093            Err(CardError::EmptyLabel)
1094        ));
1095    }
1096
1097    #[test]
1098    fn presentation_card_carries_derived_trust() {
1099        let c = Card::from_json(sample_card_json()).unwrap();
1100        let resolved = ResolvedCard::from_agnostic(c);
1101        assert_eq!(resolved.trust, TrustLevel::Sandboxed);
1102        assert!(resolved.wit.is_none());
1103    }
1104
1105    #[test]
1106    fn arje_seed_format_compatible() {
1107        // Reproduce el formato canónico de arje (sin lifecycle/priority/flow,
1108        // que son aditivos brahman). Debe parsear con defaults.
1109        let src = r#"{
1110            "schema_version": 1,
1111            "id": "01HQAR53D4M2NBV8KZTYXFGS01",
1112            "lineage": null,
1113            "label": "vps-min",
1114            "provides": ["Spawn", "Journal"],
1115            "requires": [],
1116            "soma": {
1117                "namespaces": {"mount":false,"pid":false,"net":false,"uts":false,"ipc":false,"user":false,"cgroup":false},
1118                "rlimits": {"mem_bytes":null,"nproc":null,"nofile":null},
1119                "cgroup": {"path":"ente.slice/zero","cpu_weight":null,"io_weight":null},
1120                "cpu_affinity": null
1121            },
1122            "payload": "Virtual",
1123            "supervision": "OneShot",
1124            "genesis": []
1125        }"#;
1126        let c = Card::from_json(src).unwrap();
1127        assert_eq!(c.lifecycle, Lifecycle::Daemon); // default
1128        assert_eq!(c.priority, Priority::Normal); // default
1129        assert_eq!(c.flow.input.len(), 0);
1130    }
1131
1132    #[test]
1133    fn extensions_preserved_in_json_roundtrip() {
1134        let src = r#"{
1135            "schema_version": 1,
1136            "id": "01HQAR53D4M2NBV8KZTYXFGS01",
1137            "label": "x",
1138            "soma": {
1139                "namespaces": {"mount":false,"pid":false,"net":false,"uts":false,"ipc":false,"user":false,"cgroup":false},
1140                "rlimits": {"mem_bytes":null,"nproc":null,"nofile":null},
1141                "cgroup": {"path":"x","cpu_weight":null,"io_weight":null},
1142                "cpu_affinity": null
1143            },
1144            "payload": "Virtual",
1145            "supervision": "OneShot",
1146            "author": "sergio",
1147            "tags": ["draft", "experimental"]
1148        }"#;
1149        let c = Card::from_json(src).unwrap();
1150        assert_eq!(c.extensions.get("author").and_then(|v| v.as_str()), Some("sergio"));
1151        assert!(c.extensions.contains_key("tags"));
1152
1153        // Roundtrip JSON: extensions deben re-emitirse.
1154        let s = c.to_json_pretty().unwrap();
1155        let c2 = Card::from_json(&s).unwrap();
1156        assert_eq!(c2.extensions.get("author"), c.extensions.get("author"));
1157    }
1158
1159    #[test]
1160    fn wire_card_roundtrip_strips_extensions() {
1161        let src = r#"{
1162            "schema_version": 1,
1163            "id": "01HQAR53D4M2NBV8KZTYXFGS01",
1164            "label": "x",
1165            "soma": {
1166                "namespaces": {"mount":false,"pid":false,"net":false,"uts":false,"ipc":false,"user":false,"cgroup":false},
1167                "rlimits": {"mem_bytes":null,"nproc":null,"nofile":null},
1168                "cgroup": {"path":"x","cpu_weight":null,"io_weight":null},
1169                "cpu_affinity": null
1170            },
1171            "payload": "Virtual",
1172            "supervision": "OneShot",
1173            "author": "sergio"
1174        }"#;
1175        let c = Card::from_json(src).unwrap();
1176        assert!(c.extensions.contains_key("author"));
1177
1178        // Card → WireCard descarta extensions.
1179        let wire: WireCard = c.into();
1180        assert_eq!(wire.label, "x");
1181
1182        // WireCard → Card → extensiones quedan vacías (se perdieron).
1183        let c_back: Card = wire.into();
1184        assert_eq!(c_back.label, "x");
1185        assert!(c_back.extensions.is_empty(), "extensions sobreviven al wire");
1186    }
1187
1188    #[test]
1189    fn wirecard_postcard_with_priority_contexts() {
1190        // Repro del bug que rompía chasqui-nous-mock: ContextBias con
1191        // skip_serializing_if causaba que postcard leyera bytes
1192        // equivocados. Sin esos atributos, el roundtrip es estable.
1193        let src = r#"{
1194            "schema_version": 1,
1195            "id": "01HQAR53D4M2NBV8KZTYXFGS01",
1196            "label": "x",
1197            "soma": {
1198                "namespaces": {"mount":false,"pid":false,"net":false,"uts":false,"ipc":false,"user":false,"cgroup":false},
1199                "rlimits": {"mem_bytes":null,"nproc":null,"nofile":null},
1200                "cgroup": {"path":"x","cpu_weight":null,"io_weight":null},
1201                "cpu_affinity": null
1202            },
1203            "payload": "Virtual",
1204            "supervision": "OneShot"
1205        }"#;
1206        let mut c = Card::from_json(src).unwrap();
1207        c.priority_contexts.insert(
1208            "test".into(),
1209            ContextBias {
1210                pin_to: None,
1211                priority_offset: 1,
1212            },
1213        );
1214        c.priority_contexts.insert(
1215            "prod".into(),
1216            ContextBias {
1217                pin_to: Some("real-nous".into()),
1218                priority_offset: 2,
1219            },
1220        );
1221
1222        let wire: WireCard = c.into();
1223        let bytes = postcard::to_allocvec(&wire).expect("postcard encode");
1224        let decoded: WireCard = postcard::from_bytes(&bytes).expect("postcard decode");
1225
1226        assert_eq!(decoded.priority_contexts.len(), 2);
1227        let test_bias = decoded
1228            .priority_contexts
1229            .get("test")
1230            .expect("test context");
1231        assert_eq!(test_bias.priority_offset, 1);
1232        assert!(test_bias.pin_to.is_none());
1233        let prod_bias = decoded
1234            .priority_contexts
1235            .get("prod")
1236            .expect("prod context");
1237        assert_eq!(prod_bias.pin_to.as_deref(), Some("real-nous"));
1238        assert_eq!(prod_bias.priority_offset, 2);
1239    }
1240
1241    #[test]
1242    fn wire_card_postcard_friendly() {
1243        // Validación: WireCard puede ser postcard-encoded sin error.
1244        // Si Card tuviera extensions populadas, el encode rompería con
1245        // "length of a sequence must be known". WireCard las descarta.
1246        let src = r#"{
1247            "schema_version": 1,
1248            "id": "01HQAR53D4M2NBV8KZTYXFGS01",
1249            "label": "x",
1250            "soma": {
1251                "namespaces": {"mount":false,"pid":false,"net":false,"uts":false,"ipc":false,"user":false,"cgroup":false},
1252                "rlimits": {"mem_bytes":null,"nproc":null,"nofile":null},
1253                "cgroup": {"path":"x","cpu_weight":null,"io_weight":null},
1254                "cpu_affinity": null
1255            },
1256            "payload": "Virtual",
1257            "supervision": "OneShot",
1258            "author": "sergio"
1259        }"#;
1260        let c = Card::from_json(src).unwrap();
1261        let wire: WireCard = c.into();
1262        let bytes = postcard::to_allocvec(&wire).expect("WireCard debe encodear");
1263        let decoded: WireCard = postcard::from_bytes(&bytes).expect("WireCard debe decodear");
1264        assert_eq!(decoded.label, "x");
1265    }
1266
1267    #[test]
1268    fn new_assigns_real_ulid_and_label() {
1269        let c = Card::new("chasqui.engine");
1270        assert_eq!(c.label, "chasqui.engine");
1271        assert_ne!(c.id, Ulid::nil(), "Card::new no debe dejar id en nil");
1272    }
1273
1274    #[test]
1275    fn new_yields_distinct_ids_per_call() {
1276        let a = Card::new("x");
1277        let b = Card::new("x");
1278        assert_ne!(a.id, b.id);
1279    }
1280
1281    #[test]
1282    fn default_keeps_nil_id_for_struct_update_pattern() {
1283        // Mantener este invariante explícito: Default::default() es
1284        // determinista y devuelve nil. Cualquier cambio aquí rompería
1285        // el patrón `..Default::default()` en patterns de búsqueda.
1286        let d = Card::default();
1287        assert_eq!(d.id, Ulid::nil());
1288        assert!(d.label.is_empty());
1289    }
1290}