Skip to main content

cellos_core/
events.rs

1//! Versioned CloudEvents `data` payloads (JSON only — no I/O).
2
3use std::fmt;
4
5use serde::{Serialize, Serializer};
6use serde_json::{json, Map, Value};
7
8use crate::policy::PolicyViolation;
9use crate::{
10    CloudEventV1, DnsAuthorityDnssecFailed, DnsAuthorityDrift, DnsAuthorityRebindRejected,
11    DnsAuthorityRebindThreshold, DnsQueryEvent, ExecutionCellSpec, ExportReceipt,
12    NetworkFlowDecision, WorkloadIdentity,
13};
14
15/// Subject URN string used by seam-freeze G3/G4 cross-pointers.
16///
17/// Outcome field for [`lifecycle_destroyed_data_v1`].
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
19#[serde(rename_all = "lowercase")]
20pub enum LifecycleDestroyOutcome {
21    Succeeded,
22    Failed,
23}
24
25/// How the supervisor learned the cell terminated, for the
26/// `terminalState` field of [`lifecycle_destroyed_data_v1`].
27///
28/// `outcome` answers "did the run succeed?" (no phase error). This enum
29/// answers a different question that audit cares about: "did we observe a
30/// real exit, or did we give up and kill it?"
31///
32/// - `Clean`: an authenticated exit code arrived through the in-VM bridge
33///   (Firecracker + cellos-init vsock). Whatever code arrived is the truth.
34/// - `Forced`: the supervisor never received an authenticated exit code —
35///   the bridge errored out (vsock channel closed before the 4 bytes were
36///   delivered) and teardown proceeded via SIGKILL. Any exit code recorded
37///   on this path is synthetic (e.g. -1) and must not be trusted.
38///
39/// Omitted from the event payload (encoded as `None`) on host backends that
40/// do not run an in-VM bridge, since the clean/forced distinction has no
41/// meaning when the supervisor itself owns the workload process.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
43#[serde(rename_all = "lowercase")]
44pub enum LifecycleTerminalState {
45    Clean,
46    Forced,
47}
48
49/// FC-50 typed `reason` for `dev.cellos.events.cell.lifecycle.v1.failed` /
50/// `.destroyed` payloads.
51///
52/// Replaces the free-form `Option<&str>` reason used by gap-markers in FC-23,
53/// FC-52, FC-59, FC-60, FC-61, FC-63, FC-72 with a constrained set of audit-
54/// stable codes. Each variant serializes to its `snake_case` form so the JSON
55/// wire format is stable for downstream auditors. `Other(String)` is the
56/// escape hatch for operator-supplied free-form reasons that have not yet
57/// earned a dedicated variant; it serializes verbatim as the inner string.
58///
59/// The typed surface is non-exhaustive — adding a new variant is a
60/// public-API change that requires schema updates on the
61/// `cell.lifecycle.v1.failed` / `.destroyed` contracts. Downstream
62/// `match`es outside this crate must include a wildcard arm; `#[non_exhaustive]`
63/// is enforced by the compiler so silent breaks at variant-add time aren't
64/// possible.
65#[derive(Debug, Clone, PartialEq, Eq)]
66#[non_exhaustive]
67pub enum LifecycleReason {
68    /// Workload exceeded its memory limit (cgroup OOM kill or VMM-level OOM).
69    Oom,
70    /// TTL watchdog fired before the workload completed.
71    TtlExceeded,
72    /// VMM process exited unexpectedly (e.g. Firecracker crashed).
73    VmmCrashed,
74    /// Kernel/init failed before reaching `/sbin/init`.
75    BootFailed,
76    /// Supervisor SIGKILLed the workload after the graceful-shutdown timeout
77    /// elapsed.
78    SignalKilled,
79    /// `cellos-init` segfaulted or aborted inside the guest.
80    InitCrashed,
81    /// Kernel panicked because it could not mount the rootfs (rootfs
82    /// corruption / wrong fs / missing block device).
83    KernelCannotMountRoot,
84    /// Operator-supplied free-form reason. Serialized verbatim — prefer a
85    /// typed variant when one applies.
86    Other(String),
87}
88
89impl LifecycleReason {
90    /// Wire-form string for this reason. Typed variants serialize to
91    /// `snake_case`; `Other(s)` returns the inner string verbatim.
92    pub fn as_wire_str(&self) -> &str {
93        match self {
94            LifecycleReason::Oom => "oom",
95            LifecycleReason::TtlExceeded => "ttl_exceeded",
96            LifecycleReason::VmmCrashed => "vmm_crashed",
97            LifecycleReason::BootFailed => "boot_failed",
98            LifecycleReason::SignalKilled => "signal_killed",
99            LifecycleReason::InitCrashed => "init_crashed",
100            LifecycleReason::KernelCannotMountRoot => "kernel_cannot_mount_root",
101            LifecycleReason::Other(s) => s.as_str(),
102        }
103    }
104}
105
106impl fmt::Display for LifecycleReason {
107    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108        f.write_str(self.as_wire_str())
109    }
110}
111
112impl Serialize for LifecycleReason {
113    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
114        serializer.serialize_str(self.as_wire_str())
115    }
116}
117
118/// Identity lifecycle step for [`identity_failed_data_v1`].
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
120#[serde(rename_all = "lowercase")]
121pub enum IdentityFailureOperation {
122    Materialize,
123    Revoke,
124}
125
126/// Tranche-1 seam-freeze G2 provenance pointer.
127///
128/// Surfaces the parent CloudEvent that *caused* the event carrying this
129/// `provenance` block, so downstream auditors (taudit, tencrypt) can walk
130/// `artifact → compliance.summary → spec → derivation token` without
131/// operator-supplied joins. See `docs/seam-freeze-v1.md` §3 G2.
132///
133/// Today the supervisor populates this on:
134///
135/// - `dev.cellos.events.cell.identity.v1.revoked` — revoke is caused by the
136///   cell that materialized the identity, so `parent` is the
137///   `cell.lifecycle.v1.started` event ID and `parentType` is the started
138///   event's CloudEvent type URN.
139/// - `dev.cellos.events.cell.export.v2.completed` and
140///   `dev.cellos.events.cell.export.v2.failed` — exports are caused by the
141///   originating cell run, so `parent` is the started event ID with the
142///   matching `parentType`.
143///
144/// The struct is additive: producers omit it on legacy emissions, and the
145/// schemas tolerate its absence so v1/v2 consumers keep parsing legacy
146/// events unchanged.
147///
148/// `parent` is the producing tool's CloudEvent envelope `id` — a stable URN
149/// is preferred (`urn:cellos:event:<uuid>`) but a raw UUID is accepted in
150/// v1. `parent_type` carries the parent event's CloudEvent `type` so a
151/// consumer can route without resolving the parent first.
152#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
153#[serde(rename_all = "camelCase")]
154pub struct Provenance {
155    /// CloudEvent `id` (or URN) of the parent event that caused this event.
156    pub parent: String,
157    /// CloudEvent `type` of the parent event (e.g.
158    /// `dev.cellos.events.cell.lifecycle.v1.started`). Lets consumers
159    /// dispatch on the parent class without dereferencing it.
160    pub parent_type: String,
161}
162
163/// Tranche-1 seam-freeze G3 subject URN.
164///
165/// Promotes the CloudEvents `subject` envelope field from a free-form string
166/// (`cell:<id>` was the legacy convention) to a typed, validated URN of the
167/// form `urn:<tool>:<kind>:<id>`. See `docs/seam-freeze-v1.md` §3 G3 / §4.
168///
169/// `0ryant-shell` and `tedit` use the URN prefix as a routing key; CellOS
170/// emitters use [`cell_subject_urn`] for cell subjects. Other tools mint
171/// their own (`urn:tsafe:lease:<id>`, `urn:tencrypt:cert:<id>`, etc.).
172///
173/// Validation rules (see [`SubjectUrn::parse`]):
174///
175/// 1. must start with the literal scheme `urn:`;
176/// 2. exactly four colon-separated segments — `urn`, `<tool>`, `<kind>`,
177///    `<id>` — where `<id>` may itself contain colons;
178/// 3. `<tool>`, `<kind>`, `<id>` must each be non-empty;
179/// 4. `<tool>` and `<kind>` are restricted to lowercase ASCII alphanumerics
180///    and `-` (charset `[a-z0-9-]`);
181/// 5. no ASCII control characters and no whitespace anywhere.
182///
183/// `<id>` is intentionally permissive on charset (so producers can carry
184/// existing IDs like ULIDs, UUIDs, or `cell-<host>-<n>`) but still must not
185/// contain ASCII control or whitespace.
186#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
187#[serde(transparent)]
188pub struct SubjectUrn(String);
189
190/// Parse-time error returned by [`SubjectUrn::parse`].
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub enum SubjectUrnError {
193    /// Did not start with the literal `urn:` scheme.
194    MissingUrnScheme,
195    /// Fewer than four colon-separated segments.
196    TooFewSegments,
197    /// One of `<tool>` / `<kind>` / `<id>` was empty.
198    EmptySegment,
199    /// `<tool>` or `<kind>` contained a character outside `[a-z0-9-]`.
200    InvalidToolOrKindCharset,
201    /// Subject contained an ASCII control character or whitespace.
202    ControlOrWhitespace,
203}
204
205impl std::fmt::Display for SubjectUrnError {
206    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207        match self {
208            SubjectUrnError::MissingUrnScheme => f.write_str("subject URN must start with `urn:`"),
209            SubjectUrnError::TooFewSegments => {
210                f.write_str("subject URN must have shape `urn:<tool>:<kind>:<id>`")
211            }
212            SubjectUrnError::EmptySegment => {
213                f.write_str("subject URN tool / kind / id segments must each be non-empty")
214            }
215            SubjectUrnError::InvalidToolOrKindCharset => {
216                f.write_str("subject URN tool and kind must match charset [a-z0-9-]")
217            }
218            SubjectUrnError::ControlOrWhitespace => {
219                f.write_str("subject URN must not contain ASCII control characters or whitespace")
220            }
221        }
222    }
223}
224
225impl std::error::Error for SubjectUrnError {}
226
227impl SubjectUrn {
228    /// Validate `s` and wrap it as a typed `SubjectUrn`. See the type-level
229    /// documentation for the exact rules.
230    pub fn parse(s: impl Into<String>) -> Result<Self, SubjectUrnError> {
231        let s = s.into();
232
233        // Rule 5: no ASCII control or whitespace anywhere.
234        if s.bytes()
235            .any(|b| b.is_ascii_control() || (b as char).is_whitespace())
236        {
237            return Err(SubjectUrnError::ControlOrWhitespace);
238        }
239
240        // Rule 1: must start with `urn:`.
241        let rest = match s.strip_prefix("urn:") {
242            Some(r) => r,
243            None => return Err(SubjectUrnError::MissingUrnScheme),
244        };
245
246        // Rule 2: split into <tool>:<kind>:<id> with `splitn(3, ':')` so the
247        // id portion can itself contain colons (forward-compat for nested
248        // identifiers like `urn:cellos:event:<uuid>:<seq>`).
249        let mut parts = rest.splitn(3, ':');
250        let tool = parts.next().ok_or(SubjectUrnError::TooFewSegments)?;
251        let kind = parts.next().ok_or(SubjectUrnError::TooFewSegments)?;
252        let id = parts.next().ok_or(SubjectUrnError::TooFewSegments)?;
253
254        // Rule 3: non-empty tool/kind/id.
255        if tool.is_empty() || kind.is_empty() || id.is_empty() {
256            return Err(SubjectUrnError::EmptySegment);
257        }
258
259        // Rule 4: tool/kind charset [a-z0-9-].
260        let ok_segment = |seg: &str| {
261            seg.bytes()
262                .all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'-'))
263        };
264        if !ok_segment(tool) || !ok_segment(kind) {
265            return Err(SubjectUrnError::InvalidToolOrKindCharset);
266        }
267
268        Ok(SubjectUrn(s))
269    }
270
271    /// Borrow the validated URN as a `&str`.
272    pub fn as_str(&self) -> &str {
273        &self.0
274    }
275
276    /// Consume the wrapper and return the owned string.
277    pub fn into_inner(self) -> String {
278        self.0
279    }
280}
281
282impl AsRef<str> for SubjectUrn {
283    fn as_ref(&self) -> &str {
284        &self.0
285    }
286}
287
288impl std::fmt::Display for SubjectUrn {
289    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
290        f.write_str(&self.0)
291    }
292}
293
294/// Build the canonical CellOS cell subject URN (`urn:cellos:cell:<cell_id>`).
295///
296/// The single helper means the literal prefix `urn:cellos:cell:` lives in
297/// exactly one place; the `scripts/audit/subject-urn-check.sh` CI gate
298/// enforces that no other site hand-crafts the same shape. Callers should
299/// prefer this helper over `format!("urn:cellos:cell:{cell_id}")`.
300///
301/// Returns `Err(SubjectUrnError::EmptySegment)` if `cell_id` is empty and
302/// surfaces charset / whitespace errors from [`SubjectUrn::parse`] for IDs
303/// that contain ASCII control / whitespace characters.
304pub fn cell_subject_urn(cell_id: &str) -> Result<SubjectUrn, SubjectUrnError> {
305    SubjectUrn::parse(format!("urn:cellos:cell:{cell_id}"))
306}
307
308/// `data` for event type `dev.cellos.events.cell.lifecycle.v1.started`.
309///
310/// Schema: `contracts/schemas/cell-lifecycle-started-data-v1.schema.json`.
311///
312/// Authority derivation fields (L5-05): when the spec carries an
313/// `authority.authorityDerivation` token, the supervisor verifies the proof
314/// before this event is emitted and surfaces the outcome here so taudit can
315/// answer "prove every prod cell derived authority from role X" without
316/// re-running the verification.
317///
318/// - `derivation_verified`: `Some(true)` when the proof verified, `Some(false)`
319///   when verification failed (non-fatal — the run still proceeds), `None`
320///   when the spec carried no derivation token at all.
321/// - `role_root`: the role root identifier from the derivation token (when
322///   the token was present), regardless of whether verification succeeded.
323/// - `parent_run_id`: optional lineage attribution from the derivation token.
324///
325/// All three fields are emitted into the `data` JSON only when `Some`, so
326/// specs without a derivation token produce the same payload as before this
327/// change (backward-compatible with the v1 schema).
328///
329/// **A2-03 — per-tenant event stream isolation**: when
330/// `spec.correlation.tenantId` is `Some(_)`, the value is mirrored into a
331/// top-level `tenantId` field on the event `data` (in addition to staying
332/// embedded inside the `correlation` block for joins). The supervisor's
333/// JetStream sink reads this field at publish time to substitute
334/// `{tenantId}` in the configured subject template. When the tenant is
335/// `None`, the top-level `tenantId` is OMITTED entirely so the wire format
336/// is byte-identical to pre-A2-03 builds for single-tenant operators.
337///
338/// **FC-08**: when the host backend has a verified artifact manifest
339/// (currently the Firecracker backend), the supervisor surfaces the on-disk
340/// SHA256 digests of the boot artifacts on the started event so taudit can
341/// answer "which kernel/rootfs/firecracker bytes did this run boot?" without
342/// querying any backend-side state. The three digest fields are independently
343/// optional — backends without a manifest path (stub, host-cellos) pass
344/// `None` for all three; the Firecracker backend passes `Some(hex)` for
345/// kernel and rootfs (always verified) and `Some(hex)` for firecracker only
346/// when the manifest also declares the firecracker binary digest. Each is
347/// emitted into the JSON only when `Some`, preserving the v1 schema's
348/// backward-compat semantics.
349#[allow(clippy::too_many_arguments)]
350pub fn lifecycle_started_data_v1(
351    spec: &ExecutionCellSpec,
352    cell_id: &str,
353    run_id: Option<&str>,
354    derivation_verified: Option<bool>,
355    role_root: Option<&str>,
356    parent_run_id: Option<&str>,
357    spec_hash: Option<&str>,
358    kernel_digest_sha256: Option<&str>,
359    rootfs_digest_sha256: Option<&str>,
360    firecracker_digest_sha256: Option<&str>,
361) -> Result<Value, serde_json::Error> {
362    let mut m = Map::new();
363    m.insert("cellId".to_string(), json!(cell_id));
364    m.insert("specId".to_string(), json!(&spec.id));
365    m.insert("ttlSeconds".to_string(), json!(spec.lifetime.ttl_seconds));
366    if let Some(r) = run_id {
367        m.insert("runId".to_string(), json!(r));
368    }
369    if let Some(verified) = derivation_verified {
370        m.insert("derivationVerified".to_string(), json!(verified));
371    }
372    if let Some(role) = role_root {
373        m.insert("roleRoot".to_string(), json!(role));
374    }
375    if let Some(parent) = parent_run_id {
376        m.insert("parentRunId".to_string(), json!(parent));
377    }
378    if let Some(hash) = spec_hash {
379        m.insert("specHash".to_string(), json!(hash));
380    }
381    if let Some(d) = kernel_digest_sha256 {
382        m.insert("kernelDigestSha256".to_string(), json!(d));
383    }
384    if let Some(d) = rootfs_digest_sha256 {
385        m.insert("rootfsDigestSha256".to_string(), json!(d));
386    }
387    if let Some(d) = firecracker_digest_sha256 {
388        m.insert("firecrackerDigestSha256".to_string(), json!(d));
389    }
390    if let Some(placement) = &spec.placement {
391        let mut placement_map = Map::new();
392        if let Some(pool_id) = &placement.pool_id {
393            placement_map.insert("poolId".to_string(), json!(pool_id));
394        }
395        if let Some(namespace) = &placement.kubernetes_namespace {
396            placement_map.insert("kubernetesNamespace".to_string(), json!(namespace));
397        }
398        if let Some(queue_name) = &placement.queue_name {
399            placement_map.insert("queueName".to_string(), json!(queue_name));
400        }
401        if !placement_map.is_empty() {
402            m.insert("placement".to_string(), Value::Object(placement_map));
403        }
404    }
405    if let Some(c) = &spec.correlation {
406        if let Some(tid) = &c.tenant_id {
407            m.insert("tenantId".to_string(), json!(tid));
408        }
409        m.insert("correlation".to_string(), serde_json::to_value(c)?);
410    }
411    Ok(Value::Object(m))
412}
413
414/// `data` for event type `dev.cellos.events.cell.lifecycle.v1.destroyed`.
415///
416/// Schema: `contracts/schemas/cell-lifecycle-destroyed-data-v1.schema.json`.
417///
418/// `terminal_state` is `None` for backends that do not own workload execution
419/// (host-side spawn path) and `Some(...)` for the in-VM bridge path. Auditors
420/// keying on this field can distinguish "supervisor read an authenticated exit
421/// code" from "supervisor gave up and SIGKILLed the VMM" — see
422/// [`LifecycleTerminalState`] for the distinction. The field is emitted into
423/// the JSON only when set, preserving backward-compatibility with v1 consumers.
424///
425/// F5 (D5 destruction-evidence integration):
426///
427/// - `evidence_bundle_ref`: URN of the `evidence_bundle` artifact (F1b)
428///   that aggregates this run's lifecycle, host-side series, and guest
429///   declarations. Auditors reading `cell.destroyed` cold MUST be able to
430///   walk this URN to the bundle. See `docs/adr/0006-...` and F1.
431/// - `residue_class`: final residue classification from
432///   `docs/destruction-semantics.md` (`none` / `documented_exception`).
433///
434/// Both fields are additive: emitted only when `Some(...)` so existing v1
435/// consumers parse the legacy payload unchanged. The supervisor passes
436/// `None, None` until F1b wires the evidence-bundle aggregator into the
437/// teardown path.
438///
439/// **A2-03**: like `lifecycle_started_data_v1`, this constructor mirrors
440/// `spec.correlation.tenantId` into a top-level `tenantId` field on
441/// `data` when set, and omits it entirely otherwise.
442///
443/// **FC-23 invariant**: this constructor intentionally takes no `exit_code`
444/// parameter. The destroyed payload never carries an `exitCode` field. The
445/// authenticated workload exit code (if any) is reported on the separate
446/// `cell.command.v1.completed` event via [`command_completed_data_v1`], which
447/// is only emitted on the in-VM-bridge clean-exit path. Forced terminations
448/// (SIGKILL fallback, vsock channel closed) therefore cannot surface an
449/// `exitCode` on this event by construction — any synthetic code such as
450/// `-1` or `137` stays internal to the supervisor and never reaches auditors.
451/// See `Plans/firecracker-release-readiness.md` line 70.
452#[allow(clippy::too_many_arguments)]
453pub fn lifecycle_destroyed_data_v1(
454    spec: &ExecutionCellSpec,
455    cell_id: &str,
456    run_id: Option<&str>,
457    outcome: LifecycleDestroyOutcome,
458    reason: Option<&str>,
459    terminal_state: Option<LifecycleTerminalState>,
460    evidence_bundle_ref: Option<&SubjectUrn>,
461    residue_class: Option<ResidueClass>,
462) -> Result<Value, serde_json::Error> {
463    let mut m = Map::new();
464    m.insert("cellId".to_string(), json!(cell_id));
465    m.insert("specId".to_string(), json!(&spec.id));
466    m.insert("ttlSeconds".to_string(), json!(spec.lifetime.ttl_seconds));
467    m.insert("outcome".to_string(), serde_json::to_value(outcome)?);
468    if let Some(r) = run_id {
469        m.insert("runId".to_string(), json!(r));
470    }
471    if let Some(c) = &spec.correlation {
472        if let Some(tid) = &c.tenant_id {
473            m.insert("tenantId".to_string(), json!(tid));
474        }
475        m.insert("correlation".to_string(), serde_json::to_value(c)?);
476    }
477    if let Some(s) = reason {
478        m.insert("reason".to_string(), json!(s));
479    }
480    if let Some(ts) = terminal_state {
481        m.insert("terminalState".to_string(), serde_json::to_value(ts)?);
482    }
483    if let Some(urn) = evidence_bundle_ref {
484        m.insert("evidenceBundleRef".to_string(), json!(urn));
485    }
486    if let Some(rc) = residue_class {
487        m.insert("residueClass".to_string(), serde_json::to_value(rc)?);
488    }
489    Ok(Value::Object(m))
490}
491
492/// CloudEvent `type` URN for [`manifest_failed_data_v1`].
493///
494/// Emitted when pre-boot artifact digest verification fails — the on-disk
495/// artifact does not match the digest declared in the host manifest. The
496/// emission point lives in the host backend (see
497/// `crates/cellos-host-firecracker/src/lib.rs::verify_artifacts`); this
498/// constructor only shapes the `data` payload.
499pub const LIFECYCLE_MANIFEST_FAILED_TYPE: &str =
500    "dev.cellos.events.cell.lifecycle.v1.manifest-failed";
501
502/// `data` for event type `dev.cellos.events.cell.lifecycle.v1.manifest-failed`.
503///
504/// Surfaces a manifest verification failure: the artifact bound to `role`
505/// (e.g. `"kernel"`, `"rootfs"`, `"firecracker"`) hashed to
506/// `actual_sha256` on disk, but the declared digest in `manifest_path` was
507/// `expected_sha256`. Both digests are emitted as lowercase hex (no
508/// `sha256:` prefix), matching the rest of CellOS's audit surface, so
509/// downstream taudit consumers can diff the two strings byte-for-byte.
510///
511/// FC-51 (`Plans/firecracker-release-readiness.md`): integration test
512/// replaces the on-disk kernel with a mismatched file and asserts this
513/// event is emitted with the `kernel` role and both digests.
514pub fn manifest_failed_data_v1(
515    role: &str,
516    expected_sha256: &str,
517    actual_sha256: &str,
518    manifest_path: &str,
519) -> Result<Value, serde_json::Error> {
520    let mut m = Map::new();
521    m.insert("role".to_string(), json!(role));
522    m.insert("expectedSha256".to_string(), json!(expected_sha256));
523    m.insert("actualSha256".to_string(), json!(actual_sha256));
524    m.insert("manifestPath".to_string(), json!(manifest_path));
525    Ok(Value::Object(m))
526}
527
528/// FC-50 typed-reason variant of [`lifecycle_destroyed_data_v1`].
529///
530/// Identical wire format to the string-based constructor — `reason` is
531/// serialized through [`LifecycleReason::as_wire_str`] and threaded into the
532/// existing builder via the `Option<&str>` path, so consumers see the same
533/// JSON. The typed surface is the preferred entry point for new callers
534/// (FC-23, FC-52, FC-59, FC-60, FC-61, FC-63, FC-72 gap-markers); the
535/// string-based constructor remains for back-compat until all call sites
536/// migrate.
537///
538/// Adds two F5 destruction-evidence fields not on the legacy constructor:
539///
540/// - `evidence_bundle_ref`: pointer (URN or CloudEvent id) to the per-cell
541///   `evidence_bundle` produced by Phase F (see ADR-0006). Emitted as
542///   `evidenceBundleRef` only when `Some`.
543/// - `residue_class`: destruction-semantics residue class string (per
544///   `docs/destruction-semantics.md`). Emitted as `residueClass` only when
545///   `Some`.
546///
547/// Both are additive and absent from the legacy v1 payload — consumers that
548/// do not understand them must ignore unknown fields per the v1 schema's
549/// `additionalProperties` posture.
550#[allow(clippy::too_many_arguments)]
551pub fn lifecycle_destroyed_data_v1_typed(
552    spec: &ExecutionCellSpec,
553    cell_id: &str,
554    run_id: Option<&str>,
555    outcome: LifecycleDestroyOutcome,
556    reason: Option<LifecycleReason>,
557    terminal_state: Option<LifecycleTerminalState>,
558    evidence_bundle_ref: Option<&SubjectUrn>,
559    residue_class: Option<ResidueClass>,
560) -> Result<Value, serde_json::Error> {
561    let reason_str = reason.as_ref().map(|r| r.as_wire_str());
562    lifecycle_destroyed_data_v1(
563        spec,
564        cell_id,
565        run_id,
566        outcome,
567        reason_str,
568        terminal_state,
569        evidence_bundle_ref,
570        residue_class,
571    )
572}
573
574/// `data` for event type `dev.cellos.events.cell.identity.v1.materialized`.
575///
576/// Schema: `contracts/schemas/cell-identity-materialized-data-v1.schema.json`.
577pub fn identity_materialized_data_v1(
578    spec: &ExecutionCellSpec,
579    cell_id: &str,
580    run_id: Option<&str>,
581    identity: &WorkloadIdentity,
582) -> Result<Value, serde_json::Error> {
583    let mut m = Map::new();
584    m.insert("cellId".to_string(), json!(cell_id));
585    m.insert("specId".to_string(), json!(&spec.id));
586    m.insert("identity".to_string(), serde_json::to_value(identity)?);
587    if let Some(r) = run_id {
588        m.insert("runId".to_string(), json!(r));
589    }
590    if let Some(c) = &spec.correlation {
591        m.insert("correlation".to_string(), serde_json::to_value(c)?);
592    }
593    Ok(Value::Object(m))
594}
595
596/// `data` for event type `dev.cellos.events.cell.identity.v1.revoked`.
597///
598/// Schema: `contracts/schemas/cell-identity-revoked-data-v1.schema.json`.
599///
600/// `provenance` (Tranche-1 seam-freeze G2) — when set, points at the parent
601/// event that caused this revocation. The supervisor populates it with the
602/// `cell.lifecycle.v1.started` envelope so taudit can stitch revoke →
603/// lifecycle → spec without operator joins. Omitted from the JSON when
604/// `None`, preserving the v1 schema's backward-compat semantics.
605pub fn identity_revoked_data_v1(
606    spec: &ExecutionCellSpec,
607    cell_id: &str,
608    run_id: Option<&str>,
609    identity: &WorkloadIdentity,
610    reason: Option<&str>,
611    provenance: Option<&Provenance>,
612) -> Result<Value, serde_json::Error> {
613    let mut m = Map::new();
614    m.insert("cellId".to_string(), json!(cell_id));
615    m.insert("specId".to_string(), json!(&spec.id));
616    m.insert("identity".to_string(), serde_json::to_value(identity)?);
617    if let Some(r) = run_id {
618        m.insert("runId".to_string(), json!(r));
619    }
620    if let Some(c) = &spec.correlation {
621        m.insert("correlation".to_string(), serde_json::to_value(c)?);
622    }
623    if let Some(s) = reason {
624        m.insert("reason".to_string(), json!(s));
625    }
626    if let Some(p) = provenance {
627        m.insert("provenance".to_string(), serde_json::to_value(p)?);
628    }
629    Ok(Value::Object(m))
630}
631
632/// `data` for event type `dev.cellos.events.cell.identity.v1.failed`.
633///
634/// Schema: `contracts/schemas/cell-identity-failed-data-v1.schema.json`.
635pub fn identity_failed_data_v1(
636    spec: &ExecutionCellSpec,
637    cell_id: &str,
638    run_id: Option<&str>,
639    identity: &WorkloadIdentity,
640    operation: IdentityFailureOperation,
641    reason: &str,
642) -> Result<Value, serde_json::Error> {
643    let mut m = Map::new();
644    m.insert("cellId".to_string(), json!(cell_id));
645    m.insert("specId".to_string(), json!(&spec.id));
646    m.insert("identity".to_string(), serde_json::to_value(identity)?);
647    m.insert("operation".to_string(), serde_json::to_value(operation)?);
648    m.insert("reason".to_string(), json!(reason));
649    if let Some(r) = run_id {
650        m.insert("runId".to_string(), json!(r));
651    }
652    if let Some(c) = &spec.correlation {
653        m.insert("correlation".to_string(), serde_json::to_value(c)?);
654    }
655    Ok(Value::Object(m))
656}
657
658/// `data` for event type `dev.cellos.events.cell.command.v1.completed`.
659///
660/// Schema: `contracts/schemas/cell-command-completed-data-v1.schema.json`.
661pub fn command_completed_data_v1(
662    spec: &ExecutionCellSpec,
663    cell_id: &str,
664    run_id: Option<&str>,
665    argv: &[String],
666    exit_code: i32,
667    duration_ms: u64,
668    spawn_error: Option<&str>,
669) -> Result<Value, serde_json::Error> {
670    let mut m = Map::new();
671    m.insert("cellId".to_string(), json!(cell_id));
672    m.insert("specId".to_string(), json!(&spec.id));
673    m.insert("exitCode".to_string(), json!(exit_code));
674    m.insert("durationMs".to_string(), json!(duration_ms));
675    m.insert("argv".to_string(), json!(argv));
676    if let Some(r) = run_id {
677        m.insert("runId".to_string(), json!(r));
678    }
679    if let Some(c) = &spec.correlation {
680        m.insert("correlation".to_string(), serde_json::to_value(c)?);
681    }
682    if let Some(s) = spawn_error {
683        m.insert("spawnError".to_string(), json!(s));
684    }
685    Ok(Value::Object(m))
686}
687
688/// `dev.cellos.events.cell.observability.v1.network_scope`
689///
690/// Schema: `contracts/schemas/cell-observability-network-scope-v1.schema.json`.
691pub fn observability_network_scope_data_v1(
692    spec: &ExecutionCellSpec,
693    cell_id: &str,
694    run_id: Option<&str>,
695    egress_rule_count: usize,
696    has_opaque_network_authority: bool,
697) -> Result<Value, serde_json::Error> {
698    let mut m = Map::new();
699    m.insert("cellId".to_string(), json!(cell_id));
700    m.insert("specId".to_string(), json!(&spec.id));
701    m.insert("egressRuleCount".to_string(), json!(egress_rule_count));
702    m.insert(
703        "hasOpaqueNetworkAuthority".to_string(),
704        json!(has_opaque_network_authority),
705    );
706    if let Some(r) = run_id {
707        m.insert("runId".to_string(), json!(r));
708    }
709    if let Some(c) = &spec.correlation {
710        m.insert("correlation".to_string(), serde_json::to_value(c)?);
711    }
712    Ok(Value::Object(m))
713}
714
715/// `dev.cellos.events.cell.observability.v1.process_spawned`
716///
717/// Schema: `contracts/schemas/cell-observability-process-spawned-v1.schema.json`.
718pub fn observability_process_spawned_data_v1(
719    spec: &ExecutionCellSpec,
720    cell_id: &str,
721    run_id: Option<&str>,
722    program: &str,
723    argc: usize,
724) -> Result<Value, serde_json::Error> {
725    let mut m = Map::new();
726    m.insert("cellId".to_string(), json!(cell_id));
727    m.insert("specId".to_string(), json!(&spec.id));
728    m.insert("program".to_string(), json!(program));
729    m.insert("argc".to_string(), json!(argc));
730    if let Some(r) = run_id {
731        m.insert("runId".to_string(), json!(r));
732    }
733    if let Some(c) = &spec.correlation {
734        m.insert("correlation".to_string(), serde_json::to_value(c)?);
735    }
736    Ok(Value::Object(m))
737}
738
739/// `dev.cellos.events.cell.observability.v1.fs_touch`
740///
741/// Schema: `contracts/schemas/cell-observability-fs-touch-v1.schema.json`.
742pub fn observability_fs_touch_export_data_v1(
743    spec: &ExecutionCellSpec,
744    cell_id: &str,
745    run_id: Option<&str>,
746    source_path: &str,
747    artifact_name: &str,
748) -> Result<Value, serde_json::Error> {
749    let mut m = Map::new();
750    m.insert("cellId".to_string(), json!(cell_id));
751    m.insert("specId".to_string(), json!(&spec.id));
752    m.insert("purpose".to_string(), json!("export"));
753    m.insert("sourcePath".to_string(), json!(source_path));
754    m.insert("artifactName".to_string(), json!(artifact_name));
755    if let Some(r) = run_id {
756        m.insert("runId".to_string(), json!(r));
757    }
758    if let Some(c) = &spec.correlation {
759        m.insert("correlation".to_string(), serde_json::to_value(c)?);
760    }
761    Ok(Value::Object(m))
762}
763
764/// `dev.cellos.events.cell.export.v1.completed`
765///
766/// Schema: `contracts/schemas/cell-export-completed-v1.schema.json`.
767pub fn export_completed_data_v1(
768    spec: &ExecutionCellSpec,
769    cell_id: &str,
770    run_id: Option<&str>,
771    artifact_name: &str,
772    bytes_written: u64,
773    destination_relative: &str,
774) -> Result<Value, serde_json::Error> {
775    let mut m = Map::new();
776    m.insert("cellId".to_string(), json!(cell_id));
777    m.insert("specId".to_string(), json!(&spec.id));
778    m.insert("artifactName".to_string(), json!(artifact_name));
779    m.insert("bytesWritten".to_string(), json!(bytes_written));
780    m.insert(
781        "destinationRelative".to_string(),
782        json!(destination_relative),
783    );
784    if let Some(r) = run_id {
785        m.insert("runId".to_string(), json!(r));
786    }
787    if let Some(c) = &spec.correlation {
788        m.insert("correlation".to_string(), serde_json::to_value(c)?);
789    }
790    Ok(Value::Object(m))
791}
792
793/// `dev.cellos.events.cell.export.v2.completed`
794///
795/// Schema: `contracts/schemas/cell-export-completed-v2.schema.json`.
796///
797/// `provenance` (Tranche-1 seam-freeze G2) — when set, points at the parent
798/// event that caused this export receipt. The supervisor populates it with
799/// the `cell.lifecycle.v1.started` envelope of the originating cell run so
800/// downstream consumers (tencrypt envelope promotion, taudit graph build)
801/// can walk artifact → lifecycle → spec without operator joins. Omitted
802/// from the JSON when `None` to preserve v2 schema backward-compat.
803pub fn export_completed_data_v2(
804    spec: &ExecutionCellSpec,
805    cell_id: &str,
806    run_id: Option<&str>,
807    artifact_name: &str,
808    receipt: &ExportReceipt,
809    provenance: Option<&Provenance>,
810) -> Result<Value, serde_json::Error> {
811    let mut m = Map::new();
812    m.insert("cellId".to_string(), json!(cell_id));
813    m.insert("specId".to_string(), json!(&spec.id));
814    m.insert("artifactName".to_string(), json!(artifact_name));
815    m.insert("receipt".to_string(), serde_json::to_value(receipt)?);
816    if let Some(r) = run_id {
817        m.insert("runId".to_string(), json!(r));
818    }
819    if let Some(c) = &spec.correlation {
820        m.insert("correlation".to_string(), serde_json::to_value(c)?);
821    }
822    if let Some(p) = provenance {
823        m.insert("provenance".to_string(), serde_json::to_value(p)?);
824    }
825    Ok(Value::Object(m))
826}
827
828/// `dev.cellos.events.cell.export.v2.failed`
829///
830/// Schema: `contracts/schemas/cell-export-failed-v2.schema.json`.
831///
832/// `provenance` (Tranche-1 seam-freeze G2) — when set, points at the parent
833/// event that caused this export attempt. The supervisor populates it with
834/// the `cell.lifecycle.v1.started` envelope of the originating cell run so
835/// downstream consumers (tencrypt envelope promotion, taudit graph build)
836/// can walk failed artifact → lifecycle → spec without operator joins.
837/// Omitted from the JSON when `None` to preserve v2 schema backward-compat.
838#[allow(clippy::too_many_arguments)] // mirrors CloudEvent payload fields
839pub fn export_failed_data_v2(
840    spec: &ExecutionCellSpec,
841    cell_id: &str,
842    run_id: Option<&str>,
843    artifact_name: &str,
844    target_kind: crate::ExportReceiptTargetKind,
845    target_name: Option<&str>,
846    destination: Option<&str>,
847    reason: &str,
848    provenance: Option<&Provenance>,
849) -> Result<Value, serde_json::Error> {
850    let mut m = Map::new();
851    m.insert("cellId".to_string(), json!(cell_id));
852    m.insert("specId".to_string(), json!(&spec.id));
853    m.insert("artifactName".to_string(), json!(artifact_name));
854    m.insert("targetKind".to_string(), serde_json::to_value(target_kind)?);
855    m.insert("reason".to_string(), json!(reason));
856    if let Some(name) = target_name {
857        m.insert("targetName".to_string(), json!(name));
858    }
859    if let Some(dest) = destination {
860        m.insert("destination".to_string(), json!(dest));
861    }
862    if let Some(r) = run_id {
863        m.insert("runId".to_string(), json!(r));
864    }
865    if let Some(c) = &spec.correlation {
866        m.insert("correlation".to_string(), serde_json::to_value(c)?);
867    }
868    if let Some(p) = provenance {
869        m.insert("provenance".to_string(), serde_json::to_value(p)?);
870    }
871    Ok(Value::Object(m))
872}
873
874/// `dev.cellos.events.cell.observability.v1.network_policy`
875///
876/// Emitted when `CELLOS_SUBPROCESS_UNSHARE` includes the `net` flag, announcing the
877/// network isolation mode and declared egress rules for this cell run. The isolation is
878/// enforced by the Linux network namespace (no external connectivity without explicit routing).
879/// Optional nftables rules may further filter allowed egress within the namespace.
880pub fn observability_network_policy_data_v1(
881    spec: &ExecutionCellSpec,
882    cell_id: &str,
883    run_id: Option<&str>,
884    isolation_mode: &str,
885    egress_rules: &[crate::EgressRule],
886) -> Result<Value, serde_json::Error> {
887    let mut m = Map::new();
888    m.insert("cellId".to_string(), json!(cell_id));
889    m.insert("specId".to_string(), json!(&spec.id));
890    m.insert("isolationMode".to_string(), json!(isolation_mode));
891    m.insert("declaredEgressCount".to_string(), json!(egress_rules.len()));
892    m.insert(
893        "declaredEgress".to_string(),
894        serde_json::to_value(egress_rules)?,
895    );
896    if let Some(r) = run_id {
897        m.insert("runId".to_string(), json!(r));
898    }
899    if let Some(c) = &spec.correlation {
900        m.insert("correlation".to_string(), serde_json::to_value(c)?);
901    }
902    Ok(Value::Object(m))
903}
904
905/// `dev.cellos.events.cell.observability.v1.network_enforcement`
906///
907/// Emitted after a `spec.run` subprocess exits when the Linux path used `CLONE_NEWNET`, summarizing
908/// whether supplementary nftables rules were applied and the command exit code (L2-04).
909///
910/// Schema: `contracts/schemas/cell-observability-network-enforcement-v1.schema.json`.
911#[allow(clippy::too_many_arguments)]
912pub fn observability_network_enforcement_data_v1(
913    spec: &ExecutionCellSpec,
914    cell_id: &str,
915    run_id: Option<&str>,
916    nft_rules_applied: bool,
917    declared_egress_rule_count: usize,
918    command_exit_code: i32,
919    spawn_error: Option<&str>,
920) -> Result<Value, serde_json::Error> {
921    let supplementary = nft_rules_applied && declared_egress_rule_count > 0;
922    let mut m = Map::new();
923    m.insert("cellId".to_string(), json!(cell_id));
924    m.insert("specId".to_string(), json!(&spec.id));
925    m.insert("isolationMode".to_string(), json!("clone_newnet"));
926    m.insert("nftRulesApplied".to_string(), json!(nft_rules_applied));
927    m.insert(
928        "declaredEgressRuleCount".to_string(),
929        json!(declared_egress_rule_count),
930    );
931    m.insert(
932        "supplementaryEgressFilterActive".to_string(),
933        json!(supplementary),
934    );
935    m.insert("commandExitCode".to_string(), json!(command_exit_code));
936    if let Some(r) = run_id {
937        m.insert("runId".to_string(), json!(r));
938    }
939    if let Some(s) = spawn_error {
940        m.insert("spawnError".to_string(), json!(s));
941    }
942    if let Some(c) = &spec.correlation {
943        m.insert("correlation".to_string(), serde_json::to_value(c)?);
944    }
945    Ok(Value::Object(m))
946}
947
948/// Default `keysetId` when the operator has not published a [`trust-keyset-v1`] document yet.
949///
950/// [`trust-keyset-v1`]: https://cellos.dev/schemas/trust-keyset-v1.schema.json
951pub const TRUST_PLANE_BUILTIN_KEYSET_ID: &str = "cellos:builtin-v0";
952
953/// Default `issuerKid` for resolver-style observability when no published keyset is wired.
954pub const TRUST_PLANE_BUILTIN_RESOLVER_KID: &str = "cellos-local-resolve-v0";
955
956/// Default `issuerKid` for L7 gate observability when no published keyset is wired.
957pub const TRUST_PLANE_BUILTIN_L7_KID: &str = "cellos-local-l7-v0";
958
959/// Synthetic FQDN for aggregate declared-egress materialization events (`dns_target_set`).
960pub const TRUST_PLANE_AGGREGATE_EGRESS_FQDN: &str = "declared-egress.trust.cellos.internal";
961
962/// `dev.cellos.events.cell.observability.v1.dns_resolution`
963///
964/// Schema: `contracts/schemas/cell-observability-dns-resolution-v1.schema.json`.
965#[allow(clippy::too_many_arguments)]
966pub fn observability_dns_resolution_data_v1(
967    spec: &ExecutionCellSpec,
968    cell_id: &str,
969    run_id: Option<&str>,
970    fqdn: &str,
971    resolved_at: &str,
972    targets: &[(&str, &str, Option<u16>)],
973    ttl_seconds: i64,
974    policy_digest: &str,
975    keyset_id: &str,
976    issuer_kid: &str,
977    receipt_id: Option<&str>,
978) -> Result<Value, serde_json::Error> {
979    let mut rows = Vec::with_capacity(targets.len());
980    for (addr, family, port) in targets {
981        let mut row = Map::new();
982        row.insert("address".to_string(), json!(addr));
983        row.insert("family".to_string(), json!(family));
984        if let Some(p) = port {
985            row.insert("port".to_string(), json!(p));
986        }
987        rows.push(Value::Object(row));
988    }
989    let mut m = Map::new();
990    m.insert("cellId".to_string(), json!(cell_id));
991    m.insert("specId".to_string(), json!(&spec.id));
992    if let Some(r) = run_id {
993        m.insert("runId".to_string(), json!(r));
994    }
995    if let Some(rid) = receipt_id {
996        m.insert("receiptId".to_string(), json!(rid));
997    }
998    m.insert("fqdn".to_string(), json!(fqdn));
999    m.insert("resolvedAt".to_string(), json!(resolved_at));
1000    m.insert("targets".to_string(), Value::Array(rows));
1001    m.insert("ttlSeconds".to_string(), json!(ttl_seconds));
1002    m.insert("policyDigest".to_string(), json!(policy_digest));
1003    m.insert("keysetId".to_string(), json!(keyset_id));
1004    m.insert("issuerKid".to_string(), json!(issuer_kid));
1005    if let Some(c) = &spec.correlation {
1006        m.insert("correlation".to_string(), serde_json::to_value(c)?);
1007    }
1008    Ok(Value::Object(m))
1009}
1010
1011/// `dev.cellos.events.cell.observability.v1.dns_target_set`
1012///
1013/// Schema: `contracts/schemas/cell-observability-dns-target-set-v1.schema.json`.
1014#[allow(clippy::too_many_arguments)]
1015pub fn observability_dns_target_set_data_v1(
1016    spec: &ExecutionCellSpec,
1017    cell_id: &str,
1018    run_id: Option<&str>,
1019    fqdn: &str,
1020    previous_digest: &str,
1021    current_digest: &str,
1022    reason: &str,
1023    updated_at: &str,
1024    keyset_id: &str,
1025    issuer_kid: &str,
1026) -> Result<Value, serde_json::Error> {
1027    let mut m = Map::new();
1028    m.insert("cellId".to_string(), json!(cell_id));
1029    m.insert("specId".to_string(), json!(&spec.id));
1030    if let Some(r) = run_id {
1031        m.insert("runId".to_string(), json!(r));
1032    }
1033    m.insert("fqdn".to_string(), json!(fqdn));
1034    m.insert("previousDigest".to_string(), json!(previous_digest));
1035    m.insert("currentDigest".to_string(), json!(current_digest));
1036    m.insert("reason".to_string(), json!(reason));
1037    m.insert("updatedAt".to_string(), json!(updated_at));
1038    m.insert("keysetId".to_string(), json!(keyset_id));
1039    m.insert("issuerKid".to_string(), json!(issuer_kid));
1040    if let Some(c) = &spec.correlation {
1041        m.insert("correlation".to_string(), serde_json::to_value(c)?);
1042    }
1043    Ok(Value::Object(m))
1044}
1045
1046/// `dev.cellos.events.cell.observability.v1.dns_authority_drift`
1047///
1048/// Schema: `contracts/schemas/cell-observability-dns-authority-drift-v1.schema.json`.
1049///
1050/// Emitted by the SEC-21 host-controlled resolver refresh loop when the
1051/// resolved target set for a declared FQDN differs from the prior observation.
1052/// All fields mirror [`DnsAuthorityDrift`] one-for-one; this builder exists so
1053/// supervisor wiring can emit the payload without re-deriving the canonical
1054/// JSON shape from the struct.
1055#[allow(clippy::too_many_arguments)]
1056pub fn dns_authority_drift_data_v1(drift: &DnsAuthorityDrift) -> Result<Value, serde_json::Error> {
1057    serde_json::to_value(drift)
1058}
1059
1060/// CloudEvent envelope constructor for `dns_authority_drift` (SEC-21).
1061///
1062/// Mirrors the supervisor-local `cloud_event(...)` helper but lives in
1063/// `cellos-core` so library callers (resolver refresh module, taudit) can
1064/// build the envelope without taking a supervisor-internal dependency.
1065///
1066/// `source` should identify the publisher (e.g. `"cellos-supervisor"` or
1067/// `"cellos-resolver-refresh"`). `time` is the RFC3339 emission timestamp;
1068/// independent of `drift.observed_at`, which records when the *drift* was
1069/// observed (the two are typically equal but conceptually distinct).
1070pub fn cloud_event_v1_dns_authority_drift(
1071    source: &str,
1072    time: &str,
1073    drift: &DnsAuthorityDrift,
1074) -> Result<CloudEventV1, serde_json::Error> {
1075    Ok(CloudEventV1 {
1076        specversion: "1.0".into(),
1077        id: uuid::Uuid::new_v4().to_string(),
1078        source: source.to_string(),
1079        ty: "dev.cellos.events.cell.observability.v1.dns_authority_drift".into(),
1080        datacontenttype: Some("application/json".into()),
1081        data: Some(dns_authority_drift_data_v1(drift)?),
1082        time: Some(time.to_string()),
1083        traceparent: None,
1084    })
1085}
1086
1087/// `dev.cellos.events.cell.observability.v1.dns_authority_rebind_threshold`
1088/// (SEC-21 Phase 3e).
1089///
1090/// Schema: `contracts/schemas/cell-observability-dns-authority-rebind-threshold-v1.schema.json`.
1091///
1092/// Emitted when the SEC-21 host-controlled resolver refresh observes a novel
1093/// IP for a declared FQDN that pushes the cumulative distinct-IP count past
1094/// the operator-declared `rebindingPolicy.maxNovelIpsPerHostname` cap. All
1095/// fields mirror [`DnsAuthorityRebindThreshold`] one-for-one; this builder
1096/// exists so resolver-refresh wiring can serialize the payload without
1097/// re-deriving the canonical JSON shape from the struct.
1098pub fn dns_authority_rebind_threshold_data_v1(
1099    payload: &DnsAuthorityRebindThreshold,
1100) -> Result<Value, serde_json::Error> {
1101    serde_json::to_value(payload)
1102}
1103
1104/// CloudEvent envelope constructor for `dns_authority_rebind_threshold`
1105/// (SEC-21 Phase 3e).
1106///
1107/// Mirrors [`cloud_event_v1_dns_authority_drift`] for the rebinding-threshold
1108/// signal. Lives in `cellos-core` so the resolver-refresh module can build
1109/// envelopes without taking a supervisor-internal dependency.
1110///
1111/// `source` should identify the publisher (e.g. `"cellos-supervisor"` or
1112/// `"cellos-resolver-refresh"`). `time` is the RFC3339 emission timestamp;
1113/// independent of `payload.observed_at`, which records when the threshold was
1114/// observed (the two are typically equal but conceptually distinct).
1115pub fn cloud_event_v1_dns_authority_rebind_threshold(
1116    source: &str,
1117    time: &str,
1118    payload: &DnsAuthorityRebindThreshold,
1119) -> Result<CloudEventV1, serde_json::Error> {
1120    Ok(CloudEventV1 {
1121        specversion: "1.0".into(),
1122        id: uuid::Uuid::new_v4().to_string(),
1123        source: source.to_string(),
1124        ty: "dev.cellos.events.cell.observability.v1.dns_authority_rebind_threshold".into(),
1125        datacontenttype: Some("application/json".into()),
1126        data: Some(dns_authority_rebind_threshold_data_v1(payload)?),
1127        time: Some(time.to_string()),
1128        traceparent: None,
1129    })
1130}
1131
1132/// `dev.cellos.events.cell.observability.v1.dns_authority_rebind_rejected`
1133/// (SEC-21 Phase 3e).
1134///
1135/// Schema: `contracts/schemas/cell-observability-dns-authority-rebind-rejected-v1.schema.json`.
1136///
1137/// Emitted when the SEC-21 host-controlled resolver refresh observes an IP
1138/// for a declared FQDN that is NOT in the operator's
1139/// `rebindingPolicy.responseIpAllowlist`. Only emitted when the allowlist is
1140/// non-empty for the hostname; one event per offending IP per tick. Combined
1141/// with `rejectOnRebind=true`, the IP is also dropped from the resolved
1142/// target set passed to the workload.
1143pub fn dns_authority_rebind_rejected_data_v1(
1144    payload: &DnsAuthorityRebindRejected,
1145) -> Result<Value, serde_json::Error> {
1146    serde_json::to_value(payload)
1147}
1148
1149/// CloudEvent envelope constructor for `dns_authority_rebind_rejected`
1150/// (SEC-21 Phase 3e).
1151///
1152/// Mirrors [`cloud_event_v1_dns_authority_rebind_threshold`] for the
1153/// allowlist-violation signal. One event per offending IP per tick (so a
1154/// single response containing 3 disallowed IPs produces 3 rejected events).
1155pub fn cloud_event_v1_dns_authority_rebind_rejected(
1156    source: &str,
1157    time: &str,
1158    payload: &DnsAuthorityRebindRejected,
1159) -> Result<CloudEventV1, serde_json::Error> {
1160    Ok(CloudEventV1 {
1161        specversion: "1.0".into(),
1162        id: uuid::Uuid::new_v4().to_string(),
1163        source: source.to_string(),
1164        ty: "dev.cellos.events.cell.observability.v1.dns_authority_rebind_rejected".into(),
1165        datacontenttype: Some("application/json".into()),
1166        data: Some(dns_authority_rebind_rejected_data_v1(payload)?),
1167        time: Some(time.to_string()),
1168        traceparent: None,
1169    })
1170}
1171
1172/// `dev.cellos.events.cell.observability.v1.dns_authority_dnssec_failed`
1173/// (SEC-21 Phase 3h).
1174///
1175/// Schema: `contracts/schemas/cell-observability-dns-authority-dnssec-failed-v1.schema.json`.
1176///
1177/// Emitted by the SEC-21 host-controlled resolver-refresh loop when an opt-in
1178/// DNSSEC-validating resolver returns a response that fails the chain-of-trust
1179/// check (or when the zone is not signed and the operator has opted into
1180/// DNSSEC). All fields mirror [`DnsAuthorityDnssecFailed`] one-for-one; this
1181/// builder exists so resolver-refresh wiring can serialize the payload without
1182/// re-deriving the canonical JSON shape from the struct.
1183pub fn dns_authority_dnssec_failed_data_v1(
1184    payload: &DnsAuthorityDnssecFailed,
1185) -> Result<Value, serde_json::Error> {
1186    serde_json::to_value(payload)
1187}
1188
1189/// CloudEvent envelope constructor for `dns_authority_dnssec_failed`
1190/// (SEC-21 Phase 3h).
1191///
1192/// Mirrors [`cloud_event_v1_dns_authority_drift`] for the DNSSEC-failure
1193/// signal. Lives in `cellos-core` so the resolver-refresh module can build
1194/// envelopes without taking a supervisor-internal dependency.
1195///
1196/// `source` should identify the publisher (e.g. `"cellos-supervisor"` or
1197/// `"cellos-resolver-refresh"`). `time` is the RFC3339 emission timestamp;
1198/// independent of `payload.observed_at`, which records when the failure
1199/// was observed (the two are typically equal but conceptually distinct).
1200pub fn cloud_event_v1_dns_authority_dnssec_failed(
1201    source: &str,
1202    time: &str,
1203    payload: &DnsAuthorityDnssecFailed,
1204) -> Result<CloudEventV1, serde_json::Error> {
1205    Ok(CloudEventV1 {
1206        specversion: "1.0".into(),
1207        id: uuid::Uuid::new_v4().to_string(),
1208        source: source.to_string(),
1209        ty: "dev.cellos.events.cell.observability.v1.dns_authority_dnssec_failed".into(),
1210        datacontenttype: Some("application/json".into()),
1211        data: Some(dns_authority_dnssec_failed_data_v1(payload)?),
1212        time: Some(time.to_string()),
1213        traceparent: None,
1214    })
1215}
1216
1217/// `dev.cellos.events.cell.observability.v1.dns_query`
1218///
1219/// Schema: `contracts/schemas/cell-observability-dns-query-v1.schema.json`.
1220///
1221/// Emitted by the SEAM-1 / L2-04 in-netns DNS proxy on every query — allowed,
1222/// denied, malformed, or upstream-timed-out. All fields mirror [`DnsQueryEvent`]
1223/// one-for-one; this builder exists so dataplane callers can serialize the
1224/// payload without re-deriving the canonical JSON shape from the struct.
1225pub fn dns_query_data_v1(event: &DnsQueryEvent) -> Result<Value, serde_json::Error> {
1226    serde_json::to_value(event)
1227}
1228
1229/// CloudEvent envelope constructor for `dns_query` (SEAM-1 / L2-04).
1230///
1231/// Mirrors [`cloud_event_v1_dns_authority_drift`] for the per-query DNS proxy
1232/// signal. Lives in `cellos-core` so the supervisor's DNS proxy module can
1233/// build envelopes without taking a supervisor-internal dependency.
1234///
1235/// `source` should identify the publisher (e.g. `"cellos-supervisor"` or
1236/// `"cellos-dns-proxy"`). `time` is the RFC3339 emission timestamp; typically
1237/// equal to `event.observed_at` but conceptually distinct (the latter is when
1238/// the proxy *observed* the query).
1239pub fn cloud_event_v1_dns_query(
1240    source: &str,
1241    time: &str,
1242    event: &DnsQueryEvent,
1243) -> Result<CloudEventV1, serde_json::Error> {
1244    Ok(CloudEventV1 {
1245        specversion: "1.0".into(),
1246        id: uuid::Uuid::new_v4().to_string(),
1247        source: source.to_string(),
1248        ty: "dev.cellos.events.cell.observability.v1.dns_query".into(),
1249        datacontenttype: Some("application/json".into()),
1250        data: Some(dns_query_data_v1(event)?),
1251        time: Some(time.to_string()),
1252        traceparent: None,
1253    })
1254}
1255
1256/// SEAM-1 Phase 3 — per-query data payload for
1257/// `dev.cellos.events.cell.dns.v1.query_permitted`.
1258///
1259/// Schema: `contracts/schemas/cell-dns-query-permitted-v1.schema.json`.
1260///
1261/// Companion to the existing aggregate `dns_query` event: this is the
1262/// short-form "the allowlist check accepted this query" signal that fires
1263/// as soon as the proxy decides to forward, BEFORE the upstream answer
1264/// arrives. It exists so operators can audit the workload's query
1265/// intent independently of upstream latency / outcome.
1266///
1267/// `qname` is lowercased and trailing-dot-stripped per the upstream
1268/// parser; `qtype` is the IANA mnemonic (`"A"`, `"AAAA"`, etc); `cell_id`
1269/// mirrors `lifecycle.started.cellId`; `resolver` mirrors the
1270/// `dnsAuthority.resolvers[].resolverId` the proxy will forward to.
1271#[must_use]
1272pub fn dns_query_permitted_data_v1(
1273    qname: &str,
1274    qtype: &str,
1275    cell_id: &str,
1276    resolver: &str,
1277) -> Value {
1278    json!({
1279        "schemaVersion": "1.0.0",
1280        "queryName": qname,
1281        "queryType": qtype,
1282        "cellId": cell_id,
1283        "resolver": resolver,
1284    })
1285}
1286
1287/// CloudEvent envelope constructor for `dns_query_permitted` (SEAM-1 Phase 3).
1288pub fn cloud_event_v1_dns_query_permitted(
1289    source: &str,
1290    time: &str,
1291    qname: &str,
1292    qtype: &str,
1293    cell_id: &str,
1294    resolver: &str,
1295) -> CloudEventV1 {
1296    CloudEventV1 {
1297        specversion: "1.0".into(),
1298        id: uuid::Uuid::new_v4().to_string(),
1299        source: source.to_string(),
1300        ty: "dev.cellos.events.cell.dns.v1.query_permitted".into(),
1301        datacontenttype: Some("application/json".into()),
1302        data: Some(dns_query_permitted_data_v1(qname, qtype, cell_id, resolver)),
1303        time: Some(time.to_string()),
1304        traceparent: None,
1305    }
1306}
1307
1308/// SEAM-1 Phase 3 — per-query data payload for
1309/// `dev.cellos.events.cell.dns.v1.query_refused`.
1310///
1311/// Schema: `contracts/schemas/cell-dns-query-refused-v1.schema.json`.
1312///
1313/// Emitted alongside the existing aggregate `dns_query{decision:deny}`
1314/// event when the allowlist check (or query-type gate) short-circuits a
1315/// workload query with REFUSED. The short-form payload is intended for
1316/// SIEM rules that want to fan out on refusal without parsing the
1317/// richer aggregate event.
1318///
1319/// `reason` is one of the proxy's `reasonCode` enum values — typically
1320/// `"denied_not_in_allowlist"` or `"denied_query_type"`.
1321#[must_use]
1322pub fn dns_query_refused_data_v1(qname: &str, qtype: &str, cell_id: &str, reason: &str) -> Value {
1323    json!({
1324        "schemaVersion": "1.0.0",
1325        "queryName": qname,
1326        "queryType": qtype,
1327        "cellId": cell_id,
1328        "reason": reason,
1329    })
1330}
1331
1332/// CloudEvent envelope constructor for `dns_query_refused` (SEAM-1 Phase 3).
1333pub fn cloud_event_v1_dns_query_refused(
1334    source: &str,
1335    time: &str,
1336    qname: &str,
1337    qtype: &str,
1338    cell_id: &str,
1339    reason: &str,
1340) -> CloudEventV1 {
1341    CloudEventV1 {
1342        specversion: "1.0".into(),
1343        id: uuid::Uuid::new_v4().to_string(),
1344        source: source.to_string(),
1345        ty: "dev.cellos.events.cell.dns.v1.query_refused".into(),
1346        datacontenttype: Some("application/json".into()),
1347        data: Some(dns_query_refused_data_v1(qname, qtype, cell_id, reason)),
1348        time: Some(time.to_string()),
1349        traceparent: None,
1350    }
1351}
1352
1353/// `dev.cellos.events.cell.trust.v1.keyset_verified` (SEC-25 Phase 2).
1354///
1355/// Schema: `contracts/schemas/cell-trust-keyset-verified-v1.schema.json`.
1356///
1357/// Emitted once per supervisor startup when `CELLOS_TRUST_KEYSET_PATH` points
1358/// at a `signed-trust-keyset-envelope-v1` document AND
1359/// `verify_signed_trust_keyset_envelope` accepted at least one signature
1360/// against the operator-loaded keyring (`CELLOS_TRUST_VERIFY_KEYS_PATH`).
1361///
1362/// `correlation_id` is optional; the supervisor passes `None` for the default
1363/// startup-time emission, but other producers may bind a correlation id when
1364/// the verification is part of a larger orchestration flow.
1365pub fn keyset_verified_data_v1(
1366    keyset_id: &str,
1367    payload_digest: &str,
1368    verified_signer_kid: &str,
1369    verified_at: &str,
1370    correlation_id: Option<&str>,
1371) -> Result<Value, serde_json::Error> {
1372    let mut m = Map::new();
1373    m.insert("schemaVersion".to_string(), json!("1.0.0"));
1374    m.insert("keysetId".to_string(), json!(keyset_id));
1375    m.insert("payloadDigest".to_string(), json!(payload_digest));
1376    m.insert("verifiedSignerKid".to_string(), json!(verified_signer_kid));
1377    m.insert("verifiedAt".to_string(), json!(verified_at));
1378    if let Some(cid) = correlation_id {
1379        m.insert("correlationId".to_string(), json!(cid));
1380    }
1381    Ok(Value::Object(m))
1382}
1383
1384/// CloudEvent envelope constructor for `keyset_verified` (SEC-25 Phase 2).
1385///
1386/// `source` should typically be `"cellos-supervisor"` for the startup-time
1387/// emission; sibling tooling (`cellos-trustd` etc.) may publish under a
1388/// different source identifier when verifying envelopes off-host.
1389pub fn cloud_event_v1_keyset_verified(
1390    source: &str,
1391    time: &str,
1392    keyset_id: &str,
1393    payload_digest: &str,
1394    verified_signer_kid: &str,
1395    verified_at: &str,
1396    correlation_id: Option<&str>,
1397) -> Result<CloudEventV1, serde_json::Error> {
1398    Ok(CloudEventV1 {
1399        specversion: "1.0".into(),
1400        id: uuid::Uuid::new_v4().to_string(),
1401        source: source.to_string(),
1402        ty: "dev.cellos.events.cell.trust.v1.keyset_verified".into(),
1403        datacontenttype: Some("application/json".into()),
1404        data: Some(keyset_verified_data_v1(
1405            keyset_id,
1406            payload_digest,
1407            verified_signer_kid,
1408            verified_at,
1409            correlation_id,
1410        )?),
1411        time: Some(time.to_string()),
1412        traceparent: None,
1413    })
1414}
1415
1416/// `dev.cellos.events.cell.trust.v1.keyset_verification_failed` (SEC-25 Phase 2).
1417///
1418/// Schema: `contracts/schemas/cell-trust-keyset-verification-failed-v1.schema.json`.
1419///
1420/// Emitted once per supervisor startup when `CELLOS_TRUST_KEYSET_PATH` is set
1421/// AND verification fails (file open / JSON parse / digest mismatch / no
1422/// signature verified). Under `CELLOS_REQUIRE_TRUST_VERIFY_KEYS=1` the
1423/// supervisor instead returns an error from `build_supervisor` and never
1424/// emits this event — fail-closed startup is the explicit operator opt-in;
1425/// fail-open is the default and surfaces this event so degraded trust posture
1426/// is visible in the audit stream.
1427///
1428/// `attempted_keyset_path` MUST be the file's basename (the supervisor's
1429/// emitter strips the directory portion to avoid leaking deployment-side
1430/// metadata into the event stream — see the schema description).
1431pub fn keyset_verification_failed_data_v1(
1432    attempted_keyset_path: &str,
1433    reason: &str,
1434    failed_at: &str,
1435    correlation_id: Option<&str>,
1436) -> Result<Value, serde_json::Error> {
1437    let mut m = Map::new();
1438    m.insert("schemaVersion".to_string(), json!("1.0.0"));
1439    m.insert(
1440        "attemptedKeysetPath".to_string(),
1441        json!(attempted_keyset_path),
1442    );
1443    m.insert("reason".to_string(), json!(reason));
1444    m.insert("failedAt".to_string(), json!(failed_at));
1445    if let Some(cid) = correlation_id {
1446        m.insert("correlationId".to_string(), json!(cid));
1447    }
1448    Ok(Value::Object(m))
1449}
1450
1451/// CloudEvent envelope constructor for `keyset_verification_failed` (SEC-25 Phase 2).
1452pub fn cloud_event_v1_keyset_verification_failed(
1453    source: &str,
1454    time: &str,
1455    attempted_keyset_path: &str,
1456    reason: &str,
1457    failed_at: &str,
1458    correlation_id: Option<&str>,
1459) -> Result<CloudEventV1, serde_json::Error> {
1460    Ok(CloudEventV1 {
1461        specversion: "1.0".into(),
1462        id: uuid::Uuid::new_v4().to_string(),
1463        source: source.to_string(),
1464        ty: "dev.cellos.events.cell.trust.v1.keyset_verification_failed".into(),
1465        datacontenttype: Some("application/json".into()),
1466        data: Some(keyset_verification_failed_data_v1(
1467            attempted_keyset_path,
1468            reason,
1469            failed_at,
1470            correlation_id,
1471        )?),
1472        time: Some(time.to_string()),
1473        traceparent: None,
1474    })
1475}
1476
1477/// `dev.cellos.events.cell.observability.v1.network_flow_decision` (FC-38 Phase 1).
1478///
1479/// Schema: `contracts/schemas/cell-observability-network-flow-decision-v1.schema.json`.
1480///
1481/// Emitted by the supervisor's post-run nft counter scan
1482/// (`nft list ruleset --json`) when `CELLOS_PER_FLOW_ENFORCEMENT_EVENTS=1`.
1483/// scope: honest "policy-applied attribution" — one event per matched or
1484/// applied rule, NOT a real-time per-packet stream. Future eBPF / nflog
1485/// work targets real-time per-flow events.
1486///
1487/// All fields mirror [`NetworkFlowDecision`] one-for-one; this builder exists
1488/// so supervisor wiring can emit the payload without re-deriving the canonical
1489/// JSON shape from the struct.
1490pub fn network_flow_decision_data_v1(
1491    decision: &NetworkFlowDecision,
1492) -> Result<Value, serde_json::Error> {
1493    serde_json::to_value(decision)
1494}
1495
1496/// CloudEvent envelope constructor for `network_flow_decision` (FC-38 Phase 1).
1497///
1498/// Mirrors [`cloud_event_v1_dns_authority_drift`] for the per-flow signal.
1499/// Lives in `cellos-core` so the supervisor's post-run counter-scan path can
1500/// build envelopes without taking a supervisor-internal dependency.
1501///
1502/// `source` should identify the publisher (e.g. `"cellos-supervisor"`).
1503/// `time` is the RFC3339 emission timestamp; typically equal to
1504/// `decision.observed_at` but conceptually distinct (the latter is when the
1505/// supervisor *scraped* the counter, the former when the envelope was built).
1506pub fn cloud_event_v1_network_flow_decision(
1507    source: &str,
1508    time: &str,
1509    decision: &NetworkFlowDecision,
1510) -> Result<CloudEventV1, serde_json::Error> {
1511    Ok(CloudEventV1 {
1512        specversion: "1.0".into(),
1513        id: uuid::Uuid::new_v4().to_string(),
1514        source: source.to_string(),
1515        ty: "dev.cellos.events.cell.observability.v1.network_flow_decision".into(),
1516        datacontenttype: Some("application/json".into()),
1517        data: Some(network_flow_decision_data_v1(decision)?),
1518        time: Some(time.to_string()),
1519        traceparent: None,
1520    })
1521}
1522
1523/// `dev.cellos.events.cell.observability.v1.l7_egress_decision`
1524///
1525/// Schema: `contracts/schemas/cell-observability-l7-egress-decision-v1.schema.json`.
1526#[allow(clippy::too_many_arguments)]
1527pub fn observability_l7_egress_decision_data_v1(
1528    spec: &ExecutionCellSpec,
1529    cell_id: &str,
1530    run_id: Option<&str>,
1531    decision_id: &str,
1532    action: &str,
1533    sni_host: &str,
1534    policy_digest: &str,
1535    keyset_id: &str,
1536    issuer_kid: &str,
1537    reason_code: &str,
1538    rule_ref: Option<&str>,
1539    // scope: per-stream correlation id for h2 paths. `None` for
1540    // SNI / HTTP/1.x / unknown / peek-timeout paths. Additive — the
1541    // schema's `streamId` field is optional so the field is omitted
1542    // from the output when `None`.
1543    stream_id: Option<u32>,
1544) -> Result<Value, serde_json::Error> {
1545    let mut m = Map::new();
1546    m.insert("cellId".to_string(), json!(cell_id));
1547    m.insert("specId".to_string(), json!(&spec.id));
1548    if let Some(r) = run_id {
1549        m.insert("runId".to_string(), json!(r));
1550    }
1551    m.insert("decisionId".to_string(), json!(decision_id));
1552    m.insert("action".to_string(), json!(action));
1553    m.insert("sniHost".to_string(), json!(sni_host));
1554    m.insert("policyDigest".to_string(), json!(policy_digest));
1555    m.insert("keysetId".to_string(), json!(keyset_id));
1556    m.insert("issuerKid".to_string(), json!(issuer_kid));
1557    m.insert("reasonCode".to_string(), json!(reason_code));
1558    if let Some(rr) = rule_ref {
1559        m.insert("ruleRef".to_string(), json!(rr));
1560    }
1561    if let Some(sid) = stream_id {
1562        m.insert("streamId".to_string(), json!(sid));
1563    }
1564    if let Some(c) = &spec.correlation {
1565        m.insert("correlation".to_string(), serde_json::to_value(c)?);
1566    }
1567    Ok(Value::Object(m))
1568}
1569
1570/// `dev.cellos.events.cell.observability.v1.container_security`
1571///
1572/// Emitted once per cell run immediately after `lifecycle.started`, recording
1573/// the Linux capability bitmasks read from `/proc/self/status` at supervisor
1574/// start time.  This gives the audit trail a concrete answer to "what
1575/// capabilities did the cell have?" rather than "deepce could not determine
1576/// capabilities because capsh was not installed."
1577///
1578/// Fields:
1579/// - `capEff`  — effective capability bitmask (hex string, e.g. `"00000000a80425fb"`)
1580/// - `capPrm`  — permitted capability bitmask
1581/// - `capBnd`  — bounding set bitmask
1582/// - `capAmb`  — ambient capability bitmask
1583/// - `capInh`  — inheritable capability bitmask
1584/// - `privileged` — `true` when `capEff` equals `0x1ffffffffff` (all 40 caps set)
1585///
1586/// All fields are `None`/omitted on non-Linux hosts or when `/proc/self/status`
1587/// is unreadable.  Consumers should treat absence as "unknown," not "clean."
1588pub fn observability_container_security_data_v1(
1589    cell_id: &str,
1590    run_id: Option<&str>,
1591    cap_eff: Option<&str>,
1592    cap_prm: Option<&str>,
1593    cap_bnd: Option<&str>,
1594    cap_amb: Option<&str>,
1595    cap_inh: Option<&str>,
1596) -> Value {
1597    let mut m = Map::new();
1598    m.insert("cellId".to_string(), json!(cell_id));
1599    if let Some(r) = run_id {
1600        m.insert("runId".to_string(), json!(r));
1601    }
1602    if let (Some(eff), Some(prm), Some(bnd), Some(amb), Some(inh)) =
1603        (cap_eff, cap_prm, cap_bnd, cap_amb, cap_inh)
1604    {
1605        m.insert("capEff".to_string(), json!(eff));
1606        m.insert("capPrm".to_string(), json!(prm));
1607        m.insert("capBnd".to_string(), json!(bnd));
1608        m.insert("capAmb".to_string(), json!(amb));
1609        m.insert("capInh".to_string(), json!(inh));
1610        // Full privileged set: all 40 defined capabilities (Linux 5.x bitmask).
1611        let privileged = eff == "0000001fffffffff";
1612        m.insert("privileged".to_string(), json!(privileged));
1613    }
1614    Value::Object(m)
1615}
1616
1617/// `dev.cellos.events.cell.compliance.v1.summary`
1618///
1619/// Emitted once per cell run immediately before `lifecycle.destroyed`.
1620/// Captures the compliance-relevant profile of the run: which policy pack
1621/// governed it, what security controls were declared, and the final command
1622/// exit code (when available).
1623///
1624/// This event is the canonical compliance receipt — it allows fleet operators
1625/// and auditors to reconstruct "what policy was in effect and what was
1626/// observed" for every cell run without replaying the full event stream.
1627///
1628/// Schema: `contracts/schemas/cell-compliance-summary-v1.schema.json`.
1629///
1630/// Backward-compatible thin delegate to
1631/// [`compliance_summary_data_v1_with_subjects`] with an empty subject set.
1632/// Existing callers (supervisor and tests) keep their 4-arg signature; the
1633/// resulting payload omits `subjectUrns` per the schema's "absent when empty"
1634/// contract.
1635pub fn compliance_summary_data_v1(
1636    spec: &ExecutionCellSpec,
1637    cell_id: &str,
1638    run_id: Option<&str>,
1639    command_exit_code: Option<i32>,
1640) -> Result<Value, serde_json::Error> {
1641    compliance_summary_data_v1_with_subjects(spec, cell_id, run_id, command_exit_code, &[])
1642}
1643
1644/// Seam-freeze G4 / P0-7 variant of [`compliance_summary_data_v1`] that
1645/// attaches a typed-URN subject set covered by the compliance receipt.
1646///
1647/// `subject_urns` SHOULD be the concrete URNs (cells, leases, exports, …)
1648/// whose lifecycle this compliance.summary attests to. Callers pass `&[]`
1649/// for the legacy "no cross-pointer" shape, which is exactly what the
1650/// thin-delegate [`compliance_summary_data_v1`] does.
1651///
1652/// The `subjectUrns` field is **only emitted when non-empty** — schema
1653/// declares `minItems: 1` and `uniqueItems: true`, so emitting an empty array
1654/// would fail validation. URN shape validation lives in the schema (regex);
1655/// this function does not pre-validate to keep the API allocation-free.
1656///
1657/// Schema: `contracts/schemas/cell-compliance-summary-v1.schema.json`
1658/// (`subjectUrns` field).
1659pub fn compliance_summary_data_v1_with_subjects(
1660    spec: &ExecutionCellSpec,
1661    cell_id: &str,
1662    run_id: Option<&str>,
1663    command_exit_code: Option<i32>,
1664    subject_urns: &[SubjectUrn],
1665) -> Result<Value, serde_json::Error> {
1666    let egress_rule_count = spec
1667        .authority
1668        .egress_rules
1669        .as_ref()
1670        .map(|v| v.len())
1671        .unwrap_or(0);
1672    let export_target_count = spec
1673        .export
1674        .as_ref()
1675        .and_then(|e| e.targets.as_ref())
1676        .map(|t| t.len())
1677        .unwrap_or(0);
1678    let resource_limits_present = spec.run.as_ref().and_then(|r| r.limits.as_ref()).is_some();
1679    let secret_delivery_mode = spec
1680        .run
1681        .as_ref()
1682        .map(|r| serde_json::to_value(&r.secret_delivery))
1683        .transpose()?
1684        .unwrap_or(serde_json::Value::String("env".into()));
1685
1686    let mut m = Map::new();
1687    m.insert("cellId".to_string(), json!(cell_id));
1688    m.insert("specId".to_string(), json!(&spec.id));
1689    m.insert(
1690        "lifetimeTtlSeconds".to_string(),
1691        json!(spec.lifetime.ttl_seconds),
1692    );
1693    m.insert("egressRuleCount".to_string(), json!(egress_rule_count));
1694    m.insert(
1695        "resourceLimitsPresent".to_string(),
1696        json!(resource_limits_present),
1697    );
1698    m.insert("secretDeliveryMode".to_string(), secret_delivery_mode);
1699    m.insert("exportTargetCount".to_string(), json!(export_target_count));
1700
1701    // Policy attribution — absent when no pack was declared on the spec.
1702    if let Some(policy) = &spec.policy {
1703        if let Some(id) = &policy.pack_id {
1704            m.insert("policyPackId".to_string(), json!(id));
1705        }
1706        if let Some(ver) = &policy.pack_version {
1707            m.insert("policyPackVersion".to_string(), json!(ver));
1708        }
1709        if let Some(digest) = &policy.bundle_digest {
1710            m.insert("policyBundleDigest".to_string(), json!(digest));
1711        }
1712    }
1713
1714    if let Some(placement) = &spec.placement {
1715        let mut placement_map = Map::new();
1716        if let Some(pool_id) = &placement.pool_id {
1717            placement_map.insert("poolId".to_string(), json!(pool_id));
1718        }
1719        if let Some(namespace) = &placement.kubernetes_namespace {
1720            placement_map.insert("kubernetesNamespace".to_string(), json!(namespace));
1721        }
1722        if let Some(queue_name) = &placement.queue_name {
1723            placement_map.insert("queueName".to_string(), json!(queue_name));
1724        }
1725        if !placement_map.is_empty() {
1726            m.insert("placement".to_string(), Value::Object(placement_map));
1727        }
1728    }
1729
1730    if let Some(code) = command_exit_code {
1731        m.insert("commandExitCode".to_string(), json!(code));
1732    }
1733    if let Some(r) = run_id {
1734        m.insert("runId".to_string(), json!(r));
1735    }
1736    if let Some(c) = &spec.correlation {
1737        m.insert("correlation".to_string(), serde_json::to_value(c)?);
1738    }
1739
1740    // Seam-freeze G4 / P0-7 cross-pointer. Emit only when non-empty so the
1741    // legacy 4-arg path (and any future "no subjects" caller) keeps producing
1742    // a payload that round-trips against the existing valid example.
1743    if !subject_urns.is_empty() {
1744        let urns: Vec<Value> = subject_urns.iter().map(|u| json!(u)).collect();
1745        m.insert("subjectUrns".to_string(), Value::Array(urns));
1746    }
1747
1748    Ok(Value::Object(m))
1749}
1750
1751/// `data` for event type `dev.cellos.events.cell.policy.v1.rejected`.
1752///
1753/// Emitted when a policy pack's admission gate rejects a cell before the
1754/// lifecycle starts.  The `violations` array lists every failed rule, giving
1755/// operators a complete picture of what must change.
1756///
1757/// Schema: `contracts/schemas/cell-policy-rejected-v1.schema.json`.
1758pub fn policy_rejected_data_v1(
1759    spec: &ExecutionCellSpec,
1760    violations: &[PolicyViolation],
1761) -> Result<Value, serde_json::Error> {
1762    let violation_values: Vec<Value> = violations
1763        .iter()
1764        .map(|v| {
1765            json!({
1766                "rule": v.rule,
1767                "message": v.message,
1768            })
1769        })
1770        .collect();
1771
1772    let mut m = Map::new();
1773    m.insert("specId".to_string(), json!(&spec.id));
1774    m.insert("violationCount".to_string(), json!(violations.len()));
1775    m.insert("violations".to_string(), Value::Array(violation_values));
1776
1777    // Policy attribution — absent when no pack was declared on the spec.
1778    if let Some(policy) = &spec.policy {
1779        if let Some(id) = &policy.pack_id {
1780            m.insert("policyPackId".to_string(), json!(id));
1781        }
1782        if let Some(ver) = &policy.pack_version {
1783            m.insert("policyPackVersion".to_string(), json!(ver));
1784        }
1785    }
1786
1787    if let Some(c) = &spec.correlation {
1788        m.insert("correlation".to_string(), serde_json::to_value(c)?);
1789    }
1790    Ok(Value::Object(m))
1791}
1792
1793/// T12 RBAC — `data` for event type `dev.cellos.events.cell.authz.v1.rejected`.
1794///
1795/// Emitted by the supervisor when the operator's `AuthorizationPolicy`
1796/// (loaded from `CELLOS_AUTHZ_POLICY_PATH`) rejects a cell spec at
1797/// admission. Sibling event to `cell.policy.v1.rejected`, with a different
1798/// rule namespace (authz subject/pool/pack vs policy-pack admission). Both
1799/// gates can fire independently.
1800///
1801/// `reason` is one of the strings declared in the schema:
1802/// `subject_not_authorized`, `pool_not_allowed`, `policy_pack_not_allowed`,
1803/// `rate_limit_exceeded`. Schema:
1804/// `contracts/schemas/cell-authz-rejected-v1.schema.json`.
1805pub fn authz_rejected_data_v1(
1806    spec: &ExecutionCellSpec,
1807    reason: &str,
1808    message: &str,
1809    denied_pool_id: Option<&str>,
1810    denied_policy_pack_id: Option<&str>,
1811) -> Result<Value, serde_json::Error> {
1812    let mut m = Map::new();
1813    m.insert("specId".to_string(), json!(&spec.id));
1814    m.insert("reason".to_string(), json!(reason));
1815    m.insert("message".to_string(), json!(message));
1816
1817    let tenant_id = spec
1818        .correlation
1819        .as_ref()
1820        .and_then(|c| c.tenant_id.as_deref());
1821    if let Some(t) = tenant_id {
1822        m.insert("tenantId".to_string(), json!(t));
1823        // 1.0 subject axis: subject == tenantId. Mirrored as a separate field
1824        // so consumers don't need to reach into correlation, and so future
1825        // subject axes (oidc, k8s) can replace it without breaking the
1826        // event shape.
1827        m.insert("subject".to_string(), json!(t));
1828    }
1829
1830    if let Some(p) = denied_pool_id {
1831        m.insert("deniedPoolId".to_string(), json!(p));
1832    }
1833    if let Some(p) = denied_policy_pack_id {
1834        m.insert("deniedPolicyPackId".to_string(), json!(p));
1835    }
1836
1837    if let Some(c) = &spec.correlation {
1838        m.insert("correlation".to_string(), serde_json::to_value(c)?);
1839    }
1840    Ok(Value::Object(m))
1841}
1842
1843/// `dev.cellos.events.cell.authority.v1.homeostasis`
1844///
1845/// Emitted once per run between `compliance.summary` and `lifecycle.destroyed`.
1846/// Compares declared vs exercised authority. Aggregation across runs belongs in taudit.
1847///
1848/// Schema: `contracts/schemas/cell-authority-homeostasis-v1.schema.json`.
1849pub fn homeostasis_signal_data_v1(
1850    spec: &ExecutionCellSpec,
1851    cell_id: &str,
1852    run_id: Option<&str>,
1853    signal: &crate::HomeostasisSignal,
1854) -> Result<Value, serde_json::Error> {
1855    let mut m = Map::new();
1856    m.insert("cellId".to_string(), json!(cell_id));
1857    m.insert("specId".to_string(), json!(&spec.id));
1858    m.insert("specHash".to_string(), json!(&signal.spec_hash));
1859    m.insert(
1860        "declaredEgressRules".to_string(),
1861        json!(signal.declared_egress_rules),
1862    );
1863    // E2-06: exercisedEgressConnections is nullable while real telemetry (L2-04 / L5-15)
1864    // is wired. When the count is None, emit JSON `null` AND a sibling
1865    // `exercisedEgressReason` so consumers can distinguish "telemetry pending" from a
1866    // true zero.
1867    m.insert(
1868        "exercisedEgressConnections".to_string(),
1869        match signal.exercised_egress_connections {
1870            Some(n) => json!(n),
1871            None => Value::Null,
1872        },
1873    );
1874    if let Some(reason) = &signal.exercised_egress_reason {
1875        m.insert("exercisedEgressReason".to_string(), json!(reason));
1876    }
1877    m.insert(
1878        "declaredMountPaths".to_string(),
1879        json!(signal.declared_mount_paths),
1880    );
1881    m.insert(
1882        "accessedMountPaths".to_string(),
1883        json!(signal.accessed_mount_paths),
1884    );
1885    m.insert(
1886        "declaredSecretCount".to_string(),
1887        json!(signal.declared_secret_count),
1888    );
1889    m.insert(
1890        "authorityEfficiency".to_string(),
1891        json!(signal.authority_efficiency),
1892    );
1893    m.insert(
1894        "recommendedRemovals".to_string(),
1895        serde_json::to_value(&signal.recommended_removals)?,
1896    );
1897    if let Some(r) = run_id {
1898        m.insert("runId".to_string(), json!(r));
1899    }
1900    if let Some(c) = &spec.correlation {
1901        m.insert("correlation".to_string(), serde_json::to_value(c)?);
1902    }
1903    Ok(Value::Object(m))
1904}
1905
1906/// `dev.cellos.events.cell.authority.v1.homeostasis_violation`
1907///
1908/// Emitted when exercised egress connections exceed declared authority — a
1909/// security invariant violation. The workload made connections beyond what
1910/// it declared it would need, which means either (a) the spec under-declares
1911/// the workload's real needs, or (b) the nftables/eBPF enforcement layer did
1912/// not prevent connections that policy rejected.
1913///
1914/// Pure JSON constructor (no I/O). Callers compute `overage` outside this
1915/// helper and pass `exercised`; this builder MUST NOT underflow, so the
1916/// caller is responsible for guaranteeing `exercised >= declared` before
1917/// invocation. The supervisor emit site guards on `exercised > declared`.
1918///
1919/// Schema: `contracts/schemas/cell-authority-homeostasis-violation-v1.schema.json`.
1920pub fn homeostasis_violation_data_v1(
1921    cell_id: &str,
1922    declared_egress: u64,
1923    exercised_egress: u64,
1924    spec_hash: &str,
1925) -> Value {
1926    let mut m = Map::new();
1927    m.insert("cellId".to_string(), json!(cell_id));
1928    m.insert(
1929        "declaredEgressRuleCount".to_string(),
1930        json!(declared_egress),
1931    );
1932    m.insert(
1933        "exercisedEgressConnections".to_string(),
1934        json!(exercised_egress),
1935    );
1936    // Caller guarantees exercised >= declared; saturating_sub protects against
1937    // accidental misuse without panicking.
1938    m.insert(
1939        "overage".to_string(),
1940        json!(exercised_egress.saturating_sub(declared_egress)),
1941    );
1942    m.insert("specHash".to_string(), json!(spec_hash));
1943    m.insert("severity".to_string(), json!("critical"));
1944    Value::Object(m)
1945}
1946
1947// ---------------------------------------------------------------------------
1948// F1b — Path B host-side observability primitives + evidence_bundle (ADR-0006)
1949//
1950// Builders below construct the JSON `data` payload for the new
1951// `dev.cellos.events.cell.observability.host.v1.*` event types and the
1952// per-cell `dev.cellos.events.cell.evidence_bundle.v1.emitted` aggregation
1953// primitive ("the 1.0 deliverable" — ADR-0006 §4). These are pure JSON
1954// constructors; sampling and emission live in `cellos-host-telemetry` and
1955// `cellos-supervisor` (slot F1a).
1956//
1957// All five host-probe builders carry a `spec_signature_hash` that the
1958// supervisor host-stamps before emit (ADR-0006 §6). The evidence_bundle
1959// builder requires both `spec_signature_hash` and `cell_destroyed_event_ref`
1960// — the latter enforces D5 destruction-evidence integration: a bundle
1961// without a `cell.lifecycle.v1.destroyed` reference is structurally invalid
1962// (see schema gate in `contracts/schemas/evidence-bundle-v1.schema.json`).
1963// ---------------------------------------------------------------------------
1964
1965/// `dev.cellos.events.cell.observability.host.v1.fc_metrics`
1966///
1967/// Per-cell snapshot of Firecracker metrics endpoint counters (KVM exits,
1968/// vsock bytes, block ops). Path B (ADR-0006).
1969///
1970/// Schema: `contracts/schemas/cell-observability-host-fc-metrics-v1.schema.json`.
1971#[allow(clippy::too_many_arguments)]
1972pub fn observability_host_fc_metrics_data_v1(
1973    spec: &ExecutionCellSpec,
1974    cell_id: &str,
1975    run_id: Option<&str>,
1976    spec_signature_hash: &str,
1977    sampled_at_unix_ms: u64,
1978    fc_socket_path: &str,
1979    vcpu_exits_total: Option<u64>,
1980    vsock_tx_bytes: Option<u64>,
1981    vsock_rx_bytes: Option<u64>,
1982    block_read_ops: Option<u64>,
1983    block_write_ops: Option<u64>,
1984    sample_error: Option<&str>,
1985) -> Result<Value, serde_json::Error> {
1986    let mut m = Map::new();
1987    m.insert("cellId".to_string(), json!(cell_id));
1988    m.insert("specId".to_string(), json!(&spec.id));
1989    m.insert("specSignatureHash".to_string(), json!(spec_signature_hash));
1990    m.insert("sampledAtUnixMs".to_string(), json!(sampled_at_unix_ms));
1991    m.insert("fcSocketPath".to_string(), json!(fc_socket_path));
1992    if let Some(v) = vcpu_exits_total {
1993        m.insert("vcpuExitsTotal".to_string(), json!(v));
1994    }
1995    if let Some(v) = vsock_tx_bytes {
1996        m.insert("vsockTxBytes".to_string(), json!(v));
1997    }
1998    if let Some(v) = vsock_rx_bytes {
1999        m.insert("vsockRxBytes".to_string(), json!(v));
2000    }
2001    if let Some(v) = block_read_ops {
2002        m.insert("blockReadOps".to_string(), json!(v));
2003    }
2004    if let Some(v) = block_write_ops {
2005        m.insert("blockWriteOps".to_string(), json!(v));
2006    }
2007    if let Some(e) = sample_error {
2008        m.insert("sampleError".to_string(), json!(e));
2009    }
2010    if let Some(r) = run_id {
2011        m.insert("runId".to_string(), json!(r));
2012    }
2013    if let Some(c) = &spec.correlation {
2014        m.insert("correlation".to_string(), serde_json::to_value(c)?);
2015    }
2016    Ok(Value::Object(m))
2017}
2018
2019/// One memory.events / cpu.stat / pids.events sample for
2020/// [`observability_host_cgroup_data_v1`].
2021#[derive(Debug, Default, Clone)]
2022pub struct CgroupSample<'a> {
2023    pub memory_events: Option<&'a [(&'a str, u64)]>,
2024    pub cpu_stat: Option<&'a [(&'a str, u64)]>,
2025    pub pids_events: Option<&'a [(&'a str, u64)]>,
2026}
2027
2028/// `dev.cellos.events.cell.observability.host.v1.cgroup`
2029///
2030/// Per-cell cgroup v2 counter snapshot (memory.events, cpu.stat,
2031/// pids.events). Path B (ADR-0006).
2032///
2033/// `sample` carries the kernel-reported counter pairs the supervisor read
2034/// from `/sys/fs/cgroup/<cell-leaf>/`. Field names map directly to the
2035/// schema's nested objects; unknown keys are silently dropped at emit time
2036/// because the schema's `additionalProperties: false` would reject them.
2037///
2038/// Schema: `contracts/schemas/cell-observability-host-cgroup-v1.schema.json`.
2039fn cgroup_section(keys: &[&str], pairs: Option<&[(&str, u64)]>) -> Option<Value> {
2040    let pairs = pairs?;
2041    let mut section = Map::new();
2042    for (k, v) in pairs {
2043        if keys.contains(k) {
2044            section.insert((*k).to_string(), json!(v));
2045        }
2046    }
2047    if section.is_empty() {
2048        None
2049    } else {
2050        Some(Value::Object(section))
2051    }
2052}
2053
2054/// `dev.cellos.events.cell.observability.host.v1.cgroup` (see [`CgroupSample`]).
2055#[allow(clippy::too_many_arguments)]
2056pub fn observability_host_cgroup_data_v1(
2057    spec: &ExecutionCellSpec,
2058    cell_id: &str,
2059    run_id: Option<&str>,
2060    spec_signature_hash: &str,
2061    sampled_at_unix_ms: u64,
2062    cgroup_path: &str,
2063    sample: &CgroupSample<'_>,
2064    sample_error: Option<&str>,
2065) -> Result<Value, serde_json::Error> {
2066    const MEM_KEYS: &[&str] = &["low", "high", "max", "oom", "oomKill"];
2067    const CPU_KEYS: &[&str] = &[
2068        "usageUsec",
2069        "userUsec",
2070        "systemUsec",
2071        "nrPeriods",
2072        "nrThrottled",
2073        "throttledUsec",
2074    ];
2075    const PIDS_KEYS: &[&str] = &["max"];
2076
2077    let mut m = Map::new();
2078    m.insert("cellId".to_string(), json!(cell_id));
2079    m.insert("specId".to_string(), json!(&spec.id));
2080    m.insert("specSignatureHash".to_string(), json!(spec_signature_hash));
2081    m.insert("sampledAtUnixMs".to_string(), json!(sampled_at_unix_ms));
2082    m.insert("cgroupPath".to_string(), json!(cgroup_path));
2083    if let Some(v) = cgroup_section(MEM_KEYS, sample.memory_events) {
2084        m.insert("memoryEvents".to_string(), v);
2085    }
2086    if let Some(v) = cgroup_section(CPU_KEYS, sample.cpu_stat) {
2087        m.insert("cpuStat".to_string(), v);
2088    }
2089    if let Some(v) = cgroup_section(PIDS_KEYS, sample.pids_events) {
2090        m.insert("pidsEvents".to_string(), v);
2091    }
2092    if let Some(e) = sample_error {
2093        m.insert("sampleError".to_string(), json!(e));
2094    }
2095    if let Some(r) = run_id {
2096        m.insert("runId".to_string(), json!(r));
2097    }
2098    if let Some(c) = &spec.correlation {
2099        m.insert("correlation".to_string(), serde_json::to_value(c)?);
2100    }
2101    Ok(Value::Object(m))
2102}
2103
2104/// One nft rule counter row for [`observability_host_nftables_data_v1`].
2105#[derive(Debug, Clone)]
2106pub struct NftRuleCounter<'a> {
2107    pub rule_handle: &'a str,
2108    pub verdict: Option<&'a str>,
2109    pub packets: u64,
2110    pub bytes: u64,
2111    pub r#match: Option<&'a str>,
2112}
2113
2114/// `dev.cellos.events.cell.observability.host.v1.nftables`
2115///
2116/// Per-cell nftables counter snapshot scraped from the cell's netns. Path B
2117/// (ADR-0006).
2118///
2119/// Schema: `contracts/schemas/cell-observability-host-nftables-v1.schema.json`.
2120#[allow(clippy::too_many_arguments)]
2121pub fn observability_host_nftables_data_v1(
2122    spec: &ExecutionCellSpec,
2123    cell_id: &str,
2124    run_id: Option<&str>,
2125    spec_signature_hash: &str,
2126    sampled_at_unix_ms: u64,
2127    table_name: &str,
2128    rule_counters: &[NftRuleCounter<'_>],
2129    sample_error: Option<&str>,
2130) -> Result<Value, serde_json::Error> {
2131    let mut m = Map::new();
2132    m.insert("cellId".to_string(), json!(cell_id));
2133    m.insert("specId".to_string(), json!(&spec.id));
2134    m.insert("specSignatureHash".to_string(), json!(spec_signature_hash));
2135    m.insert("sampledAtUnixMs".to_string(), json!(sampled_at_unix_ms));
2136    m.insert("tableName".to_string(), json!(table_name));
2137    let counters: Vec<Value> = rule_counters
2138        .iter()
2139        .map(|c| {
2140            let mut row = Map::new();
2141            row.insert("ruleHandle".to_string(), json!(c.rule_handle));
2142            if let Some(v) = c.verdict {
2143                row.insert("verdict".to_string(), json!(v));
2144            }
2145            row.insert("packets".to_string(), json!(c.packets));
2146            row.insert("bytes".to_string(), json!(c.bytes));
2147            if let Some(mt) = c.r#match {
2148                row.insert("match".to_string(), json!(mt));
2149            }
2150            Value::Object(row)
2151        })
2152        .collect();
2153    m.insert("ruleCounters".to_string(), Value::Array(counters));
2154    if let Some(e) = sample_error {
2155        m.insert("sampleError".to_string(), json!(e));
2156    }
2157    if let Some(r) = run_id {
2158        m.insert("runId".to_string(), json!(r));
2159    }
2160    if let Some(c) = &spec.correlation {
2161        m.insert("correlation".to_string(), serde_json::to_value(c)?);
2162    }
2163    Ok(Value::Object(m))
2164}
2165
2166/// Per-TAP statistics row for [`observability_host_tap_data_v1`].
2167#[derive(Debug, Default, Clone, Copy)]
2168pub struct TapStats {
2169    pub rx_packets: Option<u64>,
2170    pub tx_packets: Option<u64>,
2171    pub rx_bytes: Option<u64>,
2172    pub tx_bytes: Option<u64>,
2173    pub rx_errors: Option<u64>,
2174    pub tx_errors: Option<u64>,
2175    pub rx_dropped: Option<u64>,
2176    pub tx_dropped: Option<u64>,
2177}
2178
2179/// `dev.cellos.events.cell.observability.host.v1.tap`
2180///
2181/// Per-cell TAP-link snapshot for the FC microVM tap device. Path B
2182/// (ADR-0006).
2183///
2184/// Schema: `contracts/schemas/cell-observability-host-tap-v1.schema.json`.
2185#[allow(clippy::too_many_arguments)]
2186pub fn observability_host_tap_data_v1(
2187    spec: &ExecutionCellSpec,
2188    cell_id: &str,
2189    run_id: Option<&str>,
2190    spec_signature_hash: &str,
2191    sampled_at_unix_ms: u64,
2192    tap_name: &str,
2193    link_state: &str,
2194    stats: &TapStats,
2195    sample_error: Option<&str>,
2196) -> Result<Value, serde_json::Error> {
2197    let mut m = Map::new();
2198    m.insert("cellId".to_string(), json!(cell_id));
2199    m.insert("specId".to_string(), json!(&spec.id));
2200    m.insert("specSignatureHash".to_string(), json!(spec_signature_hash));
2201    m.insert("sampledAtUnixMs".to_string(), json!(sampled_at_unix_ms));
2202    m.insert("tapName".to_string(), json!(tap_name));
2203    m.insert("linkState".to_string(), json!(link_state));
2204    if let Some(v) = stats.rx_packets {
2205        m.insert("rxPackets".to_string(), json!(v));
2206    }
2207    if let Some(v) = stats.tx_packets {
2208        m.insert("txPackets".to_string(), json!(v));
2209    }
2210    if let Some(v) = stats.rx_bytes {
2211        m.insert("rxBytes".to_string(), json!(v));
2212    }
2213    if let Some(v) = stats.tx_bytes {
2214        m.insert("txBytes".to_string(), json!(v));
2215    }
2216    if let Some(v) = stats.rx_errors {
2217        m.insert("rxErrors".to_string(), json!(v));
2218    }
2219    if let Some(v) = stats.tx_errors {
2220        m.insert("txErrors".to_string(), json!(v));
2221    }
2222    if let Some(v) = stats.rx_dropped {
2223        m.insert("rxDropped".to_string(), json!(v));
2224    }
2225    if let Some(v) = stats.tx_dropped {
2226        m.insert("txDropped".to_string(), json!(v));
2227    }
2228    if let Some(e) = sample_error {
2229        m.insert("sampleError".to_string(), json!(e));
2230    }
2231    if let Some(r) = run_id {
2232        m.insert("runId".to_string(), json!(r));
2233    }
2234    if let Some(c) = &spec.correlation {
2235        m.insert("correlation".to_string(), serde_json::to_value(c)?);
2236    }
2237    Ok(Value::Object(m))
2238}
2239
2240/// Final residue class for the cell after teardown
2241/// (per `docs/destruction-semantics.md`).
2242#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
2243#[serde(rename_all = "snake_case")]
2244pub enum ResidueClass {
2245    /// No host- or broker-side residue tracked for the cell after destroy.
2246    None,
2247    /// A residue class with a named runbook entry; see
2248    /// [`EvidenceBundleRefs::residue_exception`] for the runbook id.
2249    DocumentedException,
2250}
2251
2252/// Coarser-grained residue class historically embedded directly in the
2253/// `lifecycle.v1.destroyed` payload. Retained for back-compat with consumers
2254/// that read the destruction event directly without joining the
2255/// `evidence_bundle.v1.emitted` payload. New code should prefer
2256/// [`ResidueClass`] on the bundle.
2257#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
2258#[serde(rename_all = "camelCase")]
2259pub enum LifecycleResidueClass {
2260    None,
2261    MetadataOnly,
2262    DocumentedException,
2263    Unknown,
2264}
2265
2266/// References the supervisor aggregates into an `evidence_bundle` payload.
2267///
2268/// All identifiers are CloudEvent envelope `id` strings (URN preferred).
2269/// Required fields enforce ADR-0006 §F1: every emitted bundle ties back to
2270/// a destruction event (D5) and a host-stamped spec hash (the channel's
2271/// authenticity primitive). Optional refs are populated only when the
2272/// supervisor actually emitted the corresponding event for this run —
2273/// builders never fabricate references.
2274#[derive(Debug, Default, Clone)]
2275pub struct EvidenceBundleRefs<'a> {
2276    /// Required: CloudEvent id of `cell.lifecycle.v1.started`.
2277    pub started_event_ref: &'a str,
2278    /// Required: CloudEvent id of `cell.lifecycle.v1.destroyed` (D5).
2279    pub cell_destroyed_event_ref: &'a str,
2280    pub command_completed_event_ref: Option<&'a str>,
2281    pub spawned_event_refs: &'a [&'a str],
2282    pub fc_metrics_event_refs: &'a [&'a str],
2283    pub cgroup_event_refs: &'a [&'a str],
2284    pub nftables_event_refs: &'a [&'a str],
2285    pub tap_event_refs: &'a [&'a str],
2286    pub homeostasis_event_ref: Option<&'a str>,
2287    pub compliance_summary_event_ref: Option<&'a str>,
2288    /// One row per declared `cell.observability.guest.*` event:
2289    /// `(eventId, eventType, ruleClass)`.
2290    pub guest_event_refs: &'a [(&'a str, &'a str, &'a str)],
2291    /// When `residue_class == DocumentedException`, names the runbook entry.
2292    pub residue_exception: Option<&'a str>,
2293}
2294
2295/// `dev.cellos.events.cell.evidence_bundle.v1.emitted`
2296///
2297/// The 1.0 per-cell aggregation primitive (ADR-0006 §4) — what the auditor
2298/// reads cold and what the CI customer pastes into incident review.
2299/// Carries the spec hash, lifecycle stream refs, Path B host-side resource
2300/// series refs, declared guest event refs (with ADG rule-class), and the
2301/// destruction receipt naming the residue class.
2302///
2303/// **D5 gate:** `cell_destroyed_event_ref` is required by the schema and by
2304/// this builder's signature. A bundle without a destruction event reference
2305/// is rejected by the schema validator and by `tests/residue.rs`
2306/// (per ADR-0006 §Compliance "destruction gate").
2307///
2308/// Schema: `contracts/schemas/evidence-bundle-v1.schema.json`.
2309pub fn evidence_bundle_emitted_data_v1(
2310    spec: &ExecutionCellSpec,
2311    cell_id: &str,
2312    run_id: Option<&str>,
2313    spec_signature_hash: &str,
2314    emitted_at_unix_ms: u64,
2315    residue_class: ResidueClass,
2316    refs: &EvidenceBundleRefs<'_>,
2317) -> Result<Value, serde_json::Error> {
2318    let mut m = Map::new();
2319    m.insert("cellId".to_string(), json!(cell_id));
2320    m.insert("specId".to_string(), json!(&spec.id));
2321    m.insert("specSignatureHash".to_string(), json!(spec_signature_hash));
2322    m.insert("emittedAtUnixMs".to_string(), json!(emitted_at_unix_ms));
2323
2324    let mut lifecycle = Map::new();
2325    lifecycle.insert("started".to_string(), json!(refs.started_event_ref));
2326    lifecycle.insert(
2327        "destroyed".to_string(),
2328        json!(refs.cell_destroyed_event_ref),
2329    );
2330    if let Some(cc) = refs.command_completed_event_ref {
2331        lifecycle.insert("commandCompleted".to_string(), json!(cc));
2332    }
2333    if !refs.spawned_event_refs.is_empty() {
2334        lifecycle.insert("spawned".to_string(), json!(refs.spawned_event_refs));
2335    }
2336    m.insert("lifecycleEventRefs".to_string(), Value::Object(lifecycle));
2337
2338    m.insert(
2339        "cellDestroyedEventRef".to_string(),
2340        json!(refs.cell_destroyed_event_ref),
2341    );
2342    m.insert(
2343        "residueClass".to_string(),
2344        serde_json::to_value(residue_class)?,
2345    );
2346    if let Some(rx) = refs.residue_exception {
2347        m.insert("residueException".to_string(), json!(rx));
2348    }
2349
2350    let any_host = !refs.fc_metrics_event_refs.is_empty()
2351        || !refs.cgroup_event_refs.is_empty()
2352        || !refs.nftables_event_refs.is_empty()
2353        || !refs.tap_event_refs.is_empty();
2354    if any_host {
2355        let mut host = Map::new();
2356        if !refs.fc_metrics_event_refs.is_empty() {
2357            host.insert("fcMetrics".to_string(), json!(refs.fc_metrics_event_refs));
2358        }
2359        if !refs.cgroup_event_refs.is_empty() {
2360            host.insert("cgroup".to_string(), json!(refs.cgroup_event_refs));
2361        }
2362        if !refs.nftables_event_refs.is_empty() {
2363            host.insert("nftables".to_string(), json!(refs.nftables_event_refs));
2364        }
2365        if !refs.tap_event_refs.is_empty() {
2366            host.insert("tap".to_string(), json!(refs.tap_event_refs));
2367        }
2368        m.insert("hostProbeEventRefs".to_string(), Value::Object(host));
2369    }
2370
2371    if !refs.guest_event_refs.is_empty() {
2372        let rows: Vec<Value> = refs
2373            .guest_event_refs
2374            .iter()
2375            .map(|(id, ty, rc)| {
2376                let mut row = Map::new();
2377                row.insert("eventId".to_string(), json!(id));
2378                row.insert("eventType".to_string(), json!(ty));
2379                row.insert("ruleClass".to_string(), json!(rc));
2380                Value::Object(row)
2381            })
2382            .collect();
2383        m.insert("guestEventRefs".to_string(), Value::Array(rows));
2384    }
2385
2386    if let Some(h) = refs.homeostasis_event_ref {
2387        m.insert("homeostasisEventRef".to_string(), json!(h));
2388    }
2389    if let Some(c) = refs.compliance_summary_event_ref {
2390        m.insert("complianceSummaryEventRef".to_string(), json!(c));
2391    }
2392    if let Some(r) = run_id {
2393        m.insert("runId".to_string(), json!(r));
2394    }
2395    if let Some(c) = &spec.correlation {
2396        m.insert("correlation".to_string(), serde_json::to_value(c)?);
2397    }
2398    Ok(Value::Object(m))
2399}
2400
2401/// `data` for event type `dev.cellos.events.cell.cortex.v1.dispatched`.
2402///
2403/// Emitted by `CellosLedgerEmitter` when it processes a Cortex
2404/// ContextPack dispatch onto a cell. Records the pack id, the cell id,
2405/// the doctrine refs cited by the pack (subject to ADR-0009 policy), and
2406/// the Cortex↔CellOS bridge version for downstream auditors.
2407///
2408/// Schema: `contracts/schemas/cell-cortex-dispatched-v1.schema.json`.
2409pub fn cortex_dispatched_data_v1(pack_id: &str, cell_id: &str, doctrine_refs: &[String]) -> Value {
2410    let mut m = Map::new();
2411    m.insert("packId".to_string(), json!(pack_id));
2412    m.insert("cellId".to_string(), json!(cell_id));
2413    m.insert("doctrineRefs".to_string(), json!(doctrine_refs));
2414    m.insert("bridgeVersion".to_string(), json!("1.0"));
2415    Value::Object(m)
2416}
2417
2418/// `data` for event type `dev.cellos.events.cell.firecracker.v1.pool_checkout`.
2419///
2420/// Emitted by `FirecrackerCellBackend` when it satisfies a cell start by
2421/// either consuming a slot from the warm pool (`poolHit = true`) or
2422/// falling through to a cold boot (`poolHit = false`). `slotCount` is the
2423/// number of warm slots available *before* this checkout — useful for
2424/// detecting pool-exhaustion patterns in audit.
2425///
2426/// Schema: `contracts/schemas/cell-firecracker-pool-checkout-v1.schema.json`.
2427pub fn firecracker_pool_event_data_v1(cell_id: &str, pool_hit: bool, slot_count: usize) -> Value {
2428    let mut m = Map::new();
2429    m.insert("cellId".to_string(), json!(cell_id));
2430    m.insert("poolHit".to_string(), json!(pool_hit));
2431    m.insert("slotCount".to_string(), json!(slot_count));
2432    Value::Object(m)
2433}
2434
2435/// CloudEvent envelope constructor for `dev.cellos.events.cell.cortex.v1.dispatched`.
2436///
2437/// Pairs with [`cortex_dispatched_data_v1`]. `source` is typically the Cortex
2438/// bridge identifier (e.g. `"cellos-cortex"`); `time` is RFC 3339.
2439///
2440/// Schema: `contracts/schemas/cell-cortex-dispatched-v1.schema.json`.
2441pub fn cloud_event_v1_cortex_dispatched(
2442    source: &str,
2443    time: &str,
2444    pack_id: &str,
2445    cell_id: &str,
2446    doctrine_refs: &[String],
2447) -> CloudEventV1 {
2448    CloudEventV1 {
2449        specversion: "1.0".into(),
2450        id: uuid::Uuid::new_v4().to_string(),
2451        source: source.to_string(),
2452        ty: "dev.cellos.events.cell.cortex.v1.dispatched".into(),
2453        datacontenttype: Some("application/json".into()),
2454        data: Some(cortex_dispatched_data_v1(pack_id, cell_id, doctrine_refs)),
2455        time: Some(time.to_string()),
2456        traceparent: None,
2457    }
2458}
2459
2460/// CloudEvent envelope constructor for `dev.cellos.events.cell.firecracker.v1.pool_checkout`.
2461///
2462/// Pairs with [`firecracker_pool_event_data_v1`]. `source` is typically
2463/// `"cellos-host-firecracker"`; `time` is RFC 3339. `slot_count` is the
2464/// pool's `Available` count observed *before* the checkout attempt — see
2465/// the data builder for audit semantics.
2466///
2467/// Schema: `contracts/schemas/cell-firecracker-pool-checkout-v1.schema.json`.
2468pub fn cloud_event_v1_firecracker_pool_checkout(
2469    source: &str,
2470    time: &str,
2471    cell_id: &str,
2472    pool_hit: bool,
2473    slot_count: usize,
2474) -> CloudEventV1 {
2475    CloudEventV1 {
2476        specversion: "1.0".into(),
2477        id: uuid::Uuid::new_v4().to_string(),
2478        source: source.to_string(),
2479        ty: "dev.cellos.events.cell.firecracker.v1.pool_checkout".into(),
2480        datacontenttype: Some("application/json".into()),
2481        data: Some(firecracker_pool_event_data_v1(
2482            cell_id, pool_hit, slot_count,
2483        )),
2484        time: Some(time.to_string()),
2485        traceparent: None,
2486    }
2487}
2488
2489// ─────────────────────────────────────────────────────────────────────────────
2490// Formation-level lifecycle events (Session 15+16 — Formation model).
2491//
2492// The Formation state machine governs a group of related cells managed as a
2493// single unit. Its transitions are:
2494//
2495//   PENDING → LAUNCHING → RUNNING → DEGRADED → COMPLETED / FAILED
2496//
2497// Each transition emits a CloudEvent so the formation lifecycle is auditable
2498// the same way cell lifecycle is auditable. The URN namespace is
2499// `dev.cellos.events.cell.formation.v1.<phase>`, sharing the `cell.` prefix
2500// so the FC-74 audit consumer's coverage rules apply uniformly. The shared
2501// data shape is described by [`FormationEventData`] below.
2502// ─────────────────────────────────────────────────────────────────────────────
2503
2504/// CloudEvent `type` URN for [`cloud_event_v1_formation_created`].
2505pub const FORMATION_CREATED_TYPE: &str = "dev.cellos.events.cell.formation.v1.created";
2506
2507/// CloudEvent `type` URN for [`cloud_event_v1_formation_launching`].
2508pub const FORMATION_LAUNCHING_TYPE: &str = "dev.cellos.events.cell.formation.v1.launching";
2509
2510/// CloudEvent `type` URN for [`cloud_event_v1_formation_running`].
2511pub const FORMATION_RUNNING_TYPE: &str = "dev.cellos.events.cell.formation.v1.running";
2512
2513/// CloudEvent `type` URN for [`cloud_event_v1_formation_degraded`].
2514pub const FORMATION_DEGRADED_TYPE: &str = "dev.cellos.events.cell.formation.v1.degraded";
2515
2516/// CloudEvent `type` URN for [`cloud_event_v1_formation_completed`].
2517pub const FORMATION_COMPLETED_TYPE: &str = "dev.cellos.events.cell.formation.v1.completed";
2518
2519/// CloudEvent `type` URN for [`cloud_event_v1_formation_failed`].
2520pub const FORMATION_FAILED_TYPE: &str = "dev.cellos.events.cell.formation.v1.failed";
2521
2522/// Build the canonical `data` payload for every
2523/// `dev.cellos.events.cell.formation.v1.*` CloudEvent.
2524///
2525/// All formation lifecycle events share a single shape so downstream auditors
2526/// (the FC-74 consumer, `cellos-audit-justification`, SIEM ingest) can join
2527/// on `formationId` across phases without dispatching per-URN parsers.
2528///
2529/// Fields:
2530/// - `formationId` — stable identifier of the Formation document.
2531/// - `formationName` — operator-facing label (mirrors `metadata.name` on the
2532///   FormationDocument).
2533/// - `cellCount` — total number of cells in the formation at the time of
2534///   transition. Always present so audit can detect mid-run resize.
2535/// - `failedCellIds` — concrete cell IDs that contributed to the transition
2536///   (populated for `degraded` / `failed`, empty for the happy path).
2537/// - `reason` — short, free-form explanation for `degraded` / `failed`
2538///   transitions. Omitted on the happy path.
2539fn formation_data_v1(
2540    formation_id: &str,
2541    formation_name: &str,
2542    cell_count: u32,
2543    failed_cell_ids: &[String],
2544    reason: Option<&str>,
2545) -> Value {
2546    let mut m = Map::new();
2547    m.insert("formationId".to_string(), json!(formation_id));
2548    m.insert("formationName".to_string(), json!(formation_name));
2549    m.insert("cellCount".to_string(), json!(cell_count));
2550    m.insert("failedCellIds".to_string(), json!(failed_cell_ids));
2551    if let Some(r) = reason {
2552        m.insert("reason".to_string(), json!(r));
2553    }
2554    Value::Object(m)
2555}
2556
2557/// CloudEvent envelope constructor for
2558/// `dev.cellos.events.cell.formation.v1.created` (PENDING phase entry).
2559///
2560/// Emitted by the supervisor immediately after a FormationDocument is
2561/// admitted but before any cell launch has begun. `failed_cell_ids` is
2562/// expected to be empty on this transition.
2563pub fn cloud_event_v1_formation_created(
2564    source: &str,
2565    time: &str,
2566    formation_id: &str,
2567    formation_name: &str,
2568    cell_count: u32,
2569    failed_cell_ids: &[String],
2570    reason: Option<&str>,
2571) -> CloudEventV1 {
2572    CloudEventV1 {
2573        specversion: "1.0".into(),
2574        id: uuid::Uuid::new_v4().to_string(),
2575        source: source.to_string(),
2576        ty: FORMATION_CREATED_TYPE.to_string(),
2577        datacontenttype: Some("application/json".into()),
2578        data: Some(formation_data_v1(
2579            formation_id,
2580            formation_name,
2581            cell_count,
2582            failed_cell_ids,
2583            reason,
2584        )),
2585        time: Some(time.to_string()),
2586        traceparent: None,
2587    }
2588}
2589
2590/// CloudEvent envelope constructor for
2591/// `dev.cellos.events.cell.formation.v1.launching` (LAUNCHING phase entry).
2592///
2593/// Emitted once the supervisor has started attempting to bring up the
2594/// formation's cells. `failed_cell_ids` is expected to be empty on this
2595/// transition; transient launch errors that don't fail the whole formation
2596/// belong on the subsequent `degraded` or `failed` events.
2597pub fn cloud_event_v1_formation_launching(
2598    source: &str,
2599    time: &str,
2600    formation_id: &str,
2601    formation_name: &str,
2602    cell_count: u32,
2603    failed_cell_ids: &[String],
2604    reason: Option<&str>,
2605) -> CloudEventV1 {
2606    CloudEventV1 {
2607        specversion: "1.0".into(),
2608        id: uuid::Uuid::new_v4().to_string(),
2609        source: source.to_string(),
2610        ty: FORMATION_LAUNCHING_TYPE.to_string(),
2611        datacontenttype: Some("application/json".into()),
2612        data: Some(formation_data_v1(
2613            formation_id,
2614            formation_name,
2615            cell_count,
2616            failed_cell_ids,
2617            reason,
2618        )),
2619        time: Some(time.to_string()),
2620        traceparent: None,
2621    }
2622}
2623
2624/// CloudEvent envelope constructor for
2625/// `dev.cellos.events.cell.formation.v1.running` (RUNNING phase entry).
2626///
2627/// Emitted once all of the formation's cells reach a healthy steady state.
2628/// `failed_cell_ids` is expected to be empty on this transition.
2629pub fn cloud_event_v1_formation_running(
2630    source: &str,
2631    time: &str,
2632    formation_id: &str,
2633    formation_name: &str,
2634    cell_count: u32,
2635    failed_cell_ids: &[String],
2636    reason: Option<&str>,
2637) -> CloudEventV1 {
2638    CloudEventV1 {
2639        specversion: "1.0".into(),
2640        id: uuid::Uuid::new_v4().to_string(),
2641        source: source.to_string(),
2642        ty: FORMATION_RUNNING_TYPE.to_string(),
2643        datacontenttype: Some("application/json".into()),
2644        data: Some(formation_data_v1(
2645            formation_id,
2646            formation_name,
2647            cell_count,
2648            failed_cell_ids,
2649            reason,
2650        )),
2651        time: Some(time.to_string()),
2652        traceparent: None,
2653    }
2654}
2655
2656/// CloudEvent envelope constructor for
2657/// `dev.cellos.events.cell.formation.v1.degraded` (DEGRADED phase entry).
2658///
2659/// Emitted when at least one but not all of the formation's cells have
2660/// failed and the formation is continuing to run in a degraded state.
2661/// `failed_cell_ids` MUST list the affected cells; `reason` SHOULD be set.
2662pub fn cloud_event_v1_formation_degraded(
2663    source: &str,
2664    time: &str,
2665    formation_id: &str,
2666    formation_name: &str,
2667    cell_count: u32,
2668    failed_cell_ids: &[String],
2669    reason: Option<&str>,
2670) -> CloudEventV1 {
2671    CloudEventV1 {
2672        specversion: "1.0".into(),
2673        id: uuid::Uuid::new_v4().to_string(),
2674        source: source.to_string(),
2675        ty: FORMATION_DEGRADED_TYPE.to_string(),
2676        datacontenttype: Some("application/json".into()),
2677        data: Some(formation_data_v1(
2678            formation_id,
2679            formation_name,
2680            cell_count,
2681            failed_cell_ids,
2682            reason,
2683        )),
2684        time: Some(time.to_string()),
2685        traceparent: None,
2686    }
2687}
2688
2689/// CloudEvent envelope constructor for
2690/// `dev.cellos.events.cell.formation.v1.completed` (COMPLETED terminal).
2691///
2692/// Emitted when the formation has finished its work successfully and all
2693/// cells have been torn down cleanly. `failed_cell_ids` is expected to be
2694/// empty; `reason` MAY carry an operator-facing completion note.
2695pub fn cloud_event_v1_formation_completed(
2696    source: &str,
2697    time: &str,
2698    formation_id: &str,
2699    formation_name: &str,
2700    cell_count: u32,
2701    failed_cell_ids: &[String],
2702    reason: Option<&str>,
2703) -> CloudEventV1 {
2704    CloudEventV1 {
2705        specversion: "1.0".into(),
2706        id: uuid::Uuid::new_v4().to_string(),
2707        source: source.to_string(),
2708        ty: FORMATION_COMPLETED_TYPE.to_string(),
2709        datacontenttype: Some("application/json".into()),
2710        data: Some(formation_data_v1(
2711            formation_id,
2712            formation_name,
2713            cell_count,
2714            failed_cell_ids,
2715            reason,
2716        )),
2717        time: Some(time.to_string()),
2718        traceparent: None,
2719    }
2720}
2721
2722/// CloudEvent envelope constructor for
2723/// `dev.cellos.events.cell.formation.v1.failed` (FAILED terminal).
2724///
2725/// Emitted when the formation has terminated unsuccessfully. `failed_cell_ids`
2726/// MUST list the cells that drove the failure (callers MAY include the full
2727/// cell set when the failure is formation-wide); `reason` SHOULD be set.
2728pub fn cloud_event_v1_formation_failed(
2729    source: &str,
2730    time: &str,
2731    formation_id: &str,
2732    formation_name: &str,
2733    cell_count: u32,
2734    failed_cell_ids: &[String],
2735    reason: Option<&str>,
2736) -> CloudEventV1 {
2737    CloudEventV1 {
2738        specversion: "1.0".into(),
2739        id: uuid::Uuid::new_v4().to_string(),
2740        source: source.to_string(),
2741        ty: FORMATION_FAILED_TYPE.to_string(),
2742        datacontenttype: Some("application/json".into()),
2743        data: Some(formation_data_v1(
2744            formation_id,
2745            formation_name,
2746            cell_count,
2747            failed_cell_ids,
2748            reason,
2749        )),
2750        time: Some(time.to_string()),
2751        traceparent: None,
2752    }
2753}
2754
2755#[cfg(test)]
2756mod tests {
2757    use super::*;
2758    use crate::{
2759        Correlation, ExecutionCellDocument, ExportReceipt, ExportReceiptTargetKind, Lifetime,
2760        WorkloadIdentity, WorkloadIdentityKind,
2761    };
2762
2763    #[test]
2764    fn lifecycle_started_matches_example_shape() {
2765        let raw =
2766            include_str!("../../../contracts/examples/execution-cell-ci-correlation.valid.json");
2767        let doc: ExecutionCellDocument = serde_json::from_str(raw).unwrap();
2768        let expected: Value = serde_json::from_str(include_str!(
2769            "../../../contracts/examples/cell-lifecycle-started-data.valid.json"
2770        ))
2771        .unwrap();
2772        let data = lifecycle_started_data_v1(
2773            &doc.spec,
2774            "host-cell-abc123",
2775            Some("run-2026-04-06-001"),
2776            None,
2777            None,
2778            None,
2779            None,
2780            None,
2781            None,
2782            None,
2783        )
2784        .unwrap();
2785        assert_eq!(data, expected);
2786    }
2787
2788    #[test]
2789    fn lifecycle_started_without_correlation() {
2790        let spec = ExecutionCellSpec {
2791            id: "s1".into(),
2792            correlation: None,
2793            ingress: None,
2794            environment: None,
2795            placement: None,
2796            policy: None,
2797            identity: None,
2798            run: None,
2799            authority: Default::default(),
2800            lifetime: Lifetime { ttl_seconds: 60 },
2801            export: None,
2802            telemetry: None,
2803        };
2804        let data =
2805            lifecycle_started_data_v1(&spec, "c1", None, None, None, None, None, None, None, None)
2806                .unwrap();
2807        assert!(!data.as_object().unwrap().contains_key("correlation"));
2808        assert!(!data.as_object().unwrap().contains_key("runId"));
2809        assert!(!data.as_object().unwrap().contains_key("derivationVerified"));
2810        assert!(!data.as_object().unwrap().contains_key("roleRoot"));
2811        assert!(!data.as_object().unwrap().contains_key("parentRunId"));
2812        assert!(!data.as_object().unwrap().contains_key("kernelDigestSha256"));
2813        assert!(!data.as_object().unwrap().contains_key("rootfsDigestSha256"));
2814        assert!(!data
2815            .as_object()
2816            .unwrap()
2817            .contains_key("firecrackerDigestSha256"));
2818    }
2819
2820    #[test]
2821    fn lifecycle_started_partial_correlation_serializes() {
2822        let spec = ExecutionCellSpec {
2823            id: "s2".into(),
2824            correlation: Some(Correlation {
2825                platform: Some("custom".into()),
2826                external_run_id: None,
2827                external_job_id: None,
2828                tenant_id: None,
2829                labels: None,
2830                correlation_id: None,
2831            }),
2832            ingress: None,
2833            environment: None,
2834            placement: None,
2835            policy: None,
2836            identity: None,
2837            run: None,
2838            authority: Default::default(),
2839            lifetime: Lifetime { ttl_seconds: 1 },
2840            export: None,
2841            telemetry: None,
2842        };
2843        let data =
2844            lifecycle_started_data_v1(&spec, "c2", None, None, None, None, None, None, None, None)
2845                .unwrap();
2846        assert_eq!(data["correlation"]["platform"], "custom");
2847    }
2848
2849    #[test]
2850    fn lifecycle_started_with_derivation_fields_emits_them() {
2851        let spec = ExecutionCellSpec {
2852            id: "deriv-1".into(),
2853            correlation: None,
2854            ingress: None,
2855            environment: None,
2856            placement: None,
2857            policy: None,
2858            identity: None,
2859            run: None,
2860            authority: Default::default(),
2861            lifetime: Lifetime { ttl_seconds: 60 },
2862            export: None,
2863            telemetry: None,
2864        };
2865        let data = lifecycle_started_data_v1(
2866            &spec,
2867            "cell-deriv",
2868            Some("run-deriv-1"),
2869            Some(false),
2870            Some("role-prod-ci"),
2871            Some("run-parent-001"),
2872            Some("abc123def456"),
2873            None,
2874            None,
2875            None,
2876        )
2877        .unwrap();
2878        assert_eq!(data["derivationVerified"], false);
2879        assert_eq!(data["roleRoot"], "role-prod-ci");
2880        assert_eq!(data["parentRunId"], "run-parent-001");
2881    }
2882
2883    #[test]
2884    fn lifecycle_destroyed_succeeded_shape() {
2885        let raw =
2886            include_str!("../../../contracts/examples/execution-cell-ci-correlation.valid.json");
2887        let doc: ExecutionCellDocument = serde_json::from_str(raw).unwrap();
2888        let data = lifecycle_destroyed_data_v1(
2889            &doc.spec,
2890            "host-xyz",
2891            Some("run-test"),
2892            LifecycleDestroyOutcome::Succeeded,
2893            None,
2894            None,
2895            None,
2896            None,
2897        )
2898        .unwrap();
2899        assert_eq!(data["outcome"], "succeeded");
2900        assert!(!data.as_object().unwrap().contains_key("reason"));
2901        assert!(
2902            !data.as_object().unwrap().contains_key("terminalState"),
2903            "terminalState must be omitted when None for backward-compat"
2904        );
2905        assert!(
2906            !data.as_object().unwrap().contains_key("evidenceBundleRef"),
2907            "evidenceBundleRef must be omitted when None for backward-compat"
2908        );
2909        assert!(
2910            !data.as_object().unwrap().contains_key("residueClass"),
2911            "residueClass must be omitted when None for backward-compat"
2912        );
2913        assert_eq!(data["ttlSeconds"], 3600);
2914    }
2915
2916    #[test]
2917    fn lifecycle_destroyed_failed_includes_reason() {
2918        let spec = ExecutionCellSpec {
2919            id: "s1".into(),
2920            correlation: None,
2921            ingress: None,
2922            environment: None,
2923            placement: None,
2924            policy: None,
2925            identity: None,
2926            run: None,
2927            authority: Default::default(),
2928            lifetime: Lifetime { ttl_seconds: 60 },
2929            export: None,
2930            telemetry: None,
2931        };
2932        let data = lifecycle_destroyed_data_v1(
2933            &spec,
2934            "c1",
2935            None,
2936            LifecycleDestroyOutcome::Failed,
2937            Some("secret resolve: denied"),
2938            None,
2939            None,
2940            None,
2941        )
2942        .unwrap();
2943        assert_eq!(data["outcome"], "failed");
2944        assert_eq!(data["reason"], "secret resolve: denied");
2945    }
2946
2947    #[test]
2948    fn lifecycle_destroyed_terminal_state_clean_serializes() {
2949        let spec = ExecutionCellSpec {
2950            id: "term-clean".into(),
2951            correlation: None,
2952            ingress: None,
2953            environment: None,
2954            placement: None,
2955            policy: None,
2956            identity: None,
2957            run: None,
2958            authority: Default::default(),
2959            lifetime: Lifetime { ttl_seconds: 60 },
2960            export: None,
2961            telemetry: None,
2962        };
2963        let data = lifecycle_destroyed_data_v1(
2964            &spec,
2965            "c-clean",
2966            None,
2967            LifecycleDestroyOutcome::Succeeded,
2968            None,
2969            Some(LifecycleTerminalState::Clean),
2970            None,
2971            None,
2972        )
2973        .unwrap();
2974        assert_eq!(data["terminalState"], "clean");
2975    }
2976
2977    #[test]
2978    fn lifecycle_destroyed_terminal_state_forced_serializes() {
2979        let spec = ExecutionCellSpec {
2980            id: "term-forced".into(),
2981            correlation: None,
2982            ingress: None,
2983            environment: None,
2984            placement: None,
2985            policy: None,
2986            identity: None,
2987            run: None,
2988            authority: Default::default(),
2989            lifetime: Lifetime { ttl_seconds: 60 },
2990            export: None,
2991            telemetry: None,
2992        };
2993        let data = lifecycle_destroyed_data_v1(
2994            &spec,
2995            "c-forced",
2996            None,
2997            LifecycleDestroyOutcome::Failed,
2998            Some("in-VM exit bridge: vsock closed"),
2999            Some(LifecycleTerminalState::Forced),
3000            None,
3001            None,
3002        )
3003        .unwrap();
3004        assert_eq!(data["terminalState"], "forced");
3005        assert_eq!(data["outcome"], "failed");
3006    }
3007
3008    #[test]
3009    fn lifecycle_destroyed_evidence_bundle_and_residue_class_serialize_when_populated() {
3010        let spec = ExecutionCellSpec {
3011            id: "f5-populated".into(),
3012            correlation: None,
3013            ingress: None,
3014            environment: None,
3015            placement: None,
3016            policy: None,
3017            identity: None,
3018            run: None,
3019            authority: Default::default(),
3020            lifetime: Lifetime { ttl_seconds: 60 },
3021            export: None,
3022            telemetry: None,
3023        };
3024        let bundle = SubjectUrn::parse("urn:cellos:evidence-bundle:run-1").unwrap();
3025        let data = lifecycle_destroyed_data_v1(
3026            &spec,
3027            "c-f5",
3028            Some("run-1"),
3029            LifecycleDestroyOutcome::Succeeded,
3030            None,
3031            None,
3032            Some(&bundle),
3033            Some(ResidueClass::DocumentedException),
3034        )
3035        .unwrap();
3036        assert_eq!(
3037            data["evidenceBundleRef"],
3038            "urn:cellos:evidence-bundle:run-1"
3039        );
3040        assert_eq!(data["residueClass"], "documented_exception");
3041    }
3042
3043    #[test]
3044    fn lifecycle_destroyed_evidence_bundle_and_residue_class_omitted_when_none() {
3045        let spec = ExecutionCellSpec {
3046            id: "f5-omitted".into(),
3047            correlation: None,
3048            ingress: None,
3049            environment: None,
3050            placement: None,
3051            policy: None,
3052            identity: None,
3053            run: None,
3054            authority: Default::default(),
3055            lifetime: Lifetime { ttl_seconds: 60 },
3056            export: None,
3057            telemetry: None,
3058        };
3059        let data = lifecycle_destroyed_data_v1(
3060            &spec,
3061            "c-f5-omit",
3062            None,
3063            LifecycleDestroyOutcome::Succeeded,
3064            None,
3065            None,
3066            None,
3067            None,
3068        )
3069        .unwrap();
3070        let obj = data.as_object().unwrap();
3071        assert!(
3072            !obj.contains_key("evidenceBundleRef"),
3073            "evidenceBundleRef must be omitted when None"
3074        );
3075        assert!(
3076            !obj.contains_key("residueClass"),
3077            "residueClass must be omitted when None"
3078        );
3079    }
3080
3081    #[test]
3082    fn identity_materialized_matches_example_shape() {
3083        let raw =
3084            include_str!("../../../contracts/examples/execution-cell-github-oidc-s3.valid.json");
3085        let doc: ExecutionCellDocument = serde_json::from_str(raw).unwrap();
3086        let identity = doc.spec.identity.as_ref().expect("identity");
3087        let data = identity_materialized_data_v1(&doc.spec, "host-xyz", Some("run-test"), identity)
3088            .unwrap();
3089        assert_eq!(data["identity"]["kind"], "federatedOidc");
3090        assert_eq!(data["identity"]["provider"], "github-actions");
3091        assert_eq!(data["identity"]["secretRef"], "AWS_WEB_IDENTITY");
3092        assert_eq!(data["runId"], "run-test");
3093    }
3094
3095    #[test]
3096    fn identity_revoked_includes_reason() {
3097        let spec = ExecutionCellSpec {
3098            id: "s3".into(),
3099            correlation: None,
3100            ingress: None,
3101            environment: None,
3102            placement: None,
3103            policy: None,
3104            identity: Some(WorkloadIdentity {
3105                kind: WorkloadIdentityKind::FederatedOidc,
3106                provider: "github-actions".into(),
3107                audience: "sts.amazonaws.com".into(),
3108                subject: None,
3109                ttl_seconds: Some(900),
3110                secret_ref: "AWS_WEB_IDENTITY".into(),
3111            }),
3112            run: None,
3113            authority: Default::default(),
3114            lifetime: Lifetime { ttl_seconds: 3600 },
3115            export: None,
3116            telemetry: None,
3117        };
3118        let identity = spec.identity.as_ref().unwrap();
3119        let data =
3120            identity_revoked_data_v1(&spec, "c3", None, identity, Some("teardown"), None).unwrap();
3121        assert_eq!(data["identity"]["audience"], "sts.amazonaws.com");
3122        assert_eq!(data["reason"], "teardown");
3123    }
3124
3125    #[test]
3126    fn identity_failed_matches_example_shape() {
3127        let raw =
3128            include_str!("../../../contracts/examples/execution-cell-github-oidc-s3.valid.json");
3129        let doc: ExecutionCellDocument = serde_json::from_str(raw).unwrap();
3130        let expected: Value = serde_json::from_str(include_str!(
3131            "../../../contracts/examples/cell-identity-failed-data.valid.json"
3132        ))
3133        .unwrap();
3134        let identity = doc.spec.identity.as_ref().expect("identity");
3135        let data = identity_failed_data_v1(
3136            &doc.spec,
3137            "host-cell-demo",
3138            Some("run-001"),
3139            identity,
3140            IdentityFailureOperation::Materialize,
3141            "oidc exchange denied by upstream federation policy",
3142        )
3143        .unwrap();
3144        assert_eq!(data, expected);
3145    }
3146
3147    #[test]
3148    fn export_completed_v2_matches_example_shape() {
3149        let raw =
3150            include_str!("../../../contracts/examples/execution-cell-github-oidc-s3.valid.json");
3151        let doc: ExecutionCellDocument = serde_json::from_str(raw).unwrap();
3152        let receipt = ExportReceipt {
3153            target_kind: ExportReceiptTargetKind::S3,
3154            target_name: Some("artifact-bucket".into()),
3155            destination: "s3://acme-cellos-artifacts/github/acme/widget/123456789/test-results"
3156                .into(),
3157            bytes_written: 1024,
3158        };
3159        let data = export_completed_data_v2(
3160            &doc.spec,
3161            "host-xyz",
3162            Some("run-test"),
3163            "test-results",
3164            &receipt,
3165            None,
3166        )
3167        .unwrap();
3168        assert_eq!(data["receipt"]["targetKind"], "s3");
3169        assert_eq!(data["receipt"]["targetName"], "artifact-bucket");
3170        assert_eq!(data["receipt"]["bytesWritten"], 1024);
3171    }
3172
3173    #[test]
3174    fn export_completed_v2_http_matches_example_shape() {
3175        let raw = include_str!(
3176            "../../../contracts/examples/execution-cell-github-oidc-multi-export.valid.json"
3177        );
3178        let doc: ExecutionCellDocument = serde_json::from_str(raw).unwrap();
3179        let expected: Value = serde_json::from_str(include_str!(
3180            "../../../contracts/examples/cell-export-v2-completed-data-http.valid.json"
3181        ))
3182        .unwrap();
3183        let receipt = ExportReceipt {
3184            target_kind: ExportReceiptTargetKind::Http,
3185            target_name: Some("artifact-api".into()),
3186            destination: "https://artifacts.acme.internal/upload/host-cell-demo/coverage-summary"
3187                .into(),
3188            bytes_written: 512,
3189        };
3190        let data = export_completed_data_v2(
3191            &doc.spec,
3192            "host-cell-demo",
3193            Some("run-002"),
3194            "coverage-summary",
3195            &receipt,
3196            None,
3197        )
3198        .unwrap();
3199        assert_eq!(data, expected);
3200    }
3201
3202    #[test]
3203    fn export_failed_v2_http_matches_example_shape() {
3204        let raw = include_str!(
3205            "../../../contracts/examples/execution-cell-github-oidc-multi-export.valid.json"
3206        );
3207        let doc: ExecutionCellDocument = serde_json::from_str(raw).unwrap();
3208        let expected: Value = serde_json::from_str(include_str!(
3209            "../../../contracts/examples/cell-export-v2-failed-data.valid.json"
3210        ))
3211        .unwrap();
3212        let data = export_failed_data_v2(
3213            &doc.spec,
3214            "host-cell-demo",
3215            Some("run-002"),
3216            "coverage-summary",
3217            ExportReceiptTargetKind::Http,
3218            Some("artifact-api"),
3219            Some("https://artifacts.acme.internal/upload/host-cell-demo/coverage-summary"),
3220            "http put returned 403 Forbidden",
3221            None,
3222        )
3223        .unwrap();
3224        assert_eq!(data, expected);
3225    }
3226
3227    #[test]
3228    fn compliance_summary_matches_example_shape() {
3229        let raw =
3230            include_str!("../../../contracts/examples/execution-cell-ci-correlation.valid.json");
3231        let doc: ExecutionCellDocument = serde_json::from_str(raw).unwrap();
3232        let expected: Value = serde_json::from_str(include_str!(
3233            "../../../contracts/examples/cell-compliance-summary-data.valid.json"
3234        ))
3235        .unwrap();
3236        let data =
3237            compliance_summary_data_v1(&doc.spec, "host-cell-demo", Some("run-003"), Some(0))
3238                .unwrap();
3239        assert_eq!(data, expected);
3240    }
3241
3242    #[test]
3243    fn compliance_summary_omits_placement_when_absent() {
3244        let spec = ExecutionCellSpec {
3245            id: "compliance-no-placement".into(),
3246            correlation: None,
3247            ingress: None,
3248            environment: None,
3249            placement: None,
3250            policy: None,
3251            identity: None,
3252            run: None,
3253            authority: Default::default(),
3254            lifetime: Lifetime { ttl_seconds: 60 },
3255            export: None,
3256            telemetry: None,
3257        };
3258        let data = compliance_summary_data_v1(&spec, "cell-001", None, None).unwrap();
3259        assert!(!data.as_object().unwrap().contains_key("placement"));
3260    }
3261
3262    /// P0-7 / G4: empty `subject_urns` slice MUST keep the payload byte-shape
3263    /// identical to the legacy 4-arg delegate — i.e. `subjectUrns` is omitted.
3264    /// This is the contract that lets supervisor.rs keep its current call
3265    /// site unchanged.
3266    #[test]
3267    fn compliance_summary_with_empty_subjects_omits_field() {
3268        let raw =
3269            include_str!("../../../contracts/examples/execution-cell-ci-correlation.valid.json");
3270        let doc: ExecutionCellDocument = serde_json::from_str(raw).unwrap();
3271        let legacy =
3272            compliance_summary_data_v1(&doc.spec, "host-cell-demo", Some("run-003"), Some(0))
3273                .unwrap();
3274        let with_empty = compliance_summary_data_v1_with_subjects(
3275            &doc.spec,
3276            "host-cell-demo",
3277            Some("run-003"),
3278            Some(0),
3279            &[],
3280        )
3281        .unwrap();
3282        assert_eq!(legacy, with_empty);
3283        assert!(!with_empty.as_object().unwrap().contains_key("subjectUrns"));
3284    }
3285
3286    /// P0-7 / G4: non-empty subject set must round-trip against the
3287    /// `cell-compliance-summary-data-with-subjects.valid.json` golden fixture.
3288    #[test]
3289    fn compliance_summary_with_subjects_matches_example_shape() {
3290        let raw =
3291            include_str!("../../../contracts/examples/execution-cell-ci-correlation.valid.json");
3292        let doc: ExecutionCellDocument = serde_json::from_str(raw).unwrap();
3293        let expected: Value = serde_json::from_str(include_str!(
3294            "../../../contracts/examples/cell-compliance-summary-data-with-subjects.valid.json"
3295        ))
3296        .unwrap();
3297        let subjects: Vec<SubjectUrn> = vec![
3298            SubjectUrn::parse("urn:cellos:cell:host-cell-demo").unwrap(),
3299            SubjectUrn::parse("urn:tsafe:lease:lease-42").unwrap(),
3300            SubjectUrn::parse("urn:cellos:export:run-003%2Fartifact-1").unwrap(),
3301        ];
3302        let data = compliance_summary_data_v1_with_subjects(
3303            &doc.spec,
3304            "host-cell-demo",
3305            Some("run-003"),
3306            Some(0),
3307            &subjects,
3308        )
3309        .unwrap();
3310        assert_eq!(data, expected);
3311        let urns = data["subjectUrns"].as_array().unwrap();
3312        assert_eq!(urns.len(), 3);
3313        assert_eq!(urns[0], "urn:cellos:cell:host-cell-demo");
3314    }
3315
3316    /// P0-7 / G4 negative fixture: `cell-compliance-summary-data.invalid.json`
3317    /// is intentionally malformed (URNs that violate the schema regex). The
3318    /// `scripts/validate_contracts.py` walker only globs `*.valid.json`, so we
3319    /// load the negative fixture explicitly here and hand-check each entry
3320    /// against the schema regex shape. If this test ever passes the regex,
3321    /// the fixture has drifted away from "negative" and must be regenerated.
3322    #[test]
3323    fn compliance_summary_invalid_subject_urns_fixture_is_malformed() {
3324        let raw =
3325            include_str!("../../../contracts/examples/cell-compliance-summary-data.invalid.json");
3326        let v: Value = serde_json::from_str(raw).unwrap();
3327        let urns = v["subjectUrns"]
3328            .as_array()
3329            .expect("invalid fixture must carry subjectUrns array");
3330        assert!(!urns.is_empty(), "negative fixture must have entries");
3331
3332        // Schema regex from cell-compliance-summary-v1.schema.json. We don't
3333        // pull in a regex crate just for this — instead we apply a minimal
3334        // structural decomposition that the schema regex enforces:
3335        //   urn:<tool>:<kind>:<id>
3336        //   tool/kind = [a-z0-9][a-z0-9-]*  (lowercase, must start alnum)
3337        //   id        = non-empty, [A-Za-z0-9._:%-]+
3338        fn matches_schema_shape(s: &str) -> bool {
3339            let parts: Vec<&str> = s.splitn(4, ':').collect();
3340            if parts.len() != 4 {
3341                return false;
3342            }
3343            if parts[0] != "urn" {
3344                return false;
3345            }
3346            let segment_ok = |seg: &str| {
3347                let mut it = seg.chars();
3348                match it.next() {
3349                    Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() => {}
3350                    _ => return false,
3351                }
3352                it.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
3353            };
3354            if !segment_ok(parts[1]) || !segment_ok(parts[2]) {
3355                return false;
3356            }
3357            if parts[3].is_empty() {
3358                return false;
3359            }
3360            parts[3]
3361                .chars()
3362                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '%' | '-'))
3363        }
3364
3365        for (i, urn) in urns.iter().enumerate() {
3366            let s = urn.as_str().unwrap_or("");
3367            assert!(
3368                !matches_schema_shape(s),
3369                "invalid fixture entry [{i}] {s:?} unexpectedly matches the schema URN regex; \
3370                 fixture must remain a negative case"
3371            );
3372        }
3373    }
3374
3375    #[test]
3376    fn network_enforcement_matches_example_shape() {
3377        let raw = include_str!(
3378            "../../../contracts/examples/cell-observability-network-enforcement-data.valid.json"
3379        );
3380        let expected: Value = serde_json::from_str(raw).unwrap();
3381        let spec = ExecutionCellSpec {
3382            id: "net-enforcement-demo".into(),
3383            correlation: None,
3384            ingress: None,
3385            environment: None,
3386            placement: None,
3387            policy: None,
3388            identity: None,
3389            run: None,
3390            authority: Default::default(),
3391            lifetime: Lifetime { ttl_seconds: 60 },
3392            export: None,
3393            telemetry: None,
3394        };
3395        let data = observability_network_enforcement_data_v1(
3396            &spec,
3397            "net-enforcement-demo",
3398            Some("run-local-001"),
3399            true,
3400            1,
3401            1,
3402            None,
3403        )
3404        .unwrap();
3405        assert_eq!(data, expected);
3406    }
3407
3408    #[test]
3409    fn dns_resolution_matches_example_shape() {
3410        let raw = include_str!(
3411            "../../../contracts/examples/cell-observability-dns-resolution-data.valid.json"
3412        );
3413        let expected: Value = serde_json::from_str(raw).unwrap();
3414        let spec = ExecutionCellSpec {
3415            id: "demo-cell-dns".into(),
3416            correlation: None,
3417            ingress: None,
3418            environment: None,
3419            placement: None,
3420            policy: None,
3421            identity: None,
3422            run: None,
3423            authority: Default::default(),
3424            lifetime: Lifetime { ttl_seconds: 60 },
3425            export: None,
3426            telemetry: None,
3427        };
3428        let targets: &[(&str, &str, Option<u16>)] = &[
3429            ("203.0.113.10", "inet", Some(443)),
3430            ("2001:db8::1", "inet6", Some(443)),
3431        ];
3432        let data = observability_dns_resolution_data_v1(
3433            &spec,
3434            "demo-cell-dns",
3435            Some("run-001"),
3436            "api.example.com",
3437            "2026-04-30T12:00:00Z",
3438            targets,
3439            300,
3440            "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
3441            "keyset-demo-001",
3442            "kid-resolver-01",
3443            Some("rcpt-demo-0001"),
3444        )
3445        .unwrap();
3446        assert_eq!(data, expected);
3447    }
3448
3449    #[test]
3450    fn dns_target_set_matches_example_shape() {
3451        let raw = include_str!(
3452            "../../../contracts/examples/cell-observability-dns-target-set-data.valid.json"
3453        );
3454        let expected: Value = serde_json::from_str(raw).unwrap();
3455        let spec = ExecutionCellSpec {
3456            id: "demo-cell-dns".into(),
3457            correlation: None,
3458            ingress: None,
3459            environment: None,
3460            placement: None,
3461            policy: None,
3462            identity: None,
3463            run: None,
3464            authority: Default::default(),
3465            lifetime: Lifetime { ttl_seconds: 60 },
3466            export: None,
3467            telemetry: None,
3468        };
3469        let data = observability_dns_target_set_data_v1(
3470            &spec,
3471            "demo-cell-dns",
3472            Some("run-001"),
3473            "cdn.example.com",
3474            "empty",
3475            "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
3476            "refresh",
3477            "2026-04-30T12:05:00Z",
3478            "keyset-demo-001",
3479            "kid-resolver-01",
3480        )
3481        .unwrap();
3482        assert_eq!(data, expected);
3483    }
3484
3485    #[test]
3486    fn dns_query_data_v1_serializes_allow_path() {
3487        use crate::{DnsQueryDecision, DnsQueryEvent, DnsQueryReasonCode, DnsQueryType};
3488        let ev = DnsQueryEvent {
3489            schema_version: "1.0.0".into(),
3490            cell_id: "demo-cell-dns".into(),
3491            run_id: "run-2026-05-01-001".into(),
3492            query_id: "q-3b58b2a4-e4bb-4f89-9c4f-2a0a2c8b6f01".into(),
3493            query_name: "api.example.com".into(),
3494            query_type: DnsQueryType::A,
3495            decision: DnsQueryDecision::Allow,
3496            reason_code: DnsQueryReasonCode::AllowedByAllowlist,
3497            response_rcode: Some(0),
3498            upstream_resolver_id: Some("resolver-do53-internal".into()),
3499            upstream_latency_ms: Some(4),
3500            response_target_count: Some(2),
3501            keyset_id: Some("keyset-demo-001".into()),
3502            issuer_kid: Some("kid-resolver-01".into()),
3503            policy_digest: Some(
3504                "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".into(),
3505            ),
3506            correlation_id: Some("corr-demo-0001".into()),
3507            observed_at: "2026-05-01T12:34:56Z".into(),
3508        };
3509        let v = dns_query_data_v1(&ev).unwrap();
3510        assert_eq!(v["schemaVersion"], "1.0.0");
3511        assert_eq!(v["queryName"], "api.example.com");
3512        assert_eq!(v["queryType"], "A");
3513        assert_eq!(v["decision"], "allow");
3514        assert_eq!(v["reasonCode"], "allowed_by_allowlist");
3515        assert_eq!(v["upstreamResolverId"], "resolver-do53-internal");
3516        assert_eq!(v["responseTargetCount"], 2);
3517    }
3518
3519    #[test]
3520    fn dns_query_data_v1_omits_optionals_on_deny_path() {
3521        use crate::{DnsQueryDecision, DnsQueryEvent, DnsQueryReasonCode, DnsQueryType};
3522        let ev = DnsQueryEvent {
3523            schema_version: "1.0.0".into(),
3524            cell_id: "demo-cell-dns".into(),
3525            run_id: "run-2026-05-01-001".into(),
3526            query_id: "q-deny-001".into(),
3527            query_name: "blocked.example.com".into(),
3528            query_type: DnsQueryType::AAAA,
3529            decision: DnsQueryDecision::Deny,
3530            reason_code: DnsQueryReasonCode::DeniedNotInAllowlist,
3531            response_rcode: Some(5),
3532            upstream_resolver_id: None,
3533            upstream_latency_ms: None,
3534            response_target_count: Some(0),
3535            keyset_id: None,
3536            issuer_kid: None,
3537            policy_digest: None,
3538            correlation_id: None,
3539            observed_at: "2026-05-01T12:35:00Z".into(),
3540        };
3541        let v = dns_query_data_v1(&ev).unwrap();
3542        let obj = v.as_object().unwrap();
3543        assert!(!obj.contains_key("upstreamResolverId"));
3544        assert!(!obj.contains_key("upstreamLatencyMs"));
3545        assert!(!obj.contains_key("keysetId"));
3546        assert!(!obj.contains_key("issuerKid"));
3547        assert!(!obj.contains_key("policyDigest"));
3548        assert!(!obj.contains_key("correlationId"));
3549        assert_eq!(v["decision"], "deny");
3550        assert_eq!(v["reasonCode"], "denied_not_in_allowlist");
3551        assert_eq!(v["responseRcode"], 5);
3552    }
3553
3554    #[test]
3555    fn cloud_event_v1_dns_query_envelope() {
3556        use crate::{DnsQueryDecision, DnsQueryEvent, DnsQueryReasonCode, DnsQueryType};
3557        let ev = DnsQueryEvent {
3558            schema_version: "1.0.0".into(),
3559            cell_id: "c1".into(),
3560            run_id: "r1".into(),
3561            query_id: "q1".into(),
3562            query_name: "api.example.com".into(),
3563            query_type: DnsQueryType::A,
3564            decision: DnsQueryDecision::Allow,
3565            reason_code: DnsQueryReasonCode::AllowedByAllowlist,
3566            response_rcode: Some(0),
3567            upstream_resolver_id: Some("r-001".into()),
3568            upstream_latency_ms: Some(3),
3569            response_target_count: Some(1),
3570            keyset_id: None,
3571            issuer_kid: None,
3572            policy_digest: None,
3573            correlation_id: None,
3574            observed_at: "2026-05-01T12:34:56Z".into(),
3575        };
3576        let env =
3577            cloud_event_v1_dns_query("cellos-dns-proxy", "2026-05-01T12:34:56Z", &ev).unwrap();
3578        assert_eq!(env.specversion, "1.0");
3579        assert_eq!(env.ty, "dev.cellos.events.cell.observability.v1.dns_query");
3580        assert_eq!(env.source, "cellos-dns-proxy");
3581        assert_eq!(env.datacontenttype.as_deref(), Some("application/json"));
3582        assert!(env.data.is_some());
3583    }
3584
3585    #[test]
3586    fn qtype_mapping_covers_phase1_set() {
3587        use crate::{qtype_to_dns_query_type, DnsQueryType};
3588        assert_eq!(qtype_to_dns_query_type(1), Some(DnsQueryType::A));
3589        assert_eq!(qtype_to_dns_query_type(2), Some(DnsQueryType::NS));
3590        assert_eq!(qtype_to_dns_query_type(5), Some(DnsQueryType::CNAME));
3591        assert_eq!(qtype_to_dns_query_type(12), Some(DnsQueryType::PTR));
3592        assert_eq!(qtype_to_dns_query_type(15), Some(DnsQueryType::MX));
3593        assert_eq!(qtype_to_dns_query_type(16), Some(DnsQueryType::TXT));
3594        assert_eq!(qtype_to_dns_query_type(28), Some(DnsQueryType::AAAA));
3595        assert_eq!(qtype_to_dns_query_type(33), Some(DnsQueryType::SRV));
3596        assert_eq!(qtype_to_dns_query_type(64), Some(DnsQueryType::SVCB));
3597        assert_eq!(qtype_to_dns_query_type(65), Some(DnsQueryType::HTTPS));
3598        // Unmapped types fall outside the allowlist set.
3599        assert_eq!(qtype_to_dns_query_type(0), None);
3600        assert_eq!(qtype_to_dns_query_type(99), None);
3601        assert_eq!(qtype_to_dns_query_type(255), None); // ANY
3602    }
3603
3604    #[test]
3605    fn l7_egress_decision_matches_example_shape() {
3606        let raw = include_str!(
3607            "../../../contracts/examples/cell-observability-l7-egress-decision-data.valid.json"
3608        );
3609        let expected: Value = serde_json::from_str(raw).unwrap();
3610        let spec = ExecutionCellSpec {
3611            id: "demo-cell-dns".into(),
3612            correlation: None,
3613            ingress: None,
3614            environment: None,
3615            placement: None,
3616            policy: None,
3617            identity: None,
3618            run: None,
3619            authority: Default::default(),
3620            lifetime: Lifetime { ttl_seconds: 60 },
3621            export: None,
3622            telemetry: None,
3623        };
3624        let data = observability_l7_egress_decision_data_v1(
3625            &spec,
3626            "demo-cell-dns",
3627            Some("run-001"),
3628            "l7-demo-0002",
3629            "deny",
3630            "blocked.example.com",
3631            "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
3632            "keyset-demo-001",
3633            "kid-l7-01",
3634            "deny_default",
3635            Some("authority.egressRules.default"),
3636            None, // streamId omitted on the SNI/HTTP path; Phase 3g.1 only populates for h2.
3637        )
3638        .unwrap();
3639        assert_eq!(data, expected);
3640    }
3641
3642    #[test]
3643    fn l7_egress_decision_with_stream_id_emits_field() {
3644        let spec = ExecutionCellSpec {
3645            id: "demo-cell-dns".into(),
3646            correlation: None,
3647            ingress: None,
3648            environment: None,
3649            placement: None,
3650            policy: None,
3651            identity: None,
3652            run: None,
3653            authority: Default::default(),
3654            lifetime: Lifetime { ttl_seconds: 60 },
3655            export: None,
3656            telemetry: None,
3657        };
3658        let data = observability_l7_egress_decision_data_v1(
3659            &spec,
3660            "demo-cell-dns",
3661            Some("run-001"),
3662            "l7-demo-0003",
3663            "deny",
3664            "evil.example.com",
3665            "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
3666            "keyset-demo-001",
3667            "kid-l7-01",
3668            "l7_h2_authority_allowlist_miss",
3669            None,
3670            Some(3), // h2 stream id
3671        )
3672        .unwrap();
3673        assert_eq!(data["streamId"], serde_json::json!(3));
3674    }
3675
3676    // ── Tranche-1 seam-freeze G1 + G2 provenance/correlation tests ───────────
3677
3678    fn _seam_g1_g2_minimal_spec(id: &str) -> ExecutionCellSpec {
3679        ExecutionCellSpec {
3680            id: id.into(),
3681            correlation: None,
3682            ingress: None,
3683            environment: None,
3684            placement: None,
3685            policy: None,
3686            identity: None,
3687            run: None,
3688            authority: Default::default(),
3689            lifetime: Lifetime { ttl_seconds: 60 },
3690            export: None,
3691            telemetry: None,
3692        }
3693    }
3694
3695    #[test]
3696    fn seam_g2_identity_revoked_includes_provenance_when_set() {
3697        // G2 — provenance.parent on revoke event.
3698        let mut spec = _seam_g1_g2_minimal_spec("seam-g2-revoke");
3699        spec.identity = Some(WorkloadIdentity {
3700            kind: WorkloadIdentityKind::FederatedOidc,
3701            provider: "github-actions".into(),
3702            audience: "sts.amazonaws.com".into(),
3703            subject: None,
3704            ttl_seconds: Some(900),
3705            secret_ref: "AWS_WEB_IDENTITY".into(),
3706        });
3707        let identity = spec.identity.as_ref().unwrap();
3708        let prov = Provenance {
3709            parent: "urn:cellos:event:00000000-0000-0000-0000-00000000abcd".into(),
3710            parent_type: "dev.cellos.events.cell.lifecycle.v1.started".into(),
3711        };
3712        let data = identity_revoked_data_v1(
3713            &spec,
3714            "cell-seam-g2",
3715            Some("run-seam-g2"),
3716            identity,
3717            Some("teardown"),
3718            Some(&prov),
3719        )
3720        .unwrap();
3721        assert_eq!(
3722            data["provenance"]["parent"],
3723            "urn:cellos:event:00000000-0000-0000-0000-00000000abcd"
3724        );
3725        assert_eq!(
3726            data["provenance"]["parentType"],
3727            "dev.cellos.events.cell.lifecycle.v1.started"
3728        );
3729    }
3730
3731    #[test]
3732    fn seam_g2_identity_revoked_omits_provenance_when_none() {
3733        // Backward-compat: producers without a parent reference must not
3734        // emit a provenance key at all.
3735        let mut spec = _seam_g1_g2_minimal_spec("seam-g2-revoke-no-prov");
3736        spec.identity = Some(WorkloadIdentity {
3737            kind: WorkloadIdentityKind::FederatedOidc,
3738            provider: "github-actions".into(),
3739            audience: "sts.amazonaws.com".into(),
3740            subject: None,
3741            ttl_seconds: Some(900),
3742            secret_ref: "AWS_WEB_IDENTITY".into(),
3743        });
3744        let identity = spec.identity.as_ref().unwrap();
3745        let data =
3746            identity_revoked_data_v1(&spec, "cell-x", None, identity, Some("teardown"), None)
3747                .unwrap();
3748        assert!(!data.as_object().unwrap().contains_key("provenance"));
3749    }
3750
3751    #[test]
3752    fn seam_g2_export_completed_v2_includes_provenance_when_set() {
3753        // G2 — provenance.parent on export receipt completed event.
3754        let spec = _seam_g1_g2_minimal_spec("seam-g2-export");
3755        let receipt = ExportReceipt {
3756            target_kind: ExportReceiptTargetKind::Local,
3757            target_name: None,
3758            destination: "/tmp/out/run-1/artifact.json".into(),
3759            bytes_written: 42,
3760        };
3761        let prov = Provenance {
3762            parent: "urn:cellos:event:11111111-1111-1111-1111-111111111111".into(),
3763            parent_type: "dev.cellos.events.cell.lifecycle.v1.started".into(),
3764        };
3765        let data = export_completed_data_v2(
3766            &spec,
3767            "cell-export",
3768            Some("run-export"),
3769            "artifact",
3770            &receipt,
3771            Some(&prov),
3772        )
3773        .unwrap();
3774        assert_eq!(
3775            data["provenance"]["parent"],
3776            "urn:cellos:event:11111111-1111-1111-1111-111111111111"
3777        );
3778        assert_eq!(
3779            data["provenance"]["parentType"],
3780            "dev.cellos.events.cell.lifecycle.v1.started"
3781        );
3782    }
3783
3784    #[test]
3785    fn seam_g2_export_failed_v2_includes_provenance_when_set() {
3786        // G2 — provenance.parent on export receipt failed event.
3787        let spec = _seam_g1_g2_minimal_spec("seam-g2-export-failed");
3788        let prov = Provenance {
3789            parent: "urn:cellos:event:22222222-2222-2222-2222-222222222222".into(),
3790            parent_type: "dev.cellos.events.cell.lifecycle.v1.started".into(),
3791        };
3792        let data = export_failed_data_v2(
3793            &spec,
3794            "cell-fail",
3795            Some("run-fail"),
3796            "artifact",
3797            ExportReceiptTargetKind::S3,
3798            Some("bucket"),
3799            Some("s3://bucket/artifact"),
3800            "denied by policy",
3801            Some(&prov),
3802        )
3803        .unwrap();
3804        assert_eq!(
3805            data["provenance"]["parent"],
3806            "urn:cellos:event:22222222-2222-2222-2222-222222222222"
3807        );
3808        assert_eq!(data["reason"], "denied by policy");
3809    }
3810
3811    #[test]
3812    fn seam_g1_correlation_id_propagates_when_present_in_spec() {
3813        // G1 — when the broker (or operator) supplies correlation_id,
3814        // every event that mirrors `Correlation` carries it through into
3815        // its `data.correlation.correlationId` payload field. We exercise
3816        // the property on a representative subset of events that share the
3817        // same `if let Some(c) = &spec.correlation { ... }` pattern.
3818        let mut spec = _seam_g1_g2_minimal_spec("seam-g1-corr");
3819        spec.correlation = Some(Correlation {
3820            platform: None,
3821            external_run_id: None,
3822            external_job_id: None,
3823            tenant_id: None,
3824            labels: None,
3825            correlation_id: Some("urn:tsafe:corr:01J".into()),
3826        });
3827        spec.identity = Some(WorkloadIdentity {
3828            kind: WorkloadIdentityKind::FederatedOidc,
3829            provider: "github-actions".into(),
3830            audience: "sts.amazonaws.com".into(),
3831            subject: None,
3832            ttl_seconds: Some(900),
3833            secret_ref: "AWS_WEB_IDENTITY".into(),
3834        });
3835        let identity = spec.identity.as_ref().unwrap();
3836
3837        // identity_revoked
3838        let data =
3839            identity_revoked_data_v1(&spec, "cell-1", Some("r"), identity, Some("teardown"), None)
3840                .unwrap();
3841        assert_eq!(
3842            data["correlation"]["correlationId"], "urn:tsafe:corr:01J",
3843            "identity.revoked must mirror correlationId from spec"
3844        );
3845
3846        // export_completed v2
3847        let receipt = ExportReceipt {
3848            target_kind: ExportReceiptTargetKind::Local,
3849            target_name: None,
3850            destination: "/tmp/x".into(),
3851            bytes_written: 1,
3852        };
3853        let data = export_completed_data_v2(&spec, "c", Some("r"), "art", &receipt, None).unwrap();
3854        assert_eq!(
3855            data["correlation"]["correlationId"], "urn:tsafe:corr:01J",
3856            "export.v2.completed must mirror correlationId from spec"
3857        );
3858
3859        // export_failed v2
3860        let data = export_failed_data_v2(
3861            &spec,
3862            "c",
3863            Some("r"),
3864            "art",
3865            ExportReceiptTargetKind::Local,
3866            None,
3867            None,
3868            "boom",
3869            None,
3870        )
3871        .unwrap();
3872        assert_eq!(
3873            data["correlation"]["correlationId"], "urn:tsafe:corr:01J",
3874            "export.v2.failed must mirror correlationId from spec"
3875        );
3876
3877        // command_completed
3878        let data =
3879            command_completed_data_v1(&spec, "c", Some("r"), &["echo".to_string()], 0, 5, None)
3880                .unwrap();
3881        assert_eq!(
3882            data["correlation"]["correlationId"], "urn:tsafe:corr:01J",
3883            "command.completed must mirror correlationId from spec"
3884        );
3885
3886        // compliance_summary
3887        let data = compliance_summary_data_v1(&spec, "c", Some("r"), Some(0)).unwrap();
3888        assert_eq!(
3889            data["correlation"]["correlationId"], "urn:tsafe:corr:01J",
3890            "compliance.summary must mirror correlationId from spec"
3891        );
3892    }
3893
3894    // ---------------------------------------------------------------------
3895    // Seam-freeze G3 (P0-6): SubjectUrn validation tests.
3896    //
3897    // Every literal `urn:...` / `cell:...` string in this section is for a
3898    // negative or positive test case and is intentionally allow-listed by
3899    // `scripts/audit/subject-urn-check.sh` (which excludes `events.rs`).
3900    // ---------------------------------------------------------------------
3901
3902    #[test]
3903    fn subject_urn_accepts_canonical_cell_form() {
3904        // Positive case: the canonical CellOS cell subject shape.
3905        let urn = SubjectUrn::parse("urn:cellos:cell:abc-123").expect("must parse");
3906        assert_eq!(urn.as_str(), "urn:cellos:cell:abc-123");
3907    }
3908
3909    #[test]
3910    fn subject_urn_accepts_id_with_internal_colons() {
3911        // Forward-compat: <id> may carry colons (e.g. nested identifiers).
3912        // Validation only constrains tool/kind charset, not <id>.
3913        let urn = SubjectUrn::parse("urn:cellos:event:abc:01j").expect("must parse");
3914        assert_eq!(urn.as_str(), "urn:cellos:event:abc:01j");
3915    }
3916
3917    #[test]
3918    fn subject_urn_rejects_when_no_urn_scheme() {
3919        // Negative #1: legacy `cell:<id>` shape — no `urn:` scheme.
3920        let err = SubjectUrn::parse("cell:abc-123").unwrap_err();
3921        assert_eq!(err, SubjectUrnError::MissingUrnScheme);
3922    }
3923
3924    #[test]
3925    fn subject_urn_rejects_three_segment_form() {
3926        // Negative #2: only three colon-separated segments.
3927        let err = SubjectUrn::parse("urn:cellos:cell").unwrap_err();
3928        assert_eq!(err, SubjectUrnError::TooFewSegments);
3929    }
3930
3931    #[test]
3932    fn subject_urn_rejects_empty_id() {
3933        // Negative #3: trailing empty `<id>`.
3934        let err = SubjectUrn::parse("urn:cellos:cell:").unwrap_err();
3935        assert_eq!(err, SubjectUrnError::EmptySegment);
3936    }
3937
3938    #[test]
3939    fn subject_urn_rejects_uppercase_tool_or_kind() {
3940        // Negative #4: charset [a-z0-9-] excludes uppercase.
3941        let err = SubjectUrn::parse("urn:CellOS:cell:abc-123").unwrap_err();
3942        assert_eq!(err, SubjectUrnError::InvalidToolOrKindCharset);
3943    }
3944
3945    #[test]
3946    fn subject_urn_rejects_embedded_whitespace() {
3947        // Negative #5: any whitespace (including in <id>) is rejected.
3948        let err = SubjectUrn::parse("urn:cellos:cell:abc 123").unwrap_err();
3949        assert_eq!(err, SubjectUrnError::ControlOrWhitespace);
3950    }
3951
3952    #[test]
3953    fn subject_urn_rejects_empty_tool_segment() {
3954        // Defense: `urn::kind:id` (empty <tool>) is empty, not charset.
3955        let err = SubjectUrn::parse("urn::cell:abc-123").unwrap_err();
3956        assert_eq!(err, SubjectUrnError::EmptySegment);
3957    }
3958
3959    #[test]
3960    fn cell_subject_urn_helper_round_trips() {
3961        // Helper must produce a SubjectUrn that parses identically.
3962        let urn = cell_subject_urn("cell-host-7").expect("helper must accept ASCII id");
3963        assert_eq!(urn.as_str(), "urn:cellos:cell:cell-host-7");
3964        // And re-parsing the same string must succeed without normalization.
3965        let reparsed = SubjectUrn::parse(urn.as_str()).expect("must reparse");
3966        assert_eq!(reparsed, urn);
3967    }
3968
3969    #[test]
3970    fn cell_subject_urn_helper_rejects_empty_id() {
3971        let err = cell_subject_urn("").unwrap_err();
3972        assert_eq!(err, SubjectUrnError::EmptySegment);
3973    }
3974
3975    // ── Formation lifecycle events ────────────────────────────────────────
3976
3977    #[test]
3978    fn formation_data_v1_shape_happy_path() {
3979        let data = formation_data_v1("f-123", "demo-formation", 3, &[], None);
3980        assert_eq!(data["formationId"], json!("f-123"));
3981        assert_eq!(data["formationName"], json!("demo-formation"));
3982        assert_eq!(data["cellCount"], json!(3));
3983        assert_eq!(data["failedCellIds"], json!([] as [String; 0]));
3984        // `reason` is omitted (not null) when None — auditors check key presence.
3985        let obj = data.as_object().unwrap();
3986        assert!(!obj.contains_key("reason"));
3987    }
3988
3989    #[test]
3990    fn formation_data_v1_shape_degraded_path_includes_failed_cells_and_reason() {
3991        let failed = vec!["cell-a".to_string(), "cell-b".to_string()];
3992        let data = formation_data_v1(
3993            "f-123",
3994            "demo-formation",
3995            5,
3996            &failed,
3997            Some("2/5 cells exited non-zero"),
3998        );
3999        assert_eq!(data["failedCellIds"], json!(failed));
4000        assert_eq!(data["reason"], json!("2/5 cells exited non-zero"));
4001    }
4002
4003    #[test]
4004    fn formation_created_envelope_carries_correct_urn() {
4005        let ev = cloud_event_v1_formation_created(
4006            "cellos-supervisor",
4007            "2026-05-16T00:00:00Z",
4008            "f-1",
4009            "demo",
4010            2,
4011            &[],
4012            None,
4013        );
4014        assert_eq!(ev.ty, "dev.cellos.events.cell.formation.v1.created");
4015        assert_eq!(ev.specversion, "1.0");
4016        assert_eq!(ev.source, "cellos-supervisor");
4017        assert!(ev.data.is_some());
4018    }
4019
4020    #[test]
4021    fn formation_launching_envelope_carries_correct_urn() {
4022        let ev = cloud_event_v1_formation_launching(
4023            "cellos-supervisor",
4024            "2026-05-16T00:00:00Z",
4025            "f-1",
4026            "demo",
4027            2,
4028            &[],
4029            None,
4030        );
4031        assert_eq!(ev.ty, "dev.cellos.events.cell.formation.v1.launching");
4032    }
4033
4034    #[test]
4035    fn formation_running_envelope_carries_correct_urn() {
4036        let ev = cloud_event_v1_formation_running(
4037            "cellos-supervisor",
4038            "2026-05-16T00:00:00Z",
4039            "f-1",
4040            "demo",
4041            2,
4042            &[],
4043            None,
4044        );
4045        assert_eq!(ev.ty, "dev.cellos.events.cell.formation.v1.running");
4046    }
4047
4048    #[test]
4049    fn formation_degraded_envelope_carries_correct_urn_and_failed_cells() {
4050        let failed = vec!["cell-a".to_string()];
4051        let ev = cloud_event_v1_formation_degraded(
4052            "cellos-supervisor",
4053            "2026-05-16T00:00:00Z",
4054            "f-1",
4055            "demo",
4056            3,
4057            &failed,
4058            Some("one cell exited 1"),
4059        );
4060        assert_eq!(ev.ty, "dev.cellos.events.cell.formation.v1.degraded");
4061        let data = ev.data.unwrap();
4062        assert_eq!(data["failedCellIds"], json!(failed));
4063        assert_eq!(data["reason"], json!("one cell exited 1"));
4064    }
4065
4066    #[test]
4067    fn formation_completed_envelope_carries_correct_urn() {
4068        let ev = cloud_event_v1_formation_completed(
4069            "cellos-supervisor",
4070            "2026-05-16T00:00:00Z",
4071            "f-1",
4072            "demo",
4073            2,
4074            &[],
4075            None,
4076        );
4077        assert_eq!(ev.ty, "dev.cellos.events.cell.formation.v1.completed");
4078    }
4079
4080    #[test]
4081    fn formation_failed_envelope_carries_correct_urn_and_reason() {
4082        let failed = vec!["cell-a".to_string(), "cell-b".to_string()];
4083        let ev = cloud_event_v1_formation_failed(
4084            "cellos-supervisor",
4085            "2026-05-16T00:00:00Z",
4086            "f-1",
4087            "demo",
4088            2,
4089            &failed,
4090            Some("all cells exited non-zero"),
4091        );
4092        assert_eq!(ev.ty, "dev.cellos.events.cell.formation.v1.failed");
4093        let data = ev.data.unwrap();
4094        assert_eq!(data["failedCellIds"], json!(failed));
4095        assert_eq!(data["reason"], json!("all cells exited non-zero"));
4096    }
4097
4098    #[test]
4099    fn formation_type_constants_match_envelope_urns() {
4100        assert_eq!(
4101            FORMATION_CREATED_TYPE,
4102            "dev.cellos.events.cell.formation.v1.created"
4103        );
4104        assert_eq!(
4105            FORMATION_LAUNCHING_TYPE,
4106            "dev.cellos.events.cell.formation.v1.launching"
4107        );
4108        assert_eq!(
4109            FORMATION_RUNNING_TYPE,
4110            "dev.cellos.events.cell.formation.v1.running"
4111        );
4112        assert_eq!(
4113            FORMATION_DEGRADED_TYPE,
4114            "dev.cellos.events.cell.formation.v1.degraded"
4115        );
4116        assert_eq!(
4117            FORMATION_COMPLETED_TYPE,
4118            "dev.cellos.events.cell.formation.v1.completed"
4119        );
4120        assert_eq!(
4121            FORMATION_FAILED_TYPE,
4122            "dev.cellos.events.cell.formation.v1.failed"
4123        );
4124    }
4125}