refactor(naming): A1 — ente→arje, vista→revista, pluma→fana
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}