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/// Witness fields recording what cellos actually provisioned for a cloud
597/// identity (ADR-0026 §Decision 4). These are the *receipt* of a provisioning
598/// outcome, not an enforcement claim: cellos requests and records the scoped
599/// identity while AWS IAM / Azure RBAC remains the server-side enforcer. The
600/// provisioner itself (AWS `AssumeRoleWithWebIdentity` trust setup / Azure RBAC
601/// assignment) is an L2 host-backend deliverable; this struct only carries the
602/// witness so the provisioning outcome is auditable once that provisioner lands.
603///
604/// Every field is `Option` and omitted from the wire when `None`, so the
605/// witnessed payload is additive over the base `identity_materialized_data_v1`
606/// shape — a `FederatedOidc` materialization carries none of these.
607#[derive(Debug, Clone, Default, PartialEq, Eq)]
608pub struct ProvisioningWitness<'a> {
609    /// The AWS role ARN cellos actually assumed for an `AwsAssumeRole` identity.
610    /// Emitted as `provisionedRoleArn`.
611    pub provisioned_role_arn: Option<&'a str>,
612    /// Lowercase-hex digest of the STS session policy bound to the assumed role
613    /// (no `sha256:` prefix, matching the rest of CellOS's audit surface).
614    /// Emitted as `sessionPolicyDigest`.
615    pub session_policy_digest: Option<&'a str>,
616    /// The Azure AD object id of the managed identity cellos bound for an
617    /// `AzureManagedIdentity` identity. Emitted as `azureObjectId`.
618    pub azure_object_id: Option<&'a str>,
619    /// JWT id (`jti`) of the provisioned credential, witnessing the exact token
620    /// minted so a revocation can be correlated. Emitted as `credentialJti`.
621    pub credential_jti: Option<&'a str>,
622}
623
624/// ADR-0026 provisioning-receipt variant of [`identity_materialized_data_v1`].
625///
626/// Identical wire format to the base constructor for the shared fields, plus
627/// the [`ProvisioningWitness`] fields recording what cellos actually
628/// provisioned for a cloud identity. Each witness field is emitted only when
629/// `Some`, so a witness with no populated fields produces a payload
630/// byte-identical to the base constructor — the extension is additive and
631/// absent from the legacy v1 payload, which consumers ignore per the v1
632/// schema's `additionalProperties` posture.
633///
634/// cellos does **not** enforce the provisioned scope; these fields are the
635/// audit witness of a host-backend provisioning outcome (ADR-0026 §Decision 4).
636pub fn identity_materialized_data_v1_provisioned(
637    spec: &ExecutionCellSpec,
638    cell_id: &str,
639    run_id: Option<&str>,
640    identity: &WorkloadIdentity,
641    witness: &ProvisioningWitness<'_>,
642) -> Result<Value, serde_json::Error> {
643    let mut data = identity_materialized_data_v1(spec, cell_id, run_id, identity)?;
644    let m = data
645        .as_object_mut()
646        .expect("identity_materialized_data_v1 returns a JSON object");
647    if let Some(arn) = witness.provisioned_role_arn {
648        m.insert("provisionedRoleArn".to_string(), json!(arn));
649    }
650    if let Some(digest) = witness.session_policy_digest {
651        m.insert("sessionPolicyDigest".to_string(), json!(digest));
652    }
653    if let Some(oid) = witness.azure_object_id {
654        m.insert("azureObjectId".to_string(), json!(oid));
655    }
656    if let Some(jti) = witness.credential_jti {
657        m.insert("credentialJti".to_string(), json!(jti));
658    }
659    Ok(data)
660}
661
662/// `data` for event type `dev.cellos.events.cell.identity.v1.revoked`.
663///
664/// Schema: `contracts/schemas/cell-identity-revoked-data-v1.schema.json`.
665///
666/// `provenance` (Tranche-1 seam-freeze G2) — when set, points at the parent
667/// event that caused this revocation. The supervisor populates it with the
668/// `cell.lifecycle.v1.started` envelope so taudit can stitch revoke →
669/// lifecycle → spec without operator joins. Omitted from the JSON when
670/// `None`, preserving the v1 schema's backward-compat semantics.
671pub fn identity_revoked_data_v1(
672    spec: &ExecutionCellSpec,
673    cell_id: &str,
674    run_id: Option<&str>,
675    identity: &WorkloadIdentity,
676    reason: Option<&str>,
677    provenance: Option<&Provenance>,
678) -> Result<Value, serde_json::Error> {
679    let mut m = Map::new();
680    m.insert("cellId".to_string(), json!(cell_id));
681    m.insert("specId".to_string(), json!(&spec.id));
682    m.insert("identity".to_string(), serde_json::to_value(identity)?);
683    if let Some(r) = run_id {
684        m.insert("runId".to_string(), json!(r));
685    }
686    if let Some(c) = &spec.correlation {
687        m.insert("correlation".to_string(), serde_json::to_value(c)?);
688    }
689    if let Some(s) = reason {
690        m.insert("reason".to_string(), json!(s));
691    }
692    if let Some(p) = provenance {
693        m.insert("provenance".to_string(), serde_json::to_value(p)?);
694    }
695    Ok(Value::Object(m))
696}
697
698/// `data` for event type `dev.cellos.events.cell.identity.v1.failed`.
699///
700/// Schema: `contracts/schemas/cell-identity-failed-data-v1.schema.json`.
701pub fn identity_failed_data_v1(
702    spec: &ExecutionCellSpec,
703    cell_id: &str,
704    run_id: Option<&str>,
705    identity: &WorkloadIdentity,
706    operation: IdentityFailureOperation,
707    reason: &str,
708) -> Result<Value, serde_json::Error> {
709    let mut m = Map::new();
710    m.insert("cellId".to_string(), json!(cell_id));
711    m.insert("specId".to_string(), json!(&spec.id));
712    m.insert("identity".to_string(), serde_json::to_value(identity)?);
713    m.insert("operation".to_string(), serde_json::to_value(operation)?);
714    m.insert("reason".to_string(), json!(reason));
715    if let Some(r) = run_id {
716        m.insert("runId".to_string(), json!(r));
717    }
718    if let Some(c) = &spec.correlation {
719        m.insert("correlation".to_string(), serde_json::to_value(c)?);
720    }
721    Ok(Value::Object(m))
722}
723
724/// `data` for event type `dev.cellos.events.cell.command.v1.completed`.
725///
726/// Schema: `contracts/schemas/cell-command-completed-data-v1.schema.json`.
727pub fn command_completed_data_v1(
728    spec: &ExecutionCellSpec,
729    cell_id: &str,
730    run_id: Option<&str>,
731    argv: &[String],
732    exit_code: i32,
733    duration_ms: u64,
734    spawn_error: Option<&str>,
735) -> Result<Value, serde_json::Error> {
736    let mut m = Map::new();
737    m.insert("cellId".to_string(), json!(cell_id));
738    m.insert("specId".to_string(), json!(&spec.id));
739    m.insert("exitCode".to_string(), json!(exit_code));
740    m.insert("durationMs".to_string(), json!(duration_ms));
741    m.insert("argv".to_string(), json!(argv));
742    if let Some(r) = run_id {
743        m.insert("runId".to_string(), json!(r));
744    }
745    if let Some(c) = &spec.correlation {
746        m.insert("correlation".to_string(), serde_json::to_value(c)?);
747    }
748    if let Some(s) = spawn_error {
749        m.insert("spawnError".to_string(), json!(s));
750    }
751    Ok(Value::Object(m))
752}
753
754/// `dev.cellos.events.cell.observability.v1.network_scope`
755///
756/// Schema: `contracts/schemas/cell-observability-network-scope-v1.schema.json`.
757pub fn observability_network_scope_data_v1(
758    spec: &ExecutionCellSpec,
759    cell_id: &str,
760    run_id: Option<&str>,
761    egress_rule_count: usize,
762    has_opaque_network_authority: bool,
763) -> Result<Value, serde_json::Error> {
764    let mut m = Map::new();
765    m.insert("cellId".to_string(), json!(cell_id));
766    m.insert("specId".to_string(), json!(&spec.id));
767    m.insert("egressRuleCount".to_string(), json!(egress_rule_count));
768    m.insert(
769        "hasOpaqueNetworkAuthority".to_string(),
770        json!(has_opaque_network_authority),
771    );
772    if let Some(r) = run_id {
773        m.insert("runId".to_string(), json!(r));
774    }
775    if let Some(c) = &spec.correlation {
776        m.insert("correlation".to_string(), serde_json::to_value(c)?);
777    }
778    Ok(Value::Object(m))
779}
780
781/// `dev.cellos.events.cell.observability.v1.process_spawned`
782///
783/// Schema: `contracts/schemas/cell-observability-process-spawned-v1.schema.json`.
784pub fn observability_process_spawned_data_v1(
785    spec: &ExecutionCellSpec,
786    cell_id: &str,
787    run_id: Option<&str>,
788    program: &str,
789    argc: usize,
790) -> Result<Value, serde_json::Error> {
791    let mut m = Map::new();
792    m.insert("cellId".to_string(), json!(cell_id));
793    m.insert("specId".to_string(), json!(&spec.id));
794    m.insert("program".to_string(), json!(program));
795    m.insert("argc".to_string(), json!(argc));
796    if let Some(r) = run_id {
797        m.insert("runId".to_string(), json!(r));
798    }
799    if let Some(c) = &spec.correlation {
800        m.insert("correlation".to_string(), serde_json::to_value(c)?);
801    }
802    Ok(Value::Object(m))
803}
804
805/// `dev.cellos.events.cell.observability.v1.fs_touch`
806///
807/// Schema: `contracts/schemas/cell-observability-fs-touch-v1.schema.json`.
808pub fn observability_fs_touch_export_data_v1(
809    spec: &ExecutionCellSpec,
810    cell_id: &str,
811    run_id: Option<&str>,
812    source_path: &str,
813    artifact_name: &str,
814) -> Result<Value, serde_json::Error> {
815    let mut m = Map::new();
816    m.insert("cellId".to_string(), json!(cell_id));
817    m.insert("specId".to_string(), json!(&spec.id));
818    m.insert("purpose".to_string(), json!("export"));
819    m.insert("sourcePath".to_string(), json!(source_path));
820    m.insert("artifactName".to_string(), json!(artifact_name));
821    if let Some(r) = run_id {
822        m.insert("runId".to_string(), json!(r));
823    }
824    if let Some(c) = &spec.correlation {
825        m.insert("correlation".to_string(), serde_json::to_value(c)?);
826    }
827    Ok(Value::Object(m))
828}
829
830/// `dev.cellos.events.cell.export.v1.completed`
831///
832/// Schema: `contracts/schemas/cell-export-completed-v1.schema.json`.
833pub fn export_completed_data_v1(
834    spec: &ExecutionCellSpec,
835    cell_id: &str,
836    run_id: Option<&str>,
837    artifact_name: &str,
838    bytes_written: u64,
839    destination_relative: &str,
840) -> Result<Value, serde_json::Error> {
841    let mut m = Map::new();
842    m.insert("cellId".to_string(), json!(cell_id));
843    m.insert("specId".to_string(), json!(&spec.id));
844    m.insert("artifactName".to_string(), json!(artifact_name));
845    m.insert("bytesWritten".to_string(), json!(bytes_written));
846    m.insert(
847        "destinationRelative".to_string(),
848        json!(destination_relative),
849    );
850    if let Some(r) = run_id {
851        m.insert("runId".to_string(), json!(r));
852    }
853    if let Some(c) = &spec.correlation {
854        m.insert("correlation".to_string(), serde_json::to_value(c)?);
855    }
856    Ok(Value::Object(m))
857}
858
859/// `dev.cellos.events.cell.export.v2.completed`
860///
861/// Schema: `contracts/schemas/cell-export-completed-v2.schema.json`.
862///
863/// `provenance` (Tranche-1 seam-freeze G2) — when set, points at the parent
864/// event that caused this export receipt. The supervisor populates it with
865/// the `cell.lifecycle.v1.started` envelope of the originating cell run so
866/// downstream consumers (tencrypt envelope promotion, taudit graph build)
867/// can walk artifact → lifecycle → spec without operator joins. Omitted
868/// from the JSON when `None` to preserve v2 schema backward-compat.
869pub fn export_completed_data_v2(
870    spec: &ExecutionCellSpec,
871    cell_id: &str,
872    run_id: Option<&str>,
873    artifact_name: &str,
874    receipt: &ExportReceipt,
875    provenance: Option<&Provenance>,
876) -> Result<Value, serde_json::Error> {
877    let mut m = Map::new();
878    m.insert("cellId".to_string(), json!(cell_id));
879    m.insert("specId".to_string(), json!(&spec.id));
880    m.insert("artifactName".to_string(), json!(artifact_name));
881    m.insert("receipt".to_string(), serde_json::to_value(receipt)?);
882    if let Some(r) = run_id {
883        m.insert("runId".to_string(), json!(r));
884    }
885    if let Some(c) = &spec.correlation {
886        m.insert("correlation".to_string(), serde_json::to_value(c)?);
887    }
888    if let Some(p) = provenance {
889        m.insert("provenance".to_string(), serde_json::to_value(p)?);
890    }
891    Ok(Value::Object(m))
892}
893
894/// `dev.cellos.events.cell.export.v2.failed`
895///
896/// Schema: `contracts/schemas/cell-export-failed-v2.schema.json`.
897///
898/// `provenance` (Tranche-1 seam-freeze G2) — when set, points at the parent
899/// event that caused this export attempt. The supervisor populates it with
900/// the `cell.lifecycle.v1.started` envelope of the originating cell run so
901/// downstream consumers (tencrypt envelope promotion, taudit graph build)
902/// can walk failed artifact → lifecycle → spec without operator joins.
903/// Omitted from the JSON when `None` to preserve v2 schema backward-compat.
904#[allow(clippy::too_many_arguments)] // mirrors CloudEvent payload fields
905pub fn export_failed_data_v2(
906    spec: &ExecutionCellSpec,
907    cell_id: &str,
908    run_id: Option<&str>,
909    artifact_name: &str,
910    target_kind: crate::ExportReceiptTargetKind,
911    target_name: Option<&str>,
912    destination: Option<&str>,
913    reason: &str,
914    provenance: Option<&Provenance>,
915) -> Result<Value, serde_json::Error> {
916    let mut m = Map::new();
917    m.insert("cellId".to_string(), json!(cell_id));
918    m.insert("specId".to_string(), json!(&spec.id));
919    m.insert("artifactName".to_string(), json!(artifact_name));
920    m.insert("targetKind".to_string(), serde_json::to_value(target_kind)?);
921    m.insert("reason".to_string(), json!(reason));
922    if let Some(name) = target_name {
923        m.insert("targetName".to_string(), json!(name));
924    }
925    if let Some(dest) = destination {
926        m.insert("destination".to_string(), json!(dest));
927    }
928    if let Some(r) = run_id {
929        m.insert("runId".to_string(), json!(r));
930    }
931    if let Some(c) = &spec.correlation {
932        m.insert("correlation".to_string(), serde_json::to_value(c)?);
933    }
934    if let Some(p) = provenance {
935        m.insert("provenance".to_string(), serde_json::to_value(p)?);
936    }
937    Ok(Value::Object(m))
938}
939
940/// `dev.cellos.events.cell.observability.v1.network_policy`
941///
942/// Emitted when `CELLOS_SUBPROCESS_UNSHARE` includes the `net` flag, announcing the
943/// network isolation mode and declared egress rules for this cell run. The isolation is
944/// enforced by the Linux network namespace (no external connectivity without explicit routing).
945/// Optional nftables rules may further filter allowed egress within the namespace.
946pub fn observability_network_policy_data_v1(
947    spec: &ExecutionCellSpec,
948    cell_id: &str,
949    run_id: Option<&str>,
950    isolation_mode: &str,
951    egress_rules: &[crate::EgressRule],
952) -> Result<Value, serde_json::Error> {
953    let mut m = Map::new();
954    m.insert("cellId".to_string(), json!(cell_id));
955    m.insert("specId".to_string(), json!(&spec.id));
956    m.insert("isolationMode".to_string(), json!(isolation_mode));
957    m.insert("declaredEgressCount".to_string(), json!(egress_rules.len()));
958    m.insert(
959        "declaredEgress".to_string(),
960        serde_json::to_value(egress_rules)?,
961    );
962    if let Some(r) = run_id {
963        m.insert("runId".to_string(), json!(r));
964    }
965    if let Some(c) = &spec.correlation {
966        m.insert("correlation".to_string(), serde_json::to_value(c)?);
967    }
968    Ok(Value::Object(m))
969}
970
971/// `dev.cellos.events.cell.observability.v1.network_enforcement`
972///
973/// Emitted after a `spec.run` subprocess exits when the Linux path used `CLONE_NEWNET`, summarizing
974/// whether supplementary nftables rules were applied and the command exit code (L2-04).
975///
976/// Schema: `contracts/schemas/cell-observability-network-enforcement-v1.schema.json`.
977#[allow(clippy::too_many_arguments)]
978pub fn observability_network_enforcement_data_v1(
979    spec: &ExecutionCellSpec,
980    cell_id: &str,
981    run_id: Option<&str>,
982    nft_rules_applied: bool,
983    declared_egress_rule_count: usize,
984    command_exit_code: i32,
985    spawn_error: Option<&str>,
986) -> Result<Value, serde_json::Error> {
987    let supplementary = nft_rules_applied && declared_egress_rule_count > 0;
988    let mut m = Map::new();
989    m.insert("cellId".to_string(), json!(cell_id));
990    m.insert("specId".to_string(), json!(&spec.id));
991    m.insert("isolationMode".to_string(), json!("clone_newnet"));
992    m.insert("nftRulesApplied".to_string(), json!(nft_rules_applied));
993    m.insert(
994        "declaredEgressRuleCount".to_string(),
995        json!(declared_egress_rule_count),
996    );
997    m.insert(
998        "supplementaryEgressFilterActive".to_string(),
999        json!(supplementary),
1000    );
1001    m.insert("commandExitCode".to_string(), json!(command_exit_code));
1002    if let Some(r) = run_id {
1003        m.insert("runId".to_string(), json!(r));
1004    }
1005    if let Some(s) = spawn_error {
1006        m.insert("spawnError".to_string(), json!(s));
1007    }
1008    if let Some(c) = &spec.correlation {
1009        m.insert("correlation".to_string(), serde_json::to_value(c)?);
1010    }
1011    Ok(Value::Object(m))
1012}
1013
1014/// Default `keysetId` when the operator has not published a [`trust-keyset-v1`] document yet.
1015///
1016/// [`trust-keyset-v1`]: https://cellos.dev/schemas/trust-keyset-v1.schema.json
1017pub const TRUST_PLANE_BUILTIN_KEYSET_ID: &str = "cellos:builtin-v0";
1018
1019/// Default `issuerKid` for resolver-style observability when no published keyset is wired.
1020pub const TRUST_PLANE_BUILTIN_RESOLVER_KID: &str = "cellos-local-resolve-v0";
1021
1022/// Default `issuerKid` for L7 gate observability when no published keyset is wired.
1023pub const TRUST_PLANE_BUILTIN_L7_KID: &str = "cellos-local-l7-v0";
1024
1025/// Synthetic FQDN for aggregate declared-egress materialization events (`dns_target_set`).
1026pub const TRUST_PLANE_AGGREGATE_EGRESS_FQDN: &str = "declared-egress.trust.cellos.internal";
1027
1028/// `dev.cellos.events.cell.observability.v1.dns_resolution`
1029///
1030/// Schema: `contracts/schemas/cell-observability-dns-resolution-v1.schema.json`.
1031#[allow(clippy::too_many_arguments)]
1032pub fn observability_dns_resolution_data_v1(
1033    spec: &ExecutionCellSpec,
1034    cell_id: &str,
1035    run_id: Option<&str>,
1036    fqdn: &str,
1037    resolved_at: &str,
1038    targets: &[(&str, &str, Option<u16>)],
1039    ttl_seconds: i64,
1040    policy_digest: &str,
1041    keyset_id: &str,
1042    issuer_kid: &str,
1043    receipt_id: Option<&str>,
1044) -> Result<Value, serde_json::Error> {
1045    let mut rows = Vec::with_capacity(targets.len());
1046    for (addr, family, port) in targets {
1047        let mut row = Map::new();
1048        row.insert("address".to_string(), json!(addr));
1049        row.insert("family".to_string(), json!(family));
1050        if let Some(p) = port {
1051            row.insert("port".to_string(), json!(p));
1052        }
1053        rows.push(Value::Object(row));
1054    }
1055    let mut m = Map::new();
1056    m.insert("cellId".to_string(), json!(cell_id));
1057    m.insert("specId".to_string(), json!(&spec.id));
1058    if let Some(r) = run_id {
1059        m.insert("runId".to_string(), json!(r));
1060    }
1061    if let Some(rid) = receipt_id {
1062        m.insert("receiptId".to_string(), json!(rid));
1063    }
1064    m.insert("fqdn".to_string(), json!(fqdn));
1065    m.insert("resolvedAt".to_string(), json!(resolved_at));
1066    m.insert("targets".to_string(), Value::Array(rows));
1067    m.insert("ttlSeconds".to_string(), json!(ttl_seconds));
1068    m.insert("policyDigest".to_string(), json!(policy_digest));
1069    m.insert("keysetId".to_string(), json!(keyset_id));
1070    m.insert("issuerKid".to_string(), json!(issuer_kid));
1071    if let Some(c) = &spec.correlation {
1072        m.insert("correlation".to_string(), serde_json::to_value(c)?);
1073    }
1074    Ok(Value::Object(m))
1075}
1076
1077/// `dev.cellos.events.cell.observability.v1.dns_target_set`
1078///
1079/// Schema: `contracts/schemas/cell-observability-dns-target-set-v1.schema.json`.
1080#[allow(clippy::too_many_arguments)]
1081pub fn observability_dns_target_set_data_v1(
1082    spec: &ExecutionCellSpec,
1083    cell_id: &str,
1084    run_id: Option<&str>,
1085    fqdn: &str,
1086    previous_digest: &str,
1087    current_digest: &str,
1088    reason: &str,
1089    updated_at: &str,
1090    keyset_id: &str,
1091    issuer_kid: &str,
1092) -> Result<Value, serde_json::Error> {
1093    let mut m = Map::new();
1094    m.insert("cellId".to_string(), json!(cell_id));
1095    m.insert("specId".to_string(), json!(&spec.id));
1096    if let Some(r) = run_id {
1097        m.insert("runId".to_string(), json!(r));
1098    }
1099    m.insert("fqdn".to_string(), json!(fqdn));
1100    m.insert("previousDigest".to_string(), json!(previous_digest));
1101    m.insert("currentDigest".to_string(), json!(current_digest));
1102    m.insert("reason".to_string(), json!(reason));
1103    m.insert("updatedAt".to_string(), json!(updated_at));
1104    m.insert("keysetId".to_string(), json!(keyset_id));
1105    m.insert("issuerKid".to_string(), json!(issuer_kid));
1106    if let Some(c) = &spec.correlation {
1107        m.insert("correlation".to_string(), serde_json::to_value(c)?);
1108    }
1109    Ok(Value::Object(m))
1110}
1111
1112/// `dev.cellos.events.cell.observability.v1.dns_authority_drift`
1113///
1114/// Schema: `contracts/schemas/cell-observability-dns-authority-drift-v1.schema.json`.
1115///
1116/// Emitted by the SEC-21 host-controlled resolver refresh loop when the
1117/// resolved target set for a declared FQDN differs from the prior observation.
1118/// All fields mirror [`DnsAuthorityDrift`] one-for-one; this builder exists so
1119/// supervisor wiring can emit the payload without re-deriving the canonical
1120/// JSON shape from the struct.
1121#[allow(clippy::too_many_arguments)]
1122pub fn dns_authority_drift_data_v1(drift: &DnsAuthorityDrift) -> Result<Value, serde_json::Error> {
1123    serde_json::to_value(drift)
1124}
1125
1126/// CloudEvent envelope constructor for `dns_authority_drift` (SEC-21).
1127///
1128/// Mirrors the supervisor-local `cloud_event(...)` helper but lives in
1129/// `cellos-core` so library callers (resolver refresh module, taudit) can
1130/// build the envelope without taking a supervisor-internal dependency.
1131///
1132/// `source` should identify the publisher (e.g. `"cellos-supervisor"` or
1133/// `"cellos-resolver-refresh"`). `time` is the RFC3339 emission timestamp;
1134/// independent of `drift.observed_at`, which records when the *drift* was
1135/// observed (the two are typically equal but conceptually distinct).
1136pub fn cloud_event_v1_dns_authority_drift(
1137    source: &str,
1138    time: &str,
1139    drift: &DnsAuthorityDrift,
1140) -> Result<CloudEventV1, serde_json::Error> {
1141    Ok(CloudEventV1 {
1142        specversion: "1.0".into(),
1143        id: uuid::Uuid::new_v4().to_string(),
1144        source: source.to_string(),
1145        ty: "dev.cellos.events.cell.observability.v1.dns_authority_drift".into(),
1146        datacontenttype: Some("application/json".into()),
1147        data: Some(dns_authority_drift_data_v1(drift)?),
1148        time: Some(time.to_string()),
1149        traceparent: None,
1150        cex: None,
1151    })
1152}
1153
1154/// `dev.cellos.events.cell.observability.v1.dns_authority_rebind_threshold`
1155/// (SEC-21 Phase 3e).
1156///
1157/// Schema: `contracts/schemas/cell-observability-dns-authority-rebind-threshold-v1.schema.json`.
1158///
1159/// Emitted when the SEC-21 host-controlled resolver refresh observes a novel
1160/// IP for a declared FQDN that pushes the cumulative distinct-IP count past
1161/// the operator-declared `rebindingPolicy.maxNovelIpsPerHostname` cap. All
1162/// fields mirror [`DnsAuthorityRebindThreshold`] one-for-one; this builder
1163/// exists so resolver-refresh wiring can serialize the payload without
1164/// re-deriving the canonical JSON shape from the struct.
1165pub fn dns_authority_rebind_threshold_data_v1(
1166    payload: &DnsAuthorityRebindThreshold,
1167) -> Result<Value, serde_json::Error> {
1168    serde_json::to_value(payload)
1169}
1170
1171/// CloudEvent envelope constructor for `dns_authority_rebind_threshold`
1172/// (SEC-21 Phase 3e).
1173///
1174/// Mirrors [`cloud_event_v1_dns_authority_drift`] for the rebinding-threshold
1175/// signal. Lives in `cellos-core` so the resolver-refresh module can build
1176/// envelopes without taking a supervisor-internal dependency.
1177///
1178/// `source` should identify the publisher (e.g. `"cellos-supervisor"` or
1179/// `"cellos-resolver-refresh"`). `time` is the RFC3339 emission timestamp;
1180/// independent of `payload.observed_at`, which records when the threshold was
1181/// observed (the two are typically equal but conceptually distinct).
1182pub fn cloud_event_v1_dns_authority_rebind_threshold(
1183    source: &str,
1184    time: &str,
1185    payload: &DnsAuthorityRebindThreshold,
1186) -> Result<CloudEventV1, serde_json::Error> {
1187    Ok(CloudEventV1 {
1188        specversion: "1.0".into(),
1189        id: uuid::Uuid::new_v4().to_string(),
1190        source: source.to_string(),
1191        ty: "dev.cellos.events.cell.observability.v1.dns_authority_rebind_threshold".into(),
1192        datacontenttype: Some("application/json".into()),
1193        data: Some(dns_authority_rebind_threshold_data_v1(payload)?),
1194        time: Some(time.to_string()),
1195        traceparent: None,
1196        cex: None,
1197    })
1198}
1199
1200/// `dev.cellos.events.cell.observability.v1.dns_authority_rebind_rejected`
1201/// (SEC-21 Phase 3e).
1202///
1203/// Schema: `contracts/schemas/cell-observability-dns-authority-rebind-rejected-v1.schema.json`.
1204///
1205/// Emitted when the SEC-21 host-controlled resolver refresh observes an IP
1206/// for a declared FQDN that is NOT in the operator's
1207/// `rebindingPolicy.responseIpAllowlist`. Only emitted when the allowlist is
1208/// non-empty for the hostname; one event per offending IP per tick. Combined
1209/// with `rejectOnRebind=true`, the IP is also dropped from the resolved
1210/// target set passed to the workload.
1211pub fn dns_authority_rebind_rejected_data_v1(
1212    payload: &DnsAuthorityRebindRejected,
1213) -> Result<Value, serde_json::Error> {
1214    serde_json::to_value(payload)
1215}
1216
1217/// CloudEvent envelope constructor for `dns_authority_rebind_rejected`
1218/// (SEC-21 Phase 3e).
1219///
1220/// Mirrors [`cloud_event_v1_dns_authority_rebind_threshold`] for the
1221/// allowlist-violation signal. One event per offending IP per tick (so a
1222/// single response containing 3 disallowed IPs produces 3 rejected events).
1223pub fn cloud_event_v1_dns_authority_rebind_rejected(
1224    source: &str,
1225    time: &str,
1226    payload: &DnsAuthorityRebindRejected,
1227) -> Result<CloudEventV1, serde_json::Error> {
1228    Ok(CloudEventV1 {
1229        specversion: "1.0".into(),
1230        id: uuid::Uuid::new_v4().to_string(),
1231        source: source.to_string(),
1232        ty: "dev.cellos.events.cell.observability.v1.dns_authority_rebind_rejected".into(),
1233        datacontenttype: Some("application/json".into()),
1234        data: Some(dns_authority_rebind_rejected_data_v1(payload)?),
1235        time: Some(time.to_string()),
1236        traceparent: None,
1237        cex: None,
1238    })
1239}
1240
1241/// `dev.cellos.events.cell.observability.v1.dns_authority_dnssec_failed`
1242/// (SEC-21 Phase 3h).
1243///
1244/// Schema: `contracts/schemas/cell-observability-dns-authority-dnssec-failed-v1.schema.json`.
1245///
1246/// Emitted by the SEC-21 host-controlled resolver-refresh loop when an opt-in
1247/// DNSSEC-validating resolver returns a response that fails the chain-of-trust
1248/// check (or when the zone is not signed and the operator has opted into
1249/// DNSSEC). All fields mirror [`DnsAuthorityDnssecFailed`] one-for-one; this
1250/// builder exists so resolver-refresh wiring can serialize the payload without
1251/// re-deriving the canonical JSON shape from the struct.
1252pub fn dns_authority_dnssec_failed_data_v1(
1253    payload: &DnsAuthorityDnssecFailed,
1254) -> Result<Value, serde_json::Error> {
1255    serde_json::to_value(payload)
1256}
1257
1258/// CloudEvent envelope constructor for `dns_authority_dnssec_failed`
1259/// (SEC-21 Phase 3h).
1260///
1261/// Mirrors [`cloud_event_v1_dns_authority_drift`] for the DNSSEC-failure
1262/// signal. Lives in `cellos-core` so the resolver-refresh module can build
1263/// envelopes without taking a supervisor-internal dependency.
1264///
1265/// `source` should identify the publisher (e.g. `"cellos-supervisor"` or
1266/// `"cellos-resolver-refresh"`). `time` is the RFC3339 emission timestamp;
1267/// independent of `payload.observed_at`, which records when the failure
1268/// was observed (the two are typically equal but conceptually distinct).
1269pub fn cloud_event_v1_dns_authority_dnssec_failed(
1270    source: &str,
1271    time: &str,
1272    payload: &DnsAuthorityDnssecFailed,
1273) -> Result<CloudEventV1, serde_json::Error> {
1274    Ok(CloudEventV1 {
1275        specversion: "1.0".into(),
1276        id: uuid::Uuid::new_v4().to_string(),
1277        source: source.to_string(),
1278        ty: "dev.cellos.events.cell.observability.v1.dns_authority_dnssec_failed".into(),
1279        datacontenttype: Some("application/json".into()),
1280        data: Some(dns_authority_dnssec_failed_data_v1(payload)?),
1281        time: Some(time.to_string()),
1282        traceparent: None,
1283        cex: None,
1284    })
1285}
1286
1287/// `dev.cellos.events.cell.observability.v1.dns_query`
1288///
1289/// Schema: `contracts/schemas/cell-observability-dns-query-v1.schema.json`.
1290///
1291/// Emitted by the SEAM-1 / L2-04 in-netns DNS proxy on every query — allowed,
1292/// denied, malformed, or upstream-timed-out. All fields mirror [`DnsQueryEvent`]
1293/// one-for-one; this builder exists so dataplane callers can serialize the
1294/// payload without re-deriving the canonical JSON shape from the struct.
1295pub fn dns_query_data_v1(event: &DnsQueryEvent) -> Result<Value, serde_json::Error> {
1296    serde_json::to_value(event)
1297}
1298
1299/// CloudEvent envelope constructor for `dns_query` (SEAM-1 / L2-04).
1300///
1301/// Mirrors [`cloud_event_v1_dns_authority_drift`] for the per-query DNS proxy
1302/// signal. Lives in `cellos-core` so the supervisor's DNS proxy module can
1303/// build envelopes without taking a supervisor-internal dependency.
1304///
1305/// `source` should identify the publisher (e.g. `"cellos-supervisor"` or
1306/// `"cellos-dns-proxy"`). `time` is the RFC3339 emission timestamp; typically
1307/// equal to `event.observed_at` but conceptually distinct (the latter is when
1308/// the proxy *observed* the query).
1309pub fn cloud_event_v1_dns_query(
1310    source: &str,
1311    time: &str,
1312    event: &DnsQueryEvent,
1313) -> Result<CloudEventV1, serde_json::Error> {
1314    Ok(CloudEventV1 {
1315        specversion: "1.0".into(),
1316        id: uuid::Uuid::new_v4().to_string(),
1317        source: source.to_string(),
1318        ty: "dev.cellos.events.cell.observability.v1.dns_query".into(),
1319        datacontenttype: Some("application/json".into()),
1320        data: Some(dns_query_data_v1(event)?),
1321        time: Some(time.to_string()),
1322        traceparent: None,
1323        cex: None,
1324    })
1325}
1326
1327/// SEAM-1 Phase 3 — per-query data payload for
1328/// `dev.cellos.events.cell.dns.v1.query_permitted`.
1329///
1330/// Schema: `contracts/schemas/cell-dns-query-permitted-v1.schema.json`.
1331///
1332/// Companion to the existing aggregate `dns_query` event: this is the
1333/// short-form "the allowlist check accepted this query" signal that fires
1334/// as soon as the proxy decides to forward, BEFORE the upstream answer
1335/// arrives. It exists so operators can audit the workload's query
1336/// intent independently of upstream latency / outcome.
1337///
1338/// `qname` is lowercased and trailing-dot-stripped per the upstream
1339/// parser; `qtype` is the IANA mnemonic (`"A"`, `"AAAA"`, etc); `cell_id`
1340/// mirrors `lifecycle.started.cellId`; `resolver` mirrors the
1341/// `dnsAuthority.resolvers[].resolverId` the proxy will forward to.
1342#[must_use]
1343pub fn dns_query_permitted_data_v1(
1344    qname: &str,
1345    qtype: &str,
1346    cell_id: &str,
1347    resolver: &str,
1348) -> Value {
1349    json!({
1350        "schemaVersion": "1.0.0",
1351        "queryName": qname,
1352        "queryType": qtype,
1353        "cellId": cell_id,
1354        "resolver": resolver,
1355    })
1356}
1357
1358/// CloudEvent envelope constructor for `dns_query_permitted` (SEAM-1 Phase 3).
1359pub fn cloud_event_v1_dns_query_permitted(
1360    source: &str,
1361    time: &str,
1362    qname: &str,
1363    qtype: &str,
1364    cell_id: &str,
1365    resolver: &str,
1366) -> CloudEventV1 {
1367    CloudEventV1 {
1368        specversion: "1.0".into(),
1369        id: uuid::Uuid::new_v4().to_string(),
1370        source: source.to_string(),
1371        ty: "dev.cellos.events.cell.dns.v1.query_permitted".into(),
1372        datacontenttype: Some("application/json".into()),
1373        data: Some(dns_query_permitted_data_v1(qname, qtype, cell_id, resolver)),
1374        time: Some(time.to_string()),
1375        traceparent: None,
1376        cex: None,
1377    }
1378}
1379
1380/// SEAM-1 Phase 3 — per-query data payload for
1381/// `dev.cellos.events.cell.dns.v1.query_refused`.
1382///
1383/// Schema: `contracts/schemas/cell-dns-query-refused-v1.schema.json`.
1384///
1385/// Emitted alongside the existing aggregate `dns_query{decision:deny}`
1386/// event when the allowlist check (or query-type gate) short-circuits a
1387/// workload query with REFUSED. The short-form payload is intended for
1388/// SIEM rules that want to fan out on refusal without parsing the
1389/// richer aggregate event.
1390///
1391/// `reason` is one of the proxy's `reasonCode` enum values — typically
1392/// `"denied_not_in_allowlist"` or `"denied_query_type"`.
1393#[must_use]
1394pub fn dns_query_refused_data_v1(qname: &str, qtype: &str, cell_id: &str, reason: &str) -> Value {
1395    json!({
1396        "schemaVersion": "1.0.0",
1397        "queryName": qname,
1398        "queryType": qtype,
1399        "cellId": cell_id,
1400        "reason": reason,
1401    })
1402}
1403
1404/// CloudEvent envelope constructor for `dns_query_refused` (SEAM-1 Phase 3).
1405pub fn cloud_event_v1_dns_query_refused(
1406    source: &str,
1407    time: &str,
1408    qname: &str,
1409    qtype: &str,
1410    cell_id: &str,
1411    reason: &str,
1412) -> CloudEventV1 {
1413    CloudEventV1 {
1414        specversion: "1.0".into(),
1415        id: uuid::Uuid::new_v4().to_string(),
1416        source: source.to_string(),
1417        ty: "dev.cellos.events.cell.dns.v1.query_refused".into(),
1418        datacontenttype: Some("application/json".into()),
1419        data: Some(dns_query_refused_data_v1(qname, qtype, cell_id, reason)),
1420        time: Some(time.to_string()),
1421        traceparent: None,
1422        cex: None,
1423    }
1424}
1425
1426/// `dev.cellos.events.cell.trust.v1.keyset_verified` (SEC-25 Phase 2).
1427///
1428/// Schema: `contracts/schemas/cell-trust-keyset-verified-v1.schema.json`.
1429///
1430/// Emitted once per supervisor startup when `CELLOS_TRUST_KEYSET_PATH` points
1431/// at a `signed-trust-keyset-envelope-v1` document AND
1432/// `verify_signed_trust_keyset_envelope` accepted at least one signature
1433/// against the operator-loaded keyring (`CELLOS_TRUST_VERIFY_KEYS_PATH`).
1434///
1435/// `correlation_id` is optional; the supervisor passes `None` for the default
1436/// startup-time emission, but other producers may bind a correlation id when
1437/// the verification is part of a larger orchestration flow.
1438pub fn keyset_verified_data_v1(
1439    keyset_id: &str,
1440    payload_digest: &str,
1441    verified_signer_kid: &str,
1442    verified_at: &str,
1443    correlation_id: Option<&str>,
1444) -> Result<Value, serde_json::Error> {
1445    let mut m = Map::new();
1446    m.insert("schemaVersion".to_string(), json!("1.0.0"));
1447    m.insert("keysetId".to_string(), json!(keyset_id));
1448    m.insert("payloadDigest".to_string(), json!(payload_digest));
1449    m.insert("verifiedSignerKid".to_string(), json!(verified_signer_kid));
1450    m.insert("verifiedAt".to_string(), json!(verified_at));
1451    if let Some(cid) = correlation_id {
1452        m.insert("correlationId".to_string(), json!(cid));
1453    }
1454    Ok(Value::Object(m))
1455}
1456
1457/// CloudEvent envelope constructor for `keyset_verified` (SEC-25 Phase 2).
1458///
1459/// `source` should typically be `"cellos-supervisor"` for the startup-time
1460/// emission; sibling tooling (`cellos-trustd` etc.) may publish under a
1461/// different source identifier when verifying envelopes off-host.
1462pub fn cloud_event_v1_keyset_verified(
1463    source: &str,
1464    time: &str,
1465    keyset_id: &str,
1466    payload_digest: &str,
1467    verified_signer_kid: &str,
1468    verified_at: &str,
1469    correlation_id: Option<&str>,
1470) -> Result<CloudEventV1, serde_json::Error> {
1471    Ok(CloudEventV1 {
1472        specversion: "1.0".into(),
1473        id: uuid::Uuid::new_v4().to_string(),
1474        source: source.to_string(),
1475        ty: "dev.cellos.events.cell.trust.v1.keyset_verified".into(),
1476        datacontenttype: Some("application/json".into()),
1477        data: Some(keyset_verified_data_v1(
1478            keyset_id,
1479            payload_digest,
1480            verified_signer_kid,
1481            verified_at,
1482            correlation_id,
1483        )?),
1484        time: Some(time.to_string()),
1485        traceparent: None,
1486        cex: None,
1487    })
1488}
1489
1490/// `dev.cellos.events.cell.trust.v1.keyset_verification_failed` (SEC-25 Phase 2).
1491///
1492/// Schema: `contracts/schemas/cell-trust-keyset-verification-failed-v1.schema.json`.
1493///
1494/// Emitted once per supervisor startup when `CELLOS_TRUST_KEYSET_PATH` is set
1495/// AND verification fails (file open / JSON parse / digest mismatch / no
1496/// signature verified). Under `CELLOS_REQUIRE_TRUST_VERIFY_KEYS=1` the
1497/// supervisor instead returns an error from `build_supervisor` and never
1498/// emits this event — fail-closed startup is the explicit operator opt-in;
1499/// fail-open is the default and surfaces this event so degraded trust posture
1500/// is visible in the audit stream.
1501///
1502/// `attempted_keyset_path` MUST be the file's basename (the supervisor's
1503/// emitter strips the directory portion to avoid leaking deployment-side
1504/// metadata into the event stream — see the schema description).
1505pub fn keyset_verification_failed_data_v1(
1506    attempted_keyset_path: &str,
1507    reason: &str,
1508    failed_at: &str,
1509    correlation_id: Option<&str>,
1510) -> Result<Value, serde_json::Error> {
1511    let mut m = Map::new();
1512    m.insert("schemaVersion".to_string(), json!("1.0.0"));
1513    m.insert(
1514        "attemptedKeysetPath".to_string(),
1515        json!(attempted_keyset_path),
1516    );
1517    m.insert("reason".to_string(), json!(reason));
1518    m.insert("failedAt".to_string(), json!(failed_at));
1519    if let Some(cid) = correlation_id {
1520        m.insert("correlationId".to_string(), json!(cid));
1521    }
1522    Ok(Value::Object(m))
1523}
1524
1525/// CloudEvent envelope constructor for `keyset_verification_failed` (SEC-25 Phase 2).
1526pub fn cloud_event_v1_keyset_verification_failed(
1527    source: &str,
1528    time: &str,
1529    attempted_keyset_path: &str,
1530    reason: &str,
1531    failed_at: &str,
1532    correlation_id: Option<&str>,
1533) -> Result<CloudEventV1, serde_json::Error> {
1534    Ok(CloudEventV1 {
1535        specversion: "1.0".into(),
1536        id: uuid::Uuid::new_v4().to_string(),
1537        source: source.to_string(),
1538        ty: "dev.cellos.events.cell.trust.v1.keyset_verification_failed".into(),
1539        datacontenttype: Some("application/json".into()),
1540        data: Some(keyset_verification_failed_data_v1(
1541            attempted_keyset_path,
1542            reason,
1543            failed_at,
1544            correlation_id,
1545        )?),
1546        time: Some(time.to_string()),
1547        traceparent: None,
1548        cex: None,
1549    })
1550}
1551
1552/// `dev.cellos.events.cell.observability.v1.network_flow_decision` (FC-38 Phase 1).
1553///
1554/// Schema: `contracts/schemas/cell-observability-network-flow-decision-v1.schema.json`.
1555///
1556/// Emitted by the supervisor's post-run nft counter scan
1557/// (`nft list ruleset --json`) when `CELLOS_PER_FLOW_ENFORCEMENT_EVENTS=1`.
1558/// scope: honest "policy-applied attribution" — one event per matched or
1559/// applied rule, NOT a real-time per-packet stream. Future eBPF / nflog
1560/// work targets real-time per-flow events.
1561///
1562/// All fields mirror [`NetworkFlowDecision`] one-for-one; this builder exists
1563/// so supervisor wiring can emit the payload without re-deriving the canonical
1564/// JSON shape from the struct.
1565pub fn network_flow_decision_data_v1(
1566    decision: &NetworkFlowDecision,
1567) -> Result<Value, serde_json::Error> {
1568    serde_json::to_value(decision)
1569}
1570
1571/// CloudEvent envelope constructor for `network_flow_decision` (FC-38 Phase 1).
1572///
1573/// Mirrors [`cloud_event_v1_dns_authority_drift`] for the per-flow signal.
1574/// Lives in `cellos-core` so the supervisor's post-run counter-scan path can
1575/// build envelopes without taking a supervisor-internal dependency.
1576///
1577/// `source` should identify the publisher (e.g. `"cellos-supervisor"`).
1578/// `time` is the RFC3339 emission timestamp; typically equal to
1579/// `decision.observed_at` but conceptually distinct (the latter is when the
1580/// supervisor *scraped* the counter, the former when the envelope was built).
1581pub fn cloud_event_v1_network_flow_decision(
1582    source: &str,
1583    time: &str,
1584    decision: &NetworkFlowDecision,
1585) -> Result<CloudEventV1, serde_json::Error> {
1586    Ok(CloudEventV1 {
1587        specversion: "1.0".into(),
1588        id: uuid::Uuid::new_v4().to_string(),
1589        source: source.to_string(),
1590        ty: "dev.cellos.events.cell.observability.v1.network_flow_decision".into(),
1591        datacontenttype: Some("application/json".into()),
1592        data: Some(network_flow_decision_data_v1(decision)?),
1593        time: Some(time.to_string()),
1594        traceparent: None,
1595        cex: None,
1596    })
1597}
1598
1599/// `dev.cellos.events.cell.observability.v1.l7_egress_decision`
1600///
1601/// Schema: `contracts/schemas/cell-observability-l7-egress-decision-v1.schema.json`.
1602#[allow(clippy::too_many_arguments)]
1603pub fn observability_l7_egress_decision_data_v1(
1604    spec: &ExecutionCellSpec,
1605    cell_id: &str,
1606    run_id: Option<&str>,
1607    decision_id: &str,
1608    action: &str,
1609    sni_host: &str,
1610    policy_digest: &str,
1611    keyset_id: &str,
1612    issuer_kid: &str,
1613    reason_code: &str,
1614    rule_ref: Option<&str>,
1615    // scope: per-stream correlation id for h2 paths. `None` for
1616    // SNI / HTTP/1.x / unknown / peek-timeout paths. Additive — the
1617    // schema's `streamId` field is optional so the field is omitted
1618    // from the output when `None`.
1619    stream_id: Option<u32>,
1620) -> Result<Value, serde_json::Error> {
1621    let mut m = Map::new();
1622    m.insert("cellId".to_string(), json!(cell_id));
1623    m.insert("specId".to_string(), json!(&spec.id));
1624    if let Some(r) = run_id {
1625        m.insert("runId".to_string(), json!(r));
1626    }
1627    m.insert("decisionId".to_string(), json!(decision_id));
1628    m.insert("action".to_string(), json!(action));
1629    m.insert("sniHost".to_string(), json!(sni_host));
1630    m.insert("policyDigest".to_string(), json!(policy_digest));
1631    m.insert("keysetId".to_string(), json!(keyset_id));
1632    m.insert("issuerKid".to_string(), json!(issuer_kid));
1633    m.insert("reasonCode".to_string(), json!(reason_code));
1634    if let Some(rr) = rule_ref {
1635        m.insert("ruleRef".to_string(), json!(rr));
1636    }
1637    if let Some(sid) = stream_id {
1638        m.insert("streamId".to_string(), json!(sid));
1639    }
1640    if let Some(c) = &spec.correlation {
1641        m.insert("correlation".to_string(), serde_json::to_value(c)?);
1642    }
1643    Ok(Value::Object(m))
1644}
1645
1646/// `dev.cellos.events.cell.observability.v1.container_security`
1647///
1648/// Emitted once per cell run immediately after `lifecycle.started`, recording
1649/// the Linux capability bitmasks read from `/proc/self/status` at supervisor
1650/// start time.  This gives the audit trail a concrete answer to "what
1651/// capabilities did the cell have?" rather than "deepce could not determine
1652/// capabilities because capsh was not installed."
1653///
1654/// Fields:
1655/// - `capEff`  — effective capability bitmask (hex string, e.g. `"00000000a80425fb"`)
1656/// - `capPrm`  — permitted capability bitmask
1657/// - `capBnd`  — bounding set bitmask
1658/// - `capAmb`  — ambient capability bitmask
1659/// - `capInh`  — inheritable capability bitmask
1660/// - `privileged` — `true` when `capEff` equals `0x1ffffffffff` (all 40 caps set)
1661///
1662/// All fields are `None`/omitted on non-Linux hosts or when `/proc/self/status`
1663/// is unreadable.  Consumers should treat absence as "unknown," not "clean."
1664pub fn observability_container_security_data_v1(
1665    cell_id: &str,
1666    run_id: Option<&str>,
1667    cap_eff: Option<&str>,
1668    cap_prm: Option<&str>,
1669    cap_bnd: Option<&str>,
1670    cap_amb: Option<&str>,
1671    cap_inh: Option<&str>,
1672) -> Value {
1673    let mut m = Map::new();
1674    m.insert("cellId".to_string(), json!(cell_id));
1675    if let Some(r) = run_id {
1676        m.insert("runId".to_string(), json!(r));
1677    }
1678    if let (Some(eff), Some(prm), Some(bnd), Some(amb), Some(inh)) =
1679        (cap_eff, cap_prm, cap_bnd, cap_amb, cap_inh)
1680    {
1681        m.insert("capEff".to_string(), json!(eff));
1682        m.insert("capPrm".to_string(), json!(prm));
1683        m.insert("capBnd".to_string(), json!(bnd));
1684        m.insert("capAmb".to_string(), json!(amb));
1685        m.insert("capInh".to_string(), json!(inh));
1686        // Full privileged set: all 40 defined capabilities (Linux 5.x bitmask).
1687        let privileged = eff == "0000001fffffffff";
1688        m.insert("privileged".to_string(), json!(privileged));
1689    }
1690    Value::Object(m)
1691}
1692
1693/// `dev.cellos.events.cell.compliance.v1.summary`
1694///
1695/// Emitted once per cell run immediately before `lifecycle.destroyed`.
1696/// Captures the compliance-relevant profile of the run: which policy pack
1697/// governed it, what security controls were declared, and the final command
1698/// exit code (when available).
1699///
1700/// This event is the canonical compliance receipt — it allows fleet operators
1701/// and auditors to reconstruct "what policy was in effect and what was
1702/// observed" for every cell run without replaying the full event stream.
1703///
1704/// Schema: `contracts/schemas/cell-compliance-summary-v1.schema.json`.
1705///
1706/// Backward-compatible thin delegate to
1707/// [`compliance_summary_data_v1_with_subjects`] with an empty subject set.
1708/// Existing callers (supervisor and tests) keep their 4-arg signature; the
1709/// resulting payload omits `subjectUrns` per the schema's "absent when empty"
1710/// contract.
1711pub fn compliance_summary_data_v1(
1712    spec: &ExecutionCellSpec,
1713    cell_id: &str,
1714    run_id: Option<&str>,
1715    command_exit_code: Option<i32>,
1716) -> Result<Value, serde_json::Error> {
1717    compliance_summary_data_v1_with_subjects(spec, cell_id, run_id, command_exit_code, &[])
1718}
1719
1720/// Seam-freeze G4 / P0-7 variant of [`compliance_summary_data_v1`] that
1721/// attaches a typed-URN subject set covered by the compliance receipt.
1722///
1723/// `subject_urns` SHOULD be the concrete URNs (cells, leases, exports, …)
1724/// whose lifecycle this compliance.summary attests to. Callers pass `&[]`
1725/// for the legacy "no cross-pointer" shape, which is exactly what the
1726/// thin-delegate [`compliance_summary_data_v1`] does.
1727///
1728/// The `subjectUrns` field is **only emitted when non-empty** — schema
1729/// declares `minItems: 1` and `uniqueItems: true`, so emitting an empty array
1730/// would fail validation. URN shape validation lives in the schema (regex);
1731/// this function does not pre-validate to keep the API allocation-free.
1732///
1733/// Schema: `contracts/schemas/cell-compliance-summary-v1.schema.json`
1734/// (`subjectUrns` field).
1735pub fn compliance_summary_data_v1_with_subjects(
1736    spec: &ExecutionCellSpec,
1737    cell_id: &str,
1738    run_id: Option<&str>,
1739    command_exit_code: Option<i32>,
1740    subject_urns: &[SubjectUrn],
1741) -> Result<Value, serde_json::Error> {
1742    let egress_rule_count = spec
1743        .authority
1744        .egress_rules
1745        .as_ref()
1746        .map(|v| v.len())
1747        .unwrap_or(0);
1748    let export_target_count = spec
1749        .export
1750        .as_ref()
1751        .and_then(|e| e.targets.as_ref())
1752        .map(|t| t.len())
1753        .unwrap_or(0);
1754    let resource_limits_present = spec.run.as_ref().and_then(|r| r.limits.as_ref()).is_some();
1755    let secret_delivery_mode = spec
1756        .run
1757        .as_ref()
1758        .map(|r| serde_json::to_value(&r.secret_delivery))
1759        .transpose()?
1760        .unwrap_or(serde_json::Value::String("env".into()));
1761
1762    let mut m = Map::new();
1763    m.insert("cellId".to_string(), json!(cell_id));
1764    m.insert("specId".to_string(), json!(&spec.id));
1765    m.insert(
1766        "lifetimeTtlSeconds".to_string(),
1767        json!(spec.lifetime.ttl_seconds),
1768    );
1769    m.insert("egressRuleCount".to_string(), json!(egress_rule_count));
1770    m.insert(
1771        "resourceLimitsPresent".to_string(),
1772        json!(resource_limits_present),
1773    );
1774    m.insert("secretDeliveryMode".to_string(), secret_delivery_mode);
1775    m.insert("exportTargetCount".to_string(), json!(export_target_count));
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        if let Some(digest) = &policy.bundle_digest {
1786            m.insert("policyBundleDigest".to_string(), json!(digest));
1787        }
1788    }
1789
1790    if let Some(placement) = &spec.placement {
1791        let mut placement_map = Map::new();
1792        if let Some(pool_id) = &placement.pool_id {
1793            placement_map.insert("poolId".to_string(), json!(pool_id));
1794        }
1795        if let Some(namespace) = &placement.kubernetes_namespace {
1796            placement_map.insert("kubernetesNamespace".to_string(), json!(namespace));
1797        }
1798        if let Some(queue_name) = &placement.queue_name {
1799            placement_map.insert("queueName".to_string(), json!(queue_name));
1800        }
1801        if !placement_map.is_empty() {
1802            m.insert("placement".to_string(), Value::Object(placement_map));
1803        }
1804    }
1805
1806    if let Some(code) = command_exit_code {
1807        m.insert("commandExitCode".to_string(), json!(code));
1808    }
1809    if let Some(r) = run_id {
1810        m.insert("runId".to_string(), json!(r));
1811    }
1812    if let Some(c) = &spec.correlation {
1813        m.insert("correlation".to_string(), serde_json::to_value(c)?);
1814    }
1815
1816    // Seam-freeze G4 / P0-7 cross-pointer. Emit only when non-empty so the
1817    // legacy 4-arg path (and any future "no subjects" caller) keeps producing
1818    // a payload that round-trips against the existing valid example.
1819    if !subject_urns.is_empty() {
1820        let urns: Vec<Value> = subject_urns.iter().map(|u| json!(u)).collect();
1821        m.insert("subjectUrns".to_string(), Value::Array(urns));
1822    }
1823
1824    Ok(Value::Object(m))
1825}
1826
1827/// `data` for event type `dev.cellos.events.cell.policy.v1.rejected`.
1828///
1829/// Emitted when a policy pack's admission gate rejects a cell before the
1830/// lifecycle starts.  The `violations` array lists every failed rule, giving
1831/// operators a complete picture of what must change.
1832///
1833/// Schema: `contracts/schemas/cell-policy-rejected-v1.schema.json`.
1834pub fn policy_rejected_data_v1(
1835    spec: &ExecutionCellSpec,
1836    violations: &[PolicyViolation],
1837) -> Result<Value, serde_json::Error> {
1838    let violation_values: Vec<Value> = violations
1839        .iter()
1840        .map(|v| {
1841            json!({
1842                "rule": v.rule,
1843                "message": v.message,
1844            })
1845        })
1846        .collect();
1847
1848    let mut m = Map::new();
1849    m.insert("specId".to_string(), json!(&spec.id));
1850    m.insert("violationCount".to_string(), json!(violations.len()));
1851    m.insert("violations".to_string(), Value::Array(violation_values));
1852
1853    // Policy attribution — absent when no pack was declared on the spec.
1854    if let Some(policy) = &spec.policy {
1855        if let Some(id) = &policy.pack_id {
1856            m.insert("policyPackId".to_string(), json!(id));
1857        }
1858        if let Some(ver) = &policy.pack_version {
1859            m.insert("policyPackVersion".to_string(), json!(ver));
1860        }
1861    }
1862
1863    if let Some(c) = &spec.correlation {
1864        m.insert("correlation".to_string(), serde_json::to_value(c)?);
1865    }
1866    Ok(Value::Object(m))
1867}
1868
1869/// CloudEvent `type` URN for an admitted cell (ADR-0025 §1).
1870///
1871/// Emitted by the org-root ceiling gate (ADR-0024) when a spec's declared
1872/// authority is a subset of the signed ceiling. Closes the
1873/// "admission inferred only from `lifecycle.started`" gap: an allow is now a
1874/// first-class, offline-verifiable record, not an inference.
1875pub const ADMISSION_ALLOWED_TYPE: &str = "dev.cellos.events.cell.admission.v1.allowed";
1876
1877/// CloudEvent `type` URN for a rejected cell (ADR-0025 §1).
1878///
1879/// Emitted by the org-root ceiling gate (ADR-0024) on the reject early-return
1880/// path, before the verdict's `Err` is propagated. Sibling to
1881/// [`ADMISSION_ALLOWED_TYPE`]; both carry an [`admission_decision_data_v1`]
1882/// payload.
1883pub const ADMISSION_REJECTED_TYPE: &str = "dev.cellos.events.cell.admission.v1.rejected";
1884
1885/// CloudEvent `type` of a signed audit-reconcile receipt (S18, ADR-0029):
1886/// emitted after the spool forwarder (S17) delivers a contiguous range of the
1887/// BLAKE3-chained spool to the broker. Carries an [`audit_reconciled_data_v1`]
1888/// payload. Schema: `contracts/schemas/audit-reconciled-v1.schema.json`.
1889pub const AUDIT_RECONCILED_TYPE: &str = "dev.cellos.events.cell.audit.v1.reconciled";
1890
1891/// Closed reason discriminant for an admission decision (ADR-0024 §Verdict,
1892/// ADR-0025 §1).
1893///
1894/// The reason travels with the boolean `admitted` outcome in the
1895/// [`admission_decision_data_v1`] payload so an auditor reads *what was decided
1896/// and why* from the receipt alone, with no producer source access. The set is
1897/// closed: it is the exact discriminant set named in ADR-0024, shared by value
1898/// (the snake_case wire string) with the ADR-0024 ceiling verdict. No
1899/// environment string (`"prod"`) is ever a member — the predicate is an
1900/// allowlist comparison, never an environment label.
1901///
1902/// Serializes to a stable `snake_case` string (e.g. `authority_exceeds_ceiling`)
1903/// via serde; two independent producers emit byte-identical `reason` values for
1904/// the same verdict.
1905#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1906#[serde(rename_all = "snake_case")]
1907pub enum AdmissionDecisionReason {
1908    /// Spec authority is not a subset of the signed ceiling — reject.
1909    AuthorityExceedsCeiling,
1910    /// Ceiling manifest envelope failed signature/threshold verification.
1911    CeilingManifestUnverified,
1912    /// Ceiling manifest inner payload could not be parsed.
1913    CeilingManifestUnparseable,
1914    /// Ceiling envelope carried a non-ceiling `payload_type`.
1915    CeilingManifestWrongPayloadType,
1916    /// A populated dimension (filesystem/network/dns/cdn) made the subset
1917    /// comparison undecidable under the hardened profile.
1918    CeilingDimensionUncomparable,
1919    /// Ceiling epoch is older than the on-disk freshness anchor (replay).
1920    CeilingEpochStale,
1921    /// Ceiling not configured and the profile is not hardened — the single
1922    /// labeled, degraded fail-open path.
1923    CeilingDegradedUnconfigured,
1924    /// A formation edge's child authority is not a subset of its parent.
1925    FormationEdgeNotNarrowing,
1926    /// Spec authority is a subset of the signed ceiling — admit.
1927    AdmittedWithinCeiling,
1928}
1929
1930/// Whether an *allow* decision corresponds to a kernel-bound egress allowlist
1931/// or a declared/audited one (ADR-0025 §Linux-vs-Windows boundary).
1932///
1933/// Recorded on the receipt so a verifier on either OS can tell what kind of
1934/// enforcement backs the admission. The signature is platform-independent; this
1935/// field makes the receipt's truthfulness about enforcement platform-aware.
1936///
1937/// Serializes to `linux-nftables` / `declared-audit`.
1938#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1939#[serde(rename_all = "kebab-case")]
1940pub enum EnforcementBinding {
1941    /// Egress allowlist is bound in-kernel via nftables (Linux).
1942    LinuxNftables,
1943    /// Egress allowlist is declared and audited, not kernel-bound
1944    /// (Windows / portable backends).
1945    DeclaredAudit,
1946}
1947
1948/// `data` for the admission-decision events
1949/// `dev.cellos.events.cell.admission.v1.{allowed,rejected}` (ADR-0025 §1).
1950///
1951/// Sibling of [`policy_rejected_data_v1`]: the org-root ceiling gate
1952/// (ADR-0024) emits one of these on **every** admission decision — allow and
1953/// reject alike — so the verdict is a signed, offline-verifiable record rather
1954/// than an inference from `lifecycle.started`.
1955///
1956/// The payload is legible without producer-source access: `admitted` is the
1957/// verdict as a plain boolean ([`AuthorityCapability::is_superset_of`] result),
1958/// `reason` is a closed discriminant ([`AdmissionDecisionReason`]), and
1959/// `predicate` is the human-readable statement of what was checked. `specHash`
1960/// (`raw_spec_hash`), `runId`, and `enforcementBinding` bind the decision to a
1961/// concrete spec, run, and enforcement posture. `context` carries
1962/// non-load-bearing routing labels and is omitted when empty.
1963///
1964/// Pure JSON constructor (no I/O). The signing and emission live in the
1965/// supervisor's standalone main.rs signer (ADR-0025 §2). Determinism: the field
1966/// order below is stable, so `serde_json::to_vec` of two builds of the same
1967/// verdict is byte-identical (the canonical signing payload, ADR-0025 §4).
1968#[allow(clippy::too_many_arguments)]
1969pub fn admission_decision_data_v1(
1970    spec: &ExecutionCellSpec,
1971    admitted: bool,
1972    reason: AdmissionDecisionReason,
1973    predicate: &str,
1974    spec_hash: &str,
1975    run_id: Option<&str>,
1976    enforcement_binding: EnforcementBinding,
1977    context: &[(&str, &str)],
1978) -> Result<Value, serde_json::Error> {
1979    let mut m = Map::new();
1980    m.insert("specId".to_string(), json!(&spec.id));
1981    m.insert("admitted".to_string(), json!(admitted));
1982    m.insert("reason".to_string(), serde_json::to_value(reason)?);
1983    m.insert("predicate".to_string(), json!(predicate));
1984    m.insert("specHash".to_string(), json!(spec_hash));
1985    m.insert(
1986        "enforcementBinding".to_string(),
1987        serde_json::to_value(enforcement_binding)?,
1988    );
1989
1990    // S09 (ADR-0027): stamp the active crypto provider identity so an offline
1991    // auditor sees WHICH module signed this decision (e.g. dalek vs aws-lc-fips).
1992    // `fipsMode` / `moduleCert` are a PRODUCER CLAIM, not a validated attestation
1993    // — SC-13 is met only with an active CMVP cert, never asserted here.
1994    let pid = crate::crypto::provider_identity();
1995    m.insert(
1996        "providerIdentity".to_string(),
1997        json!({
1998            "name": pid.name,
1999            "fipsMode": pid.fips_mode,
2000            "moduleCert": pid.module_cert,
2001        }),
2002    );
2003
2004    if let Some(r) = run_id {
2005        m.insert("runId".to_string(), json!(r));
2006    }
2007
2008    // Non-load-bearing routing labels. Omitted when empty so a caller passing
2009    // no context produces a payload that round-trips without a `context` key.
2010    if !context.is_empty() {
2011        let mut ctx = Map::new();
2012        for (k, v) in context {
2013            ctx.insert((*k).to_string(), json!(v));
2014        }
2015        m.insert("context".to_string(), Value::Object(ctx));
2016    }
2017
2018    if let Some(c) = &spec.correlation {
2019        m.insert("correlation".to_string(), serde_json::to_value(c)?);
2020    }
2021    Ok(Value::Object(m))
2022}
2023
2024/// `data` for [`AUDIT_RECONCILED_TYPE`] (S18, ADR-0029): a signed receipt that a
2025/// contiguous range `[fromSeq, toSeq]` of the BLAKE3-chained spool was delivered
2026/// to the broker. Sibling of [`admission_decision_data_v1`]; emitted via the
2027/// standalone signer after a successful forward (S17), using the S01 canonical
2028/// encoder so it is offline-verifiable under the org-root key.
2029/// Schema: `contracts/schemas/audit-reconciled-v1.schema.json`.
2030#[allow(clippy::too_many_arguments)]
2031pub fn audit_reconciled_data_v1(
2032    chain_id: &str,
2033    from_seq: u64,
2034    to_seq: u64,
2035    head_row_hash: &str,
2036    delivered_count: u64,
2037    broker_acked_head: Option<&str>,
2038    integrity: &str,
2039    signed: bool,
2040) -> Result<Value, serde_json::Error> {
2041    let mut m = Map::new();
2042    m.insert("chainId".to_string(), json!(chain_id));
2043    m.insert("fromSeq".to_string(), json!(from_seq));
2044    m.insert("toSeq".to_string(), json!(to_seq));
2045    m.insert("headRowHash".to_string(), json!(head_row_hash));
2046    m.insert("deliveredCount".to_string(), json!(delivered_count));
2047    m.insert("integrity".to_string(), json!(integrity));
2048    m.insert("signed".to_string(), json!(signed));
2049    if let Some(h) = broker_acked_head {
2050        m.insert("brokerAckedHead".to_string(), json!(h));
2051    }
2052    Ok(Value::Object(m))
2053}
2054
2055/// T12 RBAC — `data` for event type `dev.cellos.events.cell.authz.v1.rejected`.
2056///
2057/// Emitted by the supervisor when the operator's `AuthorizationPolicy`
2058/// (loaded from `CELLOS_AUTHZ_POLICY_PATH`) rejects a cell spec at
2059/// admission. Sibling event to `cell.policy.v1.rejected`, with a different
2060/// rule namespace (authz subject/pool/pack vs policy-pack admission). Both
2061/// gates can fire independently.
2062///
2063/// `reason` is one of the strings declared in the schema:
2064/// `subject_not_authorized`, `pool_not_allowed`, `policy_pack_not_allowed`,
2065/// `rate_limit_exceeded`. Schema:
2066/// `contracts/schemas/cell-authz-rejected-v1.schema.json`.
2067pub fn authz_rejected_data_v1(
2068    spec: &ExecutionCellSpec,
2069    reason: &str,
2070    message: &str,
2071    denied_pool_id: Option<&str>,
2072    denied_policy_pack_id: Option<&str>,
2073) -> Result<Value, serde_json::Error> {
2074    let mut m = Map::new();
2075    m.insert("specId".to_string(), json!(&spec.id));
2076    m.insert("reason".to_string(), json!(reason));
2077    m.insert("message".to_string(), json!(message));
2078
2079    let tenant_id = spec
2080        .correlation
2081        .as_ref()
2082        .and_then(|c| c.tenant_id.as_deref());
2083    if let Some(t) = tenant_id {
2084        m.insert("tenantId".to_string(), json!(t));
2085        // 1.0 subject axis: subject == tenantId. Mirrored as a separate field
2086        // so consumers don't need to reach into correlation, and so future
2087        // subject axes (oidc, k8s) can replace it without breaking the
2088        // event shape.
2089        m.insert("subject".to_string(), json!(t));
2090    }
2091
2092    if let Some(p) = denied_pool_id {
2093        m.insert("deniedPoolId".to_string(), json!(p));
2094    }
2095    if let Some(p) = denied_policy_pack_id {
2096        m.insert("deniedPolicyPackId".to_string(), json!(p));
2097    }
2098
2099    if let Some(c) = &spec.correlation {
2100        m.insert("correlation".to_string(), serde_json::to_value(c)?);
2101    }
2102    Ok(Value::Object(m))
2103}
2104
2105/// `dev.cellos.events.cell.authority.v1.homeostasis`
2106///
2107/// Emitted once per run between `compliance.summary` and `lifecycle.destroyed`.
2108/// Compares declared vs exercised authority. Aggregation across runs belongs in taudit.
2109///
2110/// Schema: `contracts/schemas/cell-authority-homeostasis-v1.schema.json`.
2111pub fn homeostasis_signal_data_v1(
2112    spec: &ExecutionCellSpec,
2113    cell_id: &str,
2114    run_id: Option<&str>,
2115    signal: &crate::HomeostasisSignal,
2116) -> Result<Value, serde_json::Error> {
2117    let mut m = Map::new();
2118    m.insert("cellId".to_string(), json!(cell_id));
2119    m.insert("specId".to_string(), json!(&spec.id));
2120    m.insert("specHash".to_string(), json!(&signal.spec_hash));
2121    m.insert(
2122        "declaredEgressRules".to_string(),
2123        json!(signal.declared_egress_rules),
2124    );
2125    // E2-06: exercisedEgressConnections is nullable while real telemetry (L2-04 / L5-15)
2126    // is wired. When the count is None, emit JSON `null` AND a sibling
2127    // `exercisedEgressReason` so consumers can distinguish "telemetry pending" from a
2128    // true zero.
2129    m.insert(
2130        "exercisedEgressConnections".to_string(),
2131        match signal.exercised_egress_connections {
2132            Some(n) => json!(n),
2133            None => Value::Null,
2134        },
2135    );
2136    if let Some(reason) = &signal.exercised_egress_reason {
2137        m.insert("exercisedEgressReason".to_string(), json!(reason));
2138    }
2139    m.insert(
2140        "declaredMountPaths".to_string(),
2141        json!(signal.declared_mount_paths),
2142    );
2143    m.insert(
2144        "accessedMountPaths".to_string(),
2145        json!(signal.accessed_mount_paths),
2146    );
2147    m.insert(
2148        "declaredSecretCount".to_string(),
2149        json!(signal.declared_secret_count),
2150    );
2151    m.insert(
2152        "authorityEfficiency".to_string(),
2153        json!(signal.authority_efficiency),
2154    );
2155    m.insert(
2156        "recommendedRemovals".to_string(),
2157        serde_json::to_value(&signal.recommended_removals)?,
2158    );
2159    if let Some(r) = run_id {
2160        m.insert("runId".to_string(), json!(r));
2161    }
2162    if let Some(c) = &spec.correlation {
2163        m.insert("correlation".to_string(), serde_json::to_value(c)?);
2164    }
2165    Ok(Value::Object(m))
2166}
2167
2168/// `dev.cellos.events.cell.authority.v1.homeostasis_violation`
2169///
2170/// Emitted when exercised egress connections exceed declared authority — a
2171/// security invariant violation. The workload made connections beyond what
2172/// it declared it would need, which means either (a) the spec under-declares
2173/// the workload's real needs, or (b) the nftables/eBPF enforcement layer did
2174/// not prevent connections that policy rejected.
2175///
2176/// Pure JSON constructor (no I/O). Callers compute `overage` outside this
2177/// helper and pass `exercised`; this builder MUST NOT underflow, so the
2178/// caller is responsible for guaranteeing `exercised >= declared` before
2179/// invocation. The supervisor emit site guards on `exercised > declared`.
2180///
2181/// Schema: `contracts/schemas/cell-authority-homeostasis-violation-v1.schema.json`.
2182pub fn homeostasis_violation_data_v1(
2183    cell_id: &str,
2184    declared_egress: u64,
2185    exercised_egress: u64,
2186    spec_hash: &str,
2187) -> Value {
2188    let mut m = Map::new();
2189    m.insert("cellId".to_string(), json!(cell_id));
2190    m.insert(
2191        "declaredEgressRuleCount".to_string(),
2192        json!(declared_egress),
2193    );
2194    m.insert(
2195        "exercisedEgressConnections".to_string(),
2196        json!(exercised_egress),
2197    );
2198    // Caller guarantees exercised >= declared; saturating_sub protects against
2199    // accidental misuse without panicking.
2200    m.insert(
2201        "overage".to_string(),
2202        json!(exercised_egress.saturating_sub(declared_egress)),
2203    );
2204    m.insert("specHash".to_string(), json!(spec_hash));
2205    m.insert("severity".to_string(), json!("critical"));
2206    Value::Object(m)
2207}
2208
2209// ---------------------------------------------------------------------------
2210// F1b — Path B host-side observability primitives + evidence_bundle (ADR-0006)
2211//
2212// Builders below construct the JSON `data` payload for the new
2213// `dev.cellos.events.cell.observability.host.v1.*` event types and the
2214// per-cell `dev.cellos.events.cell.evidence_bundle.v1.emitted` aggregation
2215// primitive ("the 1.0 deliverable" — ADR-0006 §4). These are pure JSON
2216// constructors; sampling and emission live in `cellos-host-telemetry` and
2217// `cellos-supervisor` (slot F1a).
2218//
2219// All five host-probe builders carry a `spec_signature_hash` that the
2220// supervisor host-stamps before emit (ADR-0006 §6). The evidence_bundle
2221// builder requires both `spec_signature_hash` and `cell_destroyed_event_ref`
2222// — the latter enforces D5 destruction-evidence integration: a bundle
2223// without a `cell.lifecycle.v1.destroyed` reference is structurally invalid
2224// (see schema gate in `contracts/schemas/evidence-bundle-v1.schema.json`).
2225// ---------------------------------------------------------------------------
2226
2227/// `dev.cellos.events.cell.observability.host.v1.fc_metrics`
2228///
2229/// Per-cell snapshot of Firecracker metrics endpoint counters (KVM exits,
2230/// vsock bytes, block ops). Path B (ADR-0006).
2231///
2232/// Schema: `contracts/schemas/cell-observability-host-fc-metrics-v1.schema.json`.
2233#[allow(clippy::too_many_arguments)]
2234pub fn observability_host_fc_metrics_data_v1(
2235    spec: &ExecutionCellSpec,
2236    cell_id: &str,
2237    run_id: Option<&str>,
2238    spec_signature_hash: &str,
2239    sampled_at_unix_ms: u64,
2240    fc_socket_path: &str,
2241    vcpu_exits_total: Option<u64>,
2242    vsock_tx_bytes: Option<u64>,
2243    vsock_rx_bytes: Option<u64>,
2244    block_read_ops: Option<u64>,
2245    block_write_ops: Option<u64>,
2246    sample_error: Option<&str>,
2247) -> Result<Value, serde_json::Error> {
2248    let mut m = Map::new();
2249    m.insert("cellId".to_string(), json!(cell_id));
2250    m.insert("specId".to_string(), json!(&spec.id));
2251    m.insert("specSignatureHash".to_string(), json!(spec_signature_hash));
2252    m.insert("sampledAtUnixMs".to_string(), json!(sampled_at_unix_ms));
2253    m.insert("fcSocketPath".to_string(), json!(fc_socket_path));
2254    if let Some(v) = vcpu_exits_total {
2255        m.insert("vcpuExitsTotal".to_string(), json!(v));
2256    }
2257    if let Some(v) = vsock_tx_bytes {
2258        m.insert("vsockTxBytes".to_string(), json!(v));
2259    }
2260    if let Some(v) = vsock_rx_bytes {
2261        m.insert("vsockRxBytes".to_string(), json!(v));
2262    }
2263    if let Some(v) = block_read_ops {
2264        m.insert("blockReadOps".to_string(), json!(v));
2265    }
2266    if let Some(v) = block_write_ops {
2267        m.insert("blockWriteOps".to_string(), json!(v));
2268    }
2269    if let Some(e) = sample_error {
2270        m.insert("sampleError".to_string(), json!(e));
2271    }
2272    if let Some(r) = run_id {
2273        m.insert("runId".to_string(), json!(r));
2274    }
2275    if let Some(c) = &spec.correlation {
2276        m.insert("correlation".to_string(), serde_json::to_value(c)?);
2277    }
2278    Ok(Value::Object(m))
2279}
2280
2281/// One memory.events / cpu.stat / pids.events sample for
2282/// [`observability_host_cgroup_data_v1`].
2283#[derive(Debug, Default, Clone)]
2284pub struct CgroupSample<'a> {
2285    pub memory_events: Option<&'a [(&'a str, u64)]>,
2286    pub cpu_stat: Option<&'a [(&'a str, u64)]>,
2287    pub pids_events: Option<&'a [(&'a str, u64)]>,
2288}
2289
2290/// `dev.cellos.events.cell.observability.host.v1.cgroup`
2291///
2292/// Per-cell cgroup v2 counter snapshot (memory.events, cpu.stat,
2293/// pids.events). Path B (ADR-0006).
2294///
2295/// `sample` carries the kernel-reported counter pairs the supervisor read
2296/// from `/sys/fs/cgroup/<cell-leaf>/`. Field names map directly to the
2297/// schema's nested objects; unknown keys are silently dropped at emit time
2298/// because the schema's `additionalProperties: false` would reject them.
2299///
2300/// Schema: `contracts/schemas/cell-observability-host-cgroup-v1.schema.json`.
2301fn cgroup_section(keys: &[&str], pairs: Option<&[(&str, u64)]>) -> Option<Value> {
2302    let pairs = pairs?;
2303    let mut section = Map::new();
2304    for (k, v) in pairs {
2305        if keys.contains(k) {
2306            section.insert((*k).to_string(), json!(v));
2307        }
2308    }
2309    if section.is_empty() {
2310        None
2311    } else {
2312        Some(Value::Object(section))
2313    }
2314}
2315
2316/// `dev.cellos.events.cell.observability.host.v1.cgroup` (see [`CgroupSample`]).
2317#[allow(clippy::too_many_arguments)]
2318pub fn observability_host_cgroup_data_v1(
2319    spec: &ExecutionCellSpec,
2320    cell_id: &str,
2321    run_id: Option<&str>,
2322    spec_signature_hash: &str,
2323    sampled_at_unix_ms: u64,
2324    cgroup_path: &str,
2325    sample: &CgroupSample<'_>,
2326    sample_error: Option<&str>,
2327) -> Result<Value, serde_json::Error> {
2328    const MEM_KEYS: &[&str] = &["low", "high", "max", "oom", "oomKill"];
2329    const CPU_KEYS: &[&str] = &[
2330        "usageUsec",
2331        "userUsec",
2332        "systemUsec",
2333        "nrPeriods",
2334        "nrThrottled",
2335        "throttledUsec",
2336    ];
2337    const PIDS_KEYS: &[&str] = &["max"];
2338
2339    let mut m = Map::new();
2340    m.insert("cellId".to_string(), json!(cell_id));
2341    m.insert("specId".to_string(), json!(&spec.id));
2342    m.insert("specSignatureHash".to_string(), json!(spec_signature_hash));
2343    m.insert("sampledAtUnixMs".to_string(), json!(sampled_at_unix_ms));
2344    m.insert("cgroupPath".to_string(), json!(cgroup_path));
2345    if let Some(v) = cgroup_section(MEM_KEYS, sample.memory_events) {
2346        m.insert("memoryEvents".to_string(), v);
2347    }
2348    if let Some(v) = cgroup_section(CPU_KEYS, sample.cpu_stat) {
2349        m.insert("cpuStat".to_string(), v);
2350    }
2351    if let Some(v) = cgroup_section(PIDS_KEYS, sample.pids_events) {
2352        m.insert("pidsEvents".to_string(), v);
2353    }
2354    if let Some(e) = sample_error {
2355        m.insert("sampleError".to_string(), json!(e));
2356    }
2357    if let Some(r) = run_id {
2358        m.insert("runId".to_string(), json!(r));
2359    }
2360    if let Some(c) = &spec.correlation {
2361        m.insert("correlation".to_string(), serde_json::to_value(c)?);
2362    }
2363    Ok(Value::Object(m))
2364}
2365
2366/// One nft rule counter row for [`observability_host_nftables_data_v1`].
2367#[derive(Debug, Clone)]
2368pub struct NftRuleCounter<'a> {
2369    pub rule_handle: &'a str,
2370    pub verdict: Option<&'a str>,
2371    pub packets: u64,
2372    pub bytes: u64,
2373    pub r#match: Option<&'a str>,
2374}
2375
2376/// `dev.cellos.events.cell.observability.host.v1.nftables`
2377///
2378/// Per-cell nftables counter snapshot scraped from the cell's netns. Path B
2379/// (ADR-0006).
2380///
2381/// Schema: `contracts/schemas/cell-observability-host-nftables-v1.schema.json`.
2382#[allow(clippy::too_many_arguments)]
2383pub fn observability_host_nftables_data_v1(
2384    spec: &ExecutionCellSpec,
2385    cell_id: &str,
2386    run_id: Option<&str>,
2387    spec_signature_hash: &str,
2388    sampled_at_unix_ms: u64,
2389    table_name: &str,
2390    rule_counters: &[NftRuleCounter<'_>],
2391    sample_error: Option<&str>,
2392) -> Result<Value, serde_json::Error> {
2393    let mut m = Map::new();
2394    m.insert("cellId".to_string(), json!(cell_id));
2395    m.insert("specId".to_string(), json!(&spec.id));
2396    m.insert("specSignatureHash".to_string(), json!(spec_signature_hash));
2397    m.insert("sampledAtUnixMs".to_string(), json!(sampled_at_unix_ms));
2398    m.insert("tableName".to_string(), json!(table_name));
2399    let counters: Vec<Value> = rule_counters
2400        .iter()
2401        .map(|c| {
2402            let mut row = Map::new();
2403            row.insert("ruleHandle".to_string(), json!(c.rule_handle));
2404            if let Some(v) = c.verdict {
2405                row.insert("verdict".to_string(), json!(v));
2406            }
2407            row.insert("packets".to_string(), json!(c.packets));
2408            row.insert("bytes".to_string(), json!(c.bytes));
2409            if let Some(mt) = c.r#match {
2410                row.insert("match".to_string(), json!(mt));
2411            }
2412            Value::Object(row)
2413        })
2414        .collect();
2415    m.insert("ruleCounters".to_string(), Value::Array(counters));
2416    if let Some(e) = sample_error {
2417        m.insert("sampleError".to_string(), json!(e));
2418    }
2419    if let Some(r) = run_id {
2420        m.insert("runId".to_string(), json!(r));
2421    }
2422    if let Some(c) = &spec.correlation {
2423        m.insert("correlation".to_string(), serde_json::to_value(c)?);
2424    }
2425    Ok(Value::Object(m))
2426}
2427
2428/// Per-TAP statistics row for [`observability_host_tap_data_v1`].
2429#[derive(Debug, Default, Clone, Copy)]
2430pub struct TapStats {
2431    pub rx_packets: Option<u64>,
2432    pub tx_packets: Option<u64>,
2433    pub rx_bytes: Option<u64>,
2434    pub tx_bytes: Option<u64>,
2435    pub rx_errors: Option<u64>,
2436    pub tx_errors: Option<u64>,
2437    pub rx_dropped: Option<u64>,
2438    pub tx_dropped: Option<u64>,
2439}
2440
2441/// `dev.cellos.events.cell.observability.host.v1.tap`
2442///
2443/// Per-cell TAP-link snapshot for the FC microVM tap device. Path B
2444/// (ADR-0006).
2445///
2446/// Schema: `contracts/schemas/cell-observability-host-tap-v1.schema.json`.
2447#[allow(clippy::too_many_arguments)]
2448pub fn observability_host_tap_data_v1(
2449    spec: &ExecutionCellSpec,
2450    cell_id: &str,
2451    run_id: Option<&str>,
2452    spec_signature_hash: &str,
2453    sampled_at_unix_ms: u64,
2454    tap_name: &str,
2455    link_state: &str,
2456    stats: &TapStats,
2457    sample_error: Option<&str>,
2458) -> Result<Value, serde_json::Error> {
2459    let mut m = Map::new();
2460    m.insert("cellId".to_string(), json!(cell_id));
2461    m.insert("specId".to_string(), json!(&spec.id));
2462    m.insert("specSignatureHash".to_string(), json!(spec_signature_hash));
2463    m.insert("sampledAtUnixMs".to_string(), json!(sampled_at_unix_ms));
2464    m.insert("tapName".to_string(), json!(tap_name));
2465    m.insert("linkState".to_string(), json!(link_state));
2466    if let Some(v) = stats.rx_packets {
2467        m.insert("rxPackets".to_string(), json!(v));
2468    }
2469    if let Some(v) = stats.tx_packets {
2470        m.insert("txPackets".to_string(), json!(v));
2471    }
2472    if let Some(v) = stats.rx_bytes {
2473        m.insert("rxBytes".to_string(), json!(v));
2474    }
2475    if let Some(v) = stats.tx_bytes {
2476        m.insert("txBytes".to_string(), json!(v));
2477    }
2478    if let Some(v) = stats.rx_errors {
2479        m.insert("rxErrors".to_string(), json!(v));
2480    }
2481    if let Some(v) = stats.tx_errors {
2482        m.insert("txErrors".to_string(), json!(v));
2483    }
2484    if let Some(v) = stats.rx_dropped {
2485        m.insert("rxDropped".to_string(), json!(v));
2486    }
2487    if let Some(v) = stats.tx_dropped {
2488        m.insert("txDropped".to_string(), json!(v));
2489    }
2490    if let Some(e) = sample_error {
2491        m.insert("sampleError".to_string(), json!(e));
2492    }
2493    if let Some(r) = run_id {
2494        m.insert("runId".to_string(), json!(r));
2495    }
2496    if let Some(c) = &spec.correlation {
2497        m.insert("correlation".to_string(), serde_json::to_value(c)?);
2498    }
2499    Ok(Value::Object(m))
2500}
2501
2502/// Final residue class for the cell after teardown
2503/// (per `docs/destruction-semantics.md`).
2504#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
2505#[serde(rename_all = "snake_case")]
2506pub enum ResidueClass {
2507    /// No host- or broker-side residue tracked for the cell after destroy.
2508    None,
2509    /// A residue class with a named runbook entry; see
2510    /// [`EvidenceBundleRefs::residue_exception`] for the runbook id.
2511    DocumentedException,
2512}
2513
2514/// Coarser-grained residue class historically embedded directly in the
2515/// `lifecycle.v1.destroyed` payload. Retained for back-compat with consumers
2516/// that read the destruction event directly without joining the
2517/// `evidence_bundle.v1.emitted` payload. New code should prefer
2518/// [`ResidueClass`] on the bundle.
2519#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
2520#[serde(rename_all = "camelCase")]
2521pub enum LifecycleResidueClass {
2522    None,
2523    MetadataOnly,
2524    DocumentedException,
2525    Unknown,
2526}
2527
2528/// References the supervisor aggregates into an `evidence_bundle` payload.
2529///
2530/// All identifiers are CloudEvent envelope `id` strings (URN preferred).
2531/// Required fields enforce ADR-0006 §F1: every emitted bundle ties back to
2532/// a destruction event (D5) and a host-stamped spec hash (the channel's
2533/// authenticity primitive). Optional refs are populated only when the
2534/// supervisor actually emitted the corresponding event for this run —
2535/// builders never fabricate references.
2536#[derive(Debug, Default, Clone)]
2537pub struct EvidenceBundleRefs<'a> {
2538    /// Required: CloudEvent id of `cell.lifecycle.v1.started`.
2539    pub started_event_ref: &'a str,
2540    /// Required: CloudEvent id of `cell.lifecycle.v1.destroyed` (D5).
2541    pub cell_destroyed_event_ref: &'a str,
2542    pub command_completed_event_ref: Option<&'a str>,
2543    pub spawned_event_refs: &'a [&'a str],
2544    pub fc_metrics_event_refs: &'a [&'a str],
2545    pub cgroup_event_refs: &'a [&'a str],
2546    pub nftables_event_refs: &'a [&'a str],
2547    pub tap_event_refs: &'a [&'a str],
2548    pub homeostasis_event_ref: Option<&'a str>,
2549    pub compliance_summary_event_ref: Option<&'a str>,
2550    /// One row per declared `cell.observability.guest.*` event:
2551    /// `(eventId, eventType, ruleClass)`.
2552    pub guest_event_refs: &'a [(&'a str, &'a str, &'a str)],
2553    /// When `residue_class == DocumentedException`, names the runbook entry.
2554    pub residue_exception: Option<&'a str>,
2555}
2556
2557/// `dev.cellos.events.cell.evidence_bundle.v1.emitted`
2558///
2559/// The 1.0 per-cell aggregation primitive (ADR-0006 §4) — what the auditor
2560/// reads cold and what the CI customer pastes into incident review.
2561/// Carries the spec hash, lifecycle stream refs, Path B host-side resource
2562/// series refs, declared guest event refs (with ADG rule-class), and the
2563/// destruction receipt naming the residue class.
2564///
2565/// **D5 gate:** `cell_destroyed_event_ref` is required by the schema and by
2566/// this builder's signature. A bundle without a destruction event reference
2567/// is rejected by the schema validator and by `tests/residue.rs`
2568/// (per ADR-0006 §Compliance "destruction gate").
2569///
2570/// Schema: `contracts/schemas/evidence-bundle-v1.schema.json`.
2571pub fn evidence_bundle_emitted_data_v1(
2572    spec: &ExecutionCellSpec,
2573    cell_id: &str,
2574    run_id: Option<&str>,
2575    spec_signature_hash: &str,
2576    emitted_at_unix_ms: u64,
2577    residue_class: ResidueClass,
2578    refs: &EvidenceBundleRefs<'_>,
2579) -> Result<Value, serde_json::Error> {
2580    let mut m = Map::new();
2581    m.insert("cellId".to_string(), json!(cell_id));
2582    m.insert("specId".to_string(), json!(&spec.id));
2583    m.insert("specSignatureHash".to_string(), json!(spec_signature_hash));
2584    m.insert("emittedAtUnixMs".to_string(), json!(emitted_at_unix_ms));
2585
2586    let mut lifecycle = Map::new();
2587    lifecycle.insert("started".to_string(), json!(refs.started_event_ref));
2588    lifecycle.insert(
2589        "destroyed".to_string(),
2590        json!(refs.cell_destroyed_event_ref),
2591    );
2592    if let Some(cc) = refs.command_completed_event_ref {
2593        lifecycle.insert("commandCompleted".to_string(), json!(cc));
2594    }
2595    if !refs.spawned_event_refs.is_empty() {
2596        lifecycle.insert("spawned".to_string(), json!(refs.spawned_event_refs));
2597    }
2598    m.insert("lifecycleEventRefs".to_string(), Value::Object(lifecycle));
2599
2600    m.insert(
2601        "cellDestroyedEventRef".to_string(),
2602        json!(refs.cell_destroyed_event_ref),
2603    );
2604    m.insert(
2605        "residueClass".to_string(),
2606        serde_json::to_value(residue_class)?,
2607    );
2608    if let Some(rx) = refs.residue_exception {
2609        m.insert("residueException".to_string(), json!(rx));
2610    }
2611
2612    let any_host = !refs.fc_metrics_event_refs.is_empty()
2613        || !refs.cgroup_event_refs.is_empty()
2614        || !refs.nftables_event_refs.is_empty()
2615        || !refs.tap_event_refs.is_empty();
2616    if any_host {
2617        let mut host = Map::new();
2618        if !refs.fc_metrics_event_refs.is_empty() {
2619            host.insert("fcMetrics".to_string(), json!(refs.fc_metrics_event_refs));
2620        }
2621        if !refs.cgroup_event_refs.is_empty() {
2622            host.insert("cgroup".to_string(), json!(refs.cgroup_event_refs));
2623        }
2624        if !refs.nftables_event_refs.is_empty() {
2625            host.insert("nftables".to_string(), json!(refs.nftables_event_refs));
2626        }
2627        if !refs.tap_event_refs.is_empty() {
2628            host.insert("tap".to_string(), json!(refs.tap_event_refs));
2629        }
2630        m.insert("hostProbeEventRefs".to_string(), Value::Object(host));
2631    }
2632
2633    if !refs.guest_event_refs.is_empty() {
2634        let rows: Vec<Value> = refs
2635            .guest_event_refs
2636            .iter()
2637            .map(|(id, ty, rc)| {
2638                let mut row = Map::new();
2639                row.insert("eventId".to_string(), json!(id));
2640                row.insert("eventType".to_string(), json!(ty));
2641                row.insert("ruleClass".to_string(), json!(rc));
2642                Value::Object(row)
2643            })
2644            .collect();
2645        m.insert("guestEventRefs".to_string(), Value::Array(rows));
2646    }
2647
2648    if let Some(h) = refs.homeostasis_event_ref {
2649        m.insert("homeostasisEventRef".to_string(), json!(h));
2650    }
2651    if let Some(c) = refs.compliance_summary_event_ref {
2652        m.insert("complianceSummaryEventRef".to_string(), json!(c));
2653    }
2654    if let Some(r) = run_id {
2655        m.insert("runId".to_string(), json!(r));
2656    }
2657    if let Some(c) = &spec.correlation {
2658        m.insert("correlation".to_string(), serde_json::to_value(c)?);
2659    }
2660    Ok(Value::Object(m))
2661}
2662
2663/// `data` for event type `dev.cellos.events.cell.cortex.v1.dispatched`.
2664///
2665/// Emitted by `CellosLedgerEmitter` when it processes a Cortex
2666/// ContextPack dispatch onto a cell. Records the pack id, the cell id,
2667/// the doctrine refs cited by the pack (subject to ADR-0009 policy), and
2668/// the Cortex↔CellOS bridge version for downstream auditors.
2669///
2670/// Schema: `contracts/schemas/cell-cortex-dispatched-v1.schema.json`.
2671pub fn cortex_dispatched_data_v1(pack_id: &str, cell_id: &str, doctrine_refs: &[String]) -> Value {
2672    let mut m = Map::new();
2673    m.insert("packId".to_string(), json!(pack_id));
2674    m.insert("cellId".to_string(), json!(cell_id));
2675    m.insert("doctrineRefs".to_string(), json!(doctrine_refs));
2676    m.insert("bridgeVersion".to_string(), json!("1.0"));
2677    Value::Object(m)
2678}
2679
2680/// `data` for event type `dev.cellos.events.cell.firecracker.v1.pool_checkout`.
2681///
2682/// Emitted by `FirecrackerCellBackend` when it satisfies a cell start by
2683/// either consuming a slot from the warm pool (`poolHit = true`) or
2684/// falling through to a cold boot (`poolHit = false`). `slotCount` is the
2685/// number of warm slots available *before* this checkout — useful for
2686/// detecting pool-exhaustion patterns in audit.
2687///
2688/// Schema: `contracts/schemas/cell-firecracker-pool-checkout-v1.schema.json`.
2689pub fn firecracker_pool_event_data_v1(cell_id: &str, pool_hit: bool, slot_count: usize) -> Value {
2690    let mut m = Map::new();
2691    m.insert("cellId".to_string(), json!(cell_id));
2692    m.insert("poolHit".to_string(), json!(pool_hit));
2693    m.insert("slotCount".to_string(), json!(slot_count));
2694    Value::Object(m)
2695}
2696
2697/// CloudEvent envelope constructor for `dev.cellos.events.cell.cortex.v1.dispatched`.
2698///
2699/// Pairs with [`cortex_dispatched_data_v1`]. `source` is typically the Cortex
2700/// bridge identifier (e.g. `"cellos-cortex"`); `time` is RFC 3339.
2701///
2702/// Schema: `contracts/schemas/cell-cortex-dispatched-v1.schema.json`.
2703pub fn cloud_event_v1_cortex_dispatched(
2704    source: &str,
2705    time: &str,
2706    pack_id: &str,
2707    cell_id: &str,
2708    doctrine_refs: &[String],
2709) -> CloudEventV1 {
2710    CloudEventV1 {
2711        specversion: "1.0".into(),
2712        id: uuid::Uuid::new_v4().to_string(),
2713        source: source.to_string(),
2714        ty: "dev.cellos.events.cell.cortex.v1.dispatched".into(),
2715        datacontenttype: Some("application/json".into()),
2716        data: Some(cortex_dispatched_data_v1(pack_id, cell_id, doctrine_refs)),
2717        time: Some(time.to_string()),
2718        traceparent: None,
2719        cex: None,
2720    }
2721}
2722
2723/// CloudEvent envelope constructor for `dev.cellos.events.cell.firecracker.v1.pool_checkout`.
2724///
2725/// Pairs with [`firecracker_pool_event_data_v1`]. `source` is typically
2726/// `"cellos-host-firecracker"`; `time` is RFC 3339. `slot_count` is the
2727/// pool's `Available` count observed *before* the checkout attempt — see
2728/// the data builder for audit semantics.
2729///
2730/// Schema: `contracts/schemas/cell-firecracker-pool-checkout-v1.schema.json`.
2731pub fn cloud_event_v1_firecracker_pool_checkout(
2732    source: &str,
2733    time: &str,
2734    cell_id: &str,
2735    pool_hit: bool,
2736    slot_count: usize,
2737) -> CloudEventV1 {
2738    CloudEventV1 {
2739        specversion: "1.0".into(),
2740        id: uuid::Uuid::new_v4().to_string(),
2741        source: source.to_string(),
2742        ty: "dev.cellos.events.cell.firecracker.v1.pool_checkout".into(),
2743        datacontenttype: Some("application/json".into()),
2744        data: Some(firecracker_pool_event_data_v1(
2745            cell_id, pool_hit, slot_count,
2746        )),
2747        time: Some(time.to_string()),
2748        traceparent: None,
2749        cex: None,
2750    }
2751}
2752
2753// ─────────────────────────────────────────────────────────────────────────────
2754// Formation-level lifecycle events (Session 15+16 — Formation model).
2755//
2756// The Formation state machine governs a group of related cells managed as a
2757// single unit. Its transitions are:
2758//
2759//   PENDING → LAUNCHING → RUNNING → DEGRADED → COMPLETED / FAILED
2760//
2761// Each transition emits a CloudEvent so the formation lifecycle is auditable
2762// the same way cell lifecycle is auditable. The URN namespace is
2763// `dev.cellos.events.cell.formation.v1.<phase>`, sharing the `cell.` prefix
2764// so the FC-74 audit consumer's coverage rules apply uniformly. The shared
2765// data shape is described by [`FormationEventData`] below.
2766// ─────────────────────────────────────────────────────────────────────────────
2767
2768/// CloudEvent `type` URN for [`cloud_event_v1_formation_created`].
2769pub const FORMATION_CREATED_TYPE: &str = "dev.cellos.events.cell.formation.v1.created";
2770
2771/// CloudEvent `type` URN for [`cloud_event_v1_formation_launching`].
2772pub const FORMATION_LAUNCHING_TYPE: &str = "dev.cellos.events.cell.formation.v1.launching";
2773
2774/// CloudEvent `type` URN for [`cloud_event_v1_formation_running`].
2775pub const FORMATION_RUNNING_TYPE: &str = "dev.cellos.events.cell.formation.v1.running";
2776
2777/// CloudEvent `type` URN for [`cloud_event_v1_formation_degraded`].
2778pub const FORMATION_DEGRADED_TYPE: &str = "dev.cellos.events.cell.formation.v1.degraded";
2779
2780/// CloudEvent `type` URN for [`cloud_event_v1_formation_completed`].
2781pub const FORMATION_COMPLETED_TYPE: &str = "dev.cellos.events.cell.formation.v1.completed";
2782
2783/// CloudEvent `type` URN for [`cloud_event_v1_formation_failed`].
2784pub const FORMATION_FAILED_TYPE: &str = "dev.cellos.events.cell.formation.v1.failed";
2785
2786/// Build the canonical `data` payload for every
2787/// `dev.cellos.events.cell.formation.v1.*` CloudEvent.
2788///
2789/// All formation lifecycle events share a single shape so downstream auditors
2790/// (the FC-74 consumer, `cellos-audit-justification`, SIEM ingest) can join
2791/// on `formationId` across phases without dispatching per-URN parsers.
2792///
2793/// Fields:
2794/// - `formationId` — stable identifier of the Formation document.
2795/// - `formationName` — operator-facing label (mirrors `metadata.name` on the
2796///   FormationDocument).
2797/// - `cellCount` — total number of cells in the formation at the time of
2798///   transition. Always present so audit can detect mid-run resize.
2799/// - `failedCellIds` — concrete cell IDs that contributed to the transition
2800///   (populated for `degraded` / `failed`, empty for the happy path).
2801/// - `reason` — short, free-form explanation for `degraded` / `failed`
2802///   transitions. Omitted on the happy path.
2803fn formation_data_v1(
2804    formation_id: &str,
2805    formation_name: &str,
2806    cell_count: u32,
2807    failed_cell_ids: &[String],
2808    reason: Option<&str>,
2809) -> Value {
2810    let mut m = Map::new();
2811    m.insert("formationId".to_string(), json!(formation_id));
2812    m.insert("formationName".to_string(), json!(formation_name));
2813    m.insert("cellCount".to_string(), json!(cell_count));
2814    m.insert("failedCellIds".to_string(), json!(failed_cell_ids));
2815    if let Some(r) = reason {
2816        m.insert("reason".to_string(), json!(r));
2817    }
2818    Value::Object(m)
2819}
2820
2821/// CloudEvent envelope constructor for
2822/// `dev.cellos.events.cell.formation.v1.created` (PENDING phase entry).
2823///
2824/// Emitted by the supervisor immediately after a FormationDocument is
2825/// admitted but before any cell launch has begun. `failed_cell_ids` is
2826/// expected to be empty on this transition.
2827pub fn cloud_event_v1_formation_created(
2828    source: &str,
2829    time: &str,
2830    formation_id: &str,
2831    formation_name: &str,
2832    cell_count: u32,
2833    failed_cell_ids: &[String],
2834    reason: Option<&str>,
2835) -> CloudEventV1 {
2836    CloudEventV1 {
2837        specversion: "1.0".into(),
2838        id: uuid::Uuid::new_v4().to_string(),
2839        source: source.to_string(),
2840        ty: FORMATION_CREATED_TYPE.to_string(),
2841        datacontenttype: Some("application/json".into()),
2842        data: Some(formation_data_v1(
2843            formation_id,
2844            formation_name,
2845            cell_count,
2846            failed_cell_ids,
2847            reason,
2848        )),
2849        time: Some(time.to_string()),
2850        traceparent: None,
2851        cex: None,
2852    }
2853}
2854
2855/// CloudEvent envelope constructor for
2856/// `dev.cellos.events.cell.formation.v1.launching` (LAUNCHING phase entry).
2857///
2858/// Emitted once the supervisor has started attempting to bring up the
2859/// formation's cells. `failed_cell_ids` is expected to be empty on this
2860/// transition; transient launch errors that don't fail the whole formation
2861/// belong on the subsequent `degraded` or `failed` events.
2862pub fn cloud_event_v1_formation_launching(
2863    source: &str,
2864    time: &str,
2865    formation_id: &str,
2866    formation_name: &str,
2867    cell_count: u32,
2868    failed_cell_ids: &[String],
2869    reason: Option<&str>,
2870) -> CloudEventV1 {
2871    CloudEventV1 {
2872        specversion: "1.0".into(),
2873        id: uuid::Uuid::new_v4().to_string(),
2874        source: source.to_string(),
2875        ty: FORMATION_LAUNCHING_TYPE.to_string(),
2876        datacontenttype: Some("application/json".into()),
2877        data: Some(formation_data_v1(
2878            formation_id,
2879            formation_name,
2880            cell_count,
2881            failed_cell_ids,
2882            reason,
2883        )),
2884        time: Some(time.to_string()),
2885        traceparent: None,
2886        cex: None,
2887    }
2888}
2889
2890/// CloudEvent envelope constructor for
2891/// `dev.cellos.events.cell.formation.v1.running` (RUNNING phase entry).
2892///
2893/// Emitted once all of the formation's cells reach a healthy steady state.
2894/// `failed_cell_ids` is expected to be empty on this transition.
2895pub fn cloud_event_v1_formation_running(
2896    source: &str,
2897    time: &str,
2898    formation_id: &str,
2899    formation_name: &str,
2900    cell_count: u32,
2901    failed_cell_ids: &[String],
2902    reason: Option<&str>,
2903) -> CloudEventV1 {
2904    CloudEventV1 {
2905        specversion: "1.0".into(),
2906        id: uuid::Uuid::new_v4().to_string(),
2907        source: source.to_string(),
2908        ty: FORMATION_RUNNING_TYPE.to_string(),
2909        datacontenttype: Some("application/json".into()),
2910        data: Some(formation_data_v1(
2911            formation_id,
2912            formation_name,
2913            cell_count,
2914            failed_cell_ids,
2915            reason,
2916        )),
2917        time: Some(time.to_string()),
2918        traceparent: None,
2919        cex: None,
2920    }
2921}
2922
2923/// CloudEvent envelope constructor for
2924/// `dev.cellos.events.cell.formation.v1.degraded` (DEGRADED phase entry).
2925///
2926/// Emitted when at least one but not all of the formation's cells have
2927/// failed and the formation is continuing to run in a degraded state.
2928/// `failed_cell_ids` MUST list the affected cells; `reason` SHOULD be set.
2929pub fn cloud_event_v1_formation_degraded(
2930    source: &str,
2931    time: &str,
2932    formation_id: &str,
2933    formation_name: &str,
2934    cell_count: u32,
2935    failed_cell_ids: &[String],
2936    reason: Option<&str>,
2937) -> CloudEventV1 {
2938    CloudEventV1 {
2939        specversion: "1.0".into(),
2940        id: uuid::Uuid::new_v4().to_string(),
2941        source: source.to_string(),
2942        ty: FORMATION_DEGRADED_TYPE.to_string(),
2943        datacontenttype: Some("application/json".into()),
2944        data: Some(formation_data_v1(
2945            formation_id,
2946            formation_name,
2947            cell_count,
2948            failed_cell_ids,
2949            reason,
2950        )),
2951        time: Some(time.to_string()),
2952        traceparent: None,
2953        cex: None,
2954    }
2955}
2956
2957/// CloudEvent envelope constructor for
2958/// `dev.cellos.events.cell.formation.v1.completed` (COMPLETED terminal).
2959///
2960/// Emitted when the formation has finished its work successfully and all
2961/// cells have been torn down cleanly. `failed_cell_ids` is expected to be
2962/// empty; `reason` MAY carry an operator-facing completion note.
2963pub fn cloud_event_v1_formation_completed(
2964    source: &str,
2965    time: &str,
2966    formation_id: &str,
2967    formation_name: &str,
2968    cell_count: u32,
2969    failed_cell_ids: &[String],
2970    reason: Option<&str>,
2971) -> CloudEventV1 {
2972    CloudEventV1 {
2973        specversion: "1.0".into(),
2974        id: uuid::Uuid::new_v4().to_string(),
2975        source: source.to_string(),
2976        ty: FORMATION_COMPLETED_TYPE.to_string(),
2977        datacontenttype: Some("application/json".into()),
2978        data: Some(formation_data_v1(
2979            formation_id,
2980            formation_name,
2981            cell_count,
2982            failed_cell_ids,
2983            reason,
2984        )),
2985        time: Some(time.to_string()),
2986        traceparent: None,
2987        cex: None,
2988    }
2989}
2990
2991/// CloudEvent envelope constructor for
2992/// `dev.cellos.events.cell.formation.v1.failed` (FAILED terminal).
2993///
2994/// Emitted when the formation has terminated unsuccessfully. `failed_cell_ids`
2995/// MUST list the cells that drove the failure (callers MAY include the full
2996/// cell set when the failure is formation-wide); `reason` SHOULD be set.
2997pub fn cloud_event_v1_formation_failed(
2998    source: &str,
2999    time: &str,
3000    formation_id: &str,
3001    formation_name: &str,
3002    cell_count: u32,
3003    failed_cell_ids: &[String],
3004    reason: Option<&str>,
3005) -> CloudEventV1 {
3006    CloudEventV1 {
3007        specversion: "1.0".into(),
3008        id: uuid::Uuid::new_v4().to_string(),
3009        source: source.to_string(),
3010        ty: FORMATION_FAILED_TYPE.to_string(),
3011        datacontenttype: Some("application/json".into()),
3012        data: Some(formation_data_v1(
3013            formation_id,
3014            formation_name,
3015            cell_count,
3016            failed_cell_ids,
3017            reason,
3018        )),
3019        time: Some(time.to_string()),
3020        traceparent: None,
3021        cex: None,
3022    }
3023}
3024
3025#[cfg(test)]
3026mod tests {
3027    use super::*;
3028    use crate::{
3029        Correlation, ExecutionCellDocument, ExportReceipt, ExportReceiptTargetKind, Lifetime,
3030        WorkloadIdentity, WorkloadIdentityKind,
3031    };
3032
3033    #[test]
3034    fn admission_receipt_stamps_provider_identity() {
3035        // S09: every admission decision records the active crypto provider so an
3036        // auditor sees which module signed it. fipsMode is a producer claim.
3037        let spec = crate::ExecutionCellSpec::default();
3038        let data = admission_decision_data_v1(
3039            &spec,
3040            true,
3041            AdmissionDecisionReason::AdmittedWithinCeiling,
3042            "ceiling.is_superset_of(spec.authority)",
3043            "sha256:spechash",
3044            Some("run-1"),
3045            EnforcementBinding::DeclaredAudit,
3046            &[],
3047        )
3048        .unwrap();
3049        let pid = data
3050            .get("providerIdentity")
3051            .expect("admission receipt must stamp providerIdentity");
3052        assert!(
3053            pid.get("name").and_then(|v| v.as_str()).is_some(),
3054            "providerIdentity carries a name"
3055        );
3056        assert!(
3057            pid.get("fipsMode").and_then(|v| v.as_bool()).is_some(),
3058            "providerIdentity carries fipsMode (producer claim)"
3059        );
3060    }
3061
3062    #[test]
3063    fn audit_reconciled_data_v1_is_deterministic_and_verifies_offline() {
3064        // S18: the reconcile receipt builder is deterministic and the wrapped
3065        // CloudEvent is offline-verifiable under the org-root key.
3066        use crate::{
3067            sign_event_with, verify_signed_event_envelope, CloudEventV1, Signer, SoftwareSigner,
3068        };
3069        use std::collections::HashMap;
3070
3071        let a = audit_reconciled_data_v1(
3072            "chain-A",
3073            0,
3074            41,
3075            "abc123",
3076            42,
3077            Some("abc123"),
3078            "blake3-chain+ed25519-head",
3079            true,
3080        )
3081        .unwrap();
3082        let b = audit_reconciled_data_v1(
3083            "chain-A",
3084            0,
3085            41,
3086            "abc123",
3087            42,
3088            Some("abc123"),
3089            "blake3-chain+ed25519-head",
3090            true,
3091        )
3092        .unwrap();
3093        assert_eq!(
3094            serde_json::to_vec(&a).unwrap(),
3095            serde_json::to_vec(&b).unwrap(),
3096            "builder must be deterministic"
3097        );
3098        assert_eq!(a["toSeq"], serde_json::json!(41));
3099        assert_eq!(a["signed"], serde_json::json!(true));
3100
3101        let event = CloudEventV1 {
3102            specversion: "1.0".into(),
3103            id: "rec-1".into(),
3104            source: "cellos-sink-spool".into(),
3105            ty: AUDIT_RECONCILED_TYPE.into(),
3106            datacontenttype: Some("application/json".into()),
3107            data: Some(a),
3108            time: None,
3109            traceparent: None,
3110            cex: None,
3111        };
3112        let signer = SoftwareSigner::from_seed("org-root-2026", [5u8; 32]).unwrap();
3113        let envelope = sign_event_with(&signer, &event).unwrap();
3114        let mut keys = HashMap::new();
3115        keys.insert("org-root-2026".to_string(), signer.verifying_key());
3116        let hmac: HashMap<String, Vec<u8>> = HashMap::new();
3117        let verified = verify_signed_event_envelope(&envelope, &keys, &hmac).unwrap();
3118        assert_eq!(verified.ty, AUDIT_RECONCILED_TYPE);
3119    }
3120
3121    #[test]
3122    fn lifecycle_started_matches_example_shape() {
3123        let raw =
3124            include_str!("../../../contracts/examples/execution-cell-ci-correlation.valid.json");
3125        let doc: ExecutionCellDocument = serde_json::from_str(raw).unwrap();
3126        let expected: Value = serde_json::from_str(include_str!(
3127            "../../../contracts/examples/cell-lifecycle-started-data.valid.json"
3128        ))
3129        .unwrap();
3130        let data = lifecycle_started_data_v1(
3131            &doc.spec,
3132            "host-cell-abc123",
3133            Some("run-2026-04-06-001"),
3134            None,
3135            None,
3136            None,
3137            None,
3138            None,
3139            None,
3140            None,
3141        )
3142        .unwrap();
3143        assert_eq!(data, expected);
3144    }
3145
3146    #[test]
3147    fn lifecycle_started_without_correlation() {
3148        let spec = ExecutionCellSpec {
3149            id: "s1".into(),
3150            correlation: None,
3151            ingress: None,
3152            environment: None,
3153            placement: None,
3154            policy: None,
3155            identity: None,
3156            run: None,
3157            authority: Default::default(),
3158            lifetime: Lifetime { ttl_seconds: 60 },
3159            export: None,
3160            telemetry: None,
3161        };
3162        let data =
3163            lifecycle_started_data_v1(&spec, "c1", None, None, None, None, None, None, None, None)
3164                .unwrap();
3165        assert!(!data.as_object().unwrap().contains_key("correlation"));
3166        assert!(!data.as_object().unwrap().contains_key("runId"));
3167        assert!(!data.as_object().unwrap().contains_key("derivationVerified"));
3168        assert!(!data.as_object().unwrap().contains_key("roleRoot"));
3169        assert!(!data.as_object().unwrap().contains_key("parentRunId"));
3170        assert!(!data.as_object().unwrap().contains_key("kernelDigestSha256"));
3171        assert!(!data.as_object().unwrap().contains_key("rootfsDigestSha256"));
3172        assert!(!data
3173            .as_object()
3174            .unwrap()
3175            .contains_key("firecrackerDigestSha256"));
3176    }
3177
3178    #[test]
3179    fn lifecycle_started_partial_correlation_serializes() {
3180        let spec = ExecutionCellSpec {
3181            id: "s2".into(),
3182            correlation: Some(Correlation {
3183                platform: Some("custom".into()),
3184                external_run_id: None,
3185                external_job_id: None,
3186                tenant_id: None,
3187                labels: None,
3188                correlation_id: None,
3189            }),
3190            ingress: None,
3191            environment: None,
3192            placement: None,
3193            policy: None,
3194            identity: None,
3195            run: None,
3196            authority: Default::default(),
3197            lifetime: Lifetime { ttl_seconds: 1 },
3198            export: None,
3199            telemetry: None,
3200        };
3201        let data =
3202            lifecycle_started_data_v1(&spec, "c2", None, None, None, None, None, None, None, None)
3203                .unwrap();
3204        assert_eq!(data["correlation"]["platform"], "custom");
3205    }
3206
3207    #[test]
3208    fn lifecycle_started_with_derivation_fields_emits_them() {
3209        let spec = ExecutionCellSpec {
3210            id: "deriv-1".into(),
3211            correlation: None,
3212            ingress: None,
3213            environment: None,
3214            placement: None,
3215            policy: None,
3216            identity: None,
3217            run: None,
3218            authority: Default::default(),
3219            lifetime: Lifetime { ttl_seconds: 60 },
3220            export: None,
3221            telemetry: None,
3222        };
3223        let data = lifecycle_started_data_v1(
3224            &spec,
3225            "cell-deriv",
3226            Some("run-deriv-1"),
3227            Some(false),
3228            Some("role-prod-ci"),
3229            Some("run-parent-001"),
3230            Some("abc123def456"),
3231            None,
3232            None,
3233            None,
3234        )
3235        .unwrap();
3236        assert_eq!(data["derivationVerified"], false);
3237        assert_eq!(data["roleRoot"], "role-prod-ci");
3238        assert_eq!(data["parentRunId"], "run-parent-001");
3239    }
3240
3241    #[test]
3242    fn lifecycle_destroyed_succeeded_shape() {
3243        let raw =
3244            include_str!("../../../contracts/examples/execution-cell-ci-correlation.valid.json");
3245        let doc: ExecutionCellDocument = serde_json::from_str(raw).unwrap();
3246        let data = lifecycle_destroyed_data_v1(
3247            &doc.spec,
3248            "host-xyz",
3249            Some("run-test"),
3250            LifecycleDestroyOutcome::Succeeded,
3251            None,
3252            None,
3253            None,
3254            None,
3255        )
3256        .unwrap();
3257        assert_eq!(data["outcome"], "succeeded");
3258        assert!(!data.as_object().unwrap().contains_key("reason"));
3259        assert!(
3260            !data.as_object().unwrap().contains_key("terminalState"),
3261            "terminalState must be omitted when None for backward-compat"
3262        );
3263        assert!(
3264            !data.as_object().unwrap().contains_key("evidenceBundleRef"),
3265            "evidenceBundleRef must be omitted when None for backward-compat"
3266        );
3267        assert!(
3268            !data.as_object().unwrap().contains_key("residueClass"),
3269            "residueClass must be omitted when None for backward-compat"
3270        );
3271        assert_eq!(data["ttlSeconds"], 3600);
3272    }
3273
3274    #[test]
3275    fn lifecycle_destroyed_failed_includes_reason() {
3276        let spec = ExecutionCellSpec {
3277            id: "s1".into(),
3278            correlation: None,
3279            ingress: None,
3280            environment: None,
3281            placement: None,
3282            policy: None,
3283            identity: None,
3284            run: None,
3285            authority: Default::default(),
3286            lifetime: Lifetime { ttl_seconds: 60 },
3287            export: None,
3288            telemetry: None,
3289        };
3290        let data = lifecycle_destroyed_data_v1(
3291            &spec,
3292            "c1",
3293            None,
3294            LifecycleDestroyOutcome::Failed,
3295            Some("secret resolve: denied"),
3296            None,
3297            None,
3298            None,
3299        )
3300        .unwrap();
3301        assert_eq!(data["outcome"], "failed");
3302        assert_eq!(data["reason"], "secret resolve: denied");
3303    }
3304
3305    #[test]
3306    fn admission_decision_reject_shape_and_determinism() {
3307        let raw =
3308            include_str!("../../../contracts/examples/execution-cell-ci-correlation.valid.json");
3309        let doc: ExecutionCellDocument = serde_json::from_str(raw).unwrap();
3310
3311        let build = || {
3312            admission_decision_data_v1(
3313                &doc.spec,
3314                false,
3315                AdmissionDecisionReason::AuthorityExceedsCeiling,
3316                "spec.authority ⊆ ceiling.authority",
3317                "sha256:abc",
3318                Some("run-001"),
3319                EnforcementBinding::LinuxNftables,
3320                &[("environment", "ci")],
3321            )
3322            .unwrap()
3323        };
3324
3325        let data = build();
3326        assert_eq!(data["admitted"], false);
3327        assert_eq!(data["reason"], "authority_exceeds_ceiling");
3328        assert_eq!(data["predicate"], "spec.authority ⊆ ceiling.authority");
3329        assert_eq!(data["specHash"], "sha256:abc");
3330        assert_eq!(data["runId"], "run-001");
3331        assert_eq!(data["enforcementBinding"], "linux-nftables");
3332        assert_eq!(data["context"]["environment"], "ci");
3333
3334        // reason is exactly one of the closed snake_case discriminants.
3335        let closed = [
3336            "authority_exceeds_ceiling",
3337            "ceiling_manifest_unverified",
3338            "ceiling_manifest_unparseable",
3339            "ceiling_manifest_wrong_payload_type",
3340            "ceiling_dimension_uncomparable",
3341            "ceiling_epoch_stale",
3342            "ceiling_degraded_unconfigured",
3343            "formation_edge_not_narrowing",
3344            "admitted_within_ceiling",
3345        ];
3346        assert!(closed.contains(&data["reason"].as_str().unwrap()));
3347
3348        // Two builds of the same verdict are byte-identical (canonical payload).
3349        let a = serde_json::to_vec(&build()).unwrap();
3350        let b = serde_json::to_vec(&build()).unwrap();
3351        assert_eq!(a, b);
3352    }
3353
3354    #[test]
3355    fn admission_decision_allow_omits_optional_fields() {
3356        let spec = ExecutionCellSpec {
3357            id: "s1".into(),
3358            correlation: None,
3359            ingress: None,
3360            environment: None,
3361            placement: None,
3362            policy: None,
3363            identity: None,
3364            run: None,
3365            authority: Default::default(),
3366            lifetime: Lifetime { ttl_seconds: 60 },
3367            export: None,
3368            telemetry: None,
3369        };
3370        let data = admission_decision_data_v1(
3371            &spec,
3372            true,
3373            AdmissionDecisionReason::AdmittedWithinCeiling,
3374            "spec.authority ⊆ ceiling.authority",
3375            "sha256:def",
3376            None,
3377            EnforcementBinding::DeclaredAudit,
3378            &[],
3379        )
3380        .unwrap();
3381        assert_eq!(data["admitted"], true);
3382        assert_eq!(data["reason"], "admitted_within_ceiling");
3383        assert_eq!(data["enforcementBinding"], "declared-audit");
3384        let obj = data.as_object().unwrap();
3385        assert!(!obj.contains_key("runId"), "runId omitted when None");
3386        assert!(!obj.contains_key("context"), "context omitted when empty");
3387        assert!(
3388            !obj.contains_key("correlation"),
3389            "correlation omitted when None"
3390        );
3391    }
3392
3393    #[test]
3394    fn lifecycle_destroyed_terminal_state_clean_serializes() {
3395        let spec = ExecutionCellSpec {
3396            id: "term-clean".into(),
3397            correlation: None,
3398            ingress: None,
3399            environment: None,
3400            placement: None,
3401            policy: None,
3402            identity: None,
3403            run: None,
3404            authority: Default::default(),
3405            lifetime: Lifetime { ttl_seconds: 60 },
3406            export: None,
3407            telemetry: None,
3408        };
3409        let data = lifecycle_destroyed_data_v1(
3410            &spec,
3411            "c-clean",
3412            None,
3413            LifecycleDestroyOutcome::Succeeded,
3414            None,
3415            Some(LifecycleTerminalState::Clean),
3416            None,
3417            None,
3418        )
3419        .unwrap();
3420        assert_eq!(data["terminalState"], "clean");
3421    }
3422
3423    #[test]
3424    fn lifecycle_destroyed_terminal_state_forced_serializes() {
3425        let spec = ExecutionCellSpec {
3426            id: "term-forced".into(),
3427            correlation: None,
3428            ingress: None,
3429            environment: None,
3430            placement: None,
3431            policy: None,
3432            identity: None,
3433            run: None,
3434            authority: Default::default(),
3435            lifetime: Lifetime { ttl_seconds: 60 },
3436            export: None,
3437            telemetry: None,
3438        };
3439        let data = lifecycle_destroyed_data_v1(
3440            &spec,
3441            "c-forced",
3442            None,
3443            LifecycleDestroyOutcome::Failed,
3444            Some("in-VM exit bridge: vsock closed"),
3445            Some(LifecycleTerminalState::Forced),
3446            None,
3447            None,
3448        )
3449        .unwrap();
3450        assert_eq!(data["terminalState"], "forced");
3451        assert_eq!(data["outcome"], "failed");
3452    }
3453
3454    #[test]
3455    fn lifecycle_destroyed_evidence_bundle_and_residue_class_serialize_when_populated() {
3456        let spec = ExecutionCellSpec {
3457            id: "f5-populated".into(),
3458            correlation: None,
3459            ingress: None,
3460            environment: None,
3461            placement: None,
3462            policy: None,
3463            identity: None,
3464            run: None,
3465            authority: Default::default(),
3466            lifetime: Lifetime { ttl_seconds: 60 },
3467            export: None,
3468            telemetry: None,
3469        };
3470        let bundle = SubjectUrn::parse("urn:cellos:evidence-bundle:run-1").unwrap();
3471        let data = lifecycle_destroyed_data_v1(
3472            &spec,
3473            "c-f5",
3474            Some("run-1"),
3475            LifecycleDestroyOutcome::Succeeded,
3476            None,
3477            None,
3478            Some(&bundle),
3479            Some(ResidueClass::DocumentedException),
3480        )
3481        .unwrap();
3482        assert_eq!(
3483            data["evidenceBundleRef"],
3484            "urn:cellos:evidence-bundle:run-1"
3485        );
3486        assert_eq!(data["residueClass"], "documented_exception");
3487    }
3488
3489    #[test]
3490    fn lifecycle_destroyed_evidence_bundle_and_residue_class_omitted_when_none() {
3491        let spec = ExecutionCellSpec {
3492            id: "f5-omitted".into(),
3493            correlation: None,
3494            ingress: None,
3495            environment: None,
3496            placement: None,
3497            policy: None,
3498            identity: None,
3499            run: None,
3500            authority: Default::default(),
3501            lifetime: Lifetime { ttl_seconds: 60 },
3502            export: None,
3503            telemetry: None,
3504        };
3505        let data = lifecycle_destroyed_data_v1(
3506            &spec,
3507            "c-f5-omit",
3508            None,
3509            LifecycleDestroyOutcome::Succeeded,
3510            None,
3511            None,
3512            None,
3513            None,
3514        )
3515        .unwrap();
3516        let obj = data.as_object().unwrap();
3517        assert!(
3518            !obj.contains_key("evidenceBundleRef"),
3519            "evidenceBundleRef must be omitted when None"
3520        );
3521        assert!(
3522            !obj.contains_key("residueClass"),
3523            "residueClass must be omitted when None"
3524        );
3525    }
3526
3527    #[test]
3528    fn identity_materialized_matches_example_shape() {
3529        let raw =
3530            include_str!("../../../contracts/examples/execution-cell-github-oidc-s3.valid.json");
3531        let doc: ExecutionCellDocument = serde_json::from_str(raw).unwrap();
3532        let identity = doc.spec.identity.as_ref().expect("identity");
3533        let data = identity_materialized_data_v1(&doc.spec, "host-xyz", Some("run-test"), identity)
3534            .unwrap();
3535        assert_eq!(data["identity"]["kind"], "federatedOidc");
3536        assert_eq!(data["identity"]["provider"], "github-actions");
3537        assert_eq!(data["identity"]["secretRef"], "AWS_WEB_IDENTITY");
3538        assert_eq!(data["runId"], "run-test");
3539    }
3540
3541    #[test]
3542    fn identity_materialized_provisioned_witnesses_aws_role_arn() {
3543        let spec = ExecutionCellSpec {
3544            id: "s-aws".into(),
3545            correlation: None,
3546            ingress: None,
3547            environment: None,
3548            placement: None,
3549            policy: None,
3550            identity: Some(WorkloadIdentity {
3551                kind: WorkloadIdentityKind::AwsAssumeRole {
3552                    role_arn: "arn:aws:iam::123456789012:role/cell".into(),
3553                    sts_session_policy: None,
3554                },
3555                provider: String::new(),
3556                audience: String::new(),
3557                subject: None,
3558                ttl_seconds: Some(900),
3559                secret_ref: "AWS_WEB_IDENTITY".into(),
3560            }),
3561            run: None,
3562            authority: Default::default(),
3563            lifetime: Lifetime { ttl_seconds: 3600 },
3564            export: None,
3565            telemetry: None,
3566        };
3567        let identity = spec.identity.as_ref().unwrap();
3568        let witness = ProvisioningWitness {
3569            provisioned_role_arn: Some("arn:aws:iam::123456789012:role/cell"),
3570            session_policy_digest: Some(
3571                "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
3572            ),
3573            azure_object_id: None,
3574            credential_jti: Some("jti-abc"),
3575        };
3576        let data = identity_materialized_data_v1_provisioned(
3577            &spec,
3578            "host-aws",
3579            Some("run-aws"),
3580            identity,
3581            &witness,
3582        )
3583        .unwrap();
3584        assert_eq!(
3585            data["provisionedRoleArn"],
3586            "arn:aws:iam::123456789012:role/cell"
3587        );
3588        assert_eq!(
3589            data["sessionPolicyDigest"],
3590            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
3591        );
3592        assert_eq!(data["credentialJti"], "jti-abc");
3593        let obj = data.as_object().unwrap();
3594        assert!(
3595            !obj.contains_key("azureObjectId"),
3596            "azureObjectId must be omitted when None"
3597        );
3598    }
3599
3600    #[test]
3601    fn identity_materialized_provisioned_empty_witness_matches_base() {
3602        let raw =
3603            include_str!("../../../contracts/examples/execution-cell-github-oidc-s3.valid.json");
3604        let doc: ExecutionCellDocument = serde_json::from_str(raw).unwrap();
3605        let identity = doc.spec.identity.as_ref().expect("identity");
3606        let base = identity_materialized_data_v1(&doc.spec, "host-xyz", Some("run-test"), identity)
3607            .unwrap();
3608        let provisioned = identity_materialized_data_v1_provisioned(
3609            &doc.spec,
3610            "host-xyz",
3611            Some("run-test"),
3612            identity,
3613            &ProvisioningWitness::default(),
3614        )
3615        .unwrap();
3616        assert_eq!(base, provisioned);
3617    }
3618
3619    #[test]
3620    fn identity_revoked_includes_reason() {
3621        let spec = ExecutionCellSpec {
3622            id: "s3".into(),
3623            correlation: None,
3624            ingress: None,
3625            environment: None,
3626            placement: None,
3627            policy: None,
3628            identity: Some(WorkloadIdentity {
3629                kind: WorkloadIdentityKind::FederatedOidc,
3630                provider: "github-actions".into(),
3631                audience: "sts.amazonaws.com".into(),
3632                subject: None,
3633                ttl_seconds: Some(900),
3634                secret_ref: "AWS_WEB_IDENTITY".into(),
3635            }),
3636            run: None,
3637            authority: Default::default(),
3638            lifetime: Lifetime { ttl_seconds: 3600 },
3639            export: None,
3640            telemetry: None,
3641        };
3642        let identity = spec.identity.as_ref().unwrap();
3643        let data =
3644            identity_revoked_data_v1(&spec, "c3", None, identity, Some("teardown"), None).unwrap();
3645        assert_eq!(data["identity"]["audience"], "sts.amazonaws.com");
3646        assert_eq!(data["reason"], "teardown");
3647    }
3648
3649    #[test]
3650    fn identity_failed_matches_example_shape() {
3651        let raw =
3652            include_str!("../../../contracts/examples/execution-cell-github-oidc-s3.valid.json");
3653        let doc: ExecutionCellDocument = serde_json::from_str(raw).unwrap();
3654        let expected: Value = serde_json::from_str(include_str!(
3655            "../../../contracts/examples/cell-identity-failed-data.valid.json"
3656        ))
3657        .unwrap();
3658        let identity = doc.spec.identity.as_ref().expect("identity");
3659        let data = identity_failed_data_v1(
3660            &doc.spec,
3661            "host-cell-demo",
3662            Some("run-001"),
3663            identity,
3664            IdentityFailureOperation::Materialize,
3665            "oidc exchange denied by upstream federation policy",
3666        )
3667        .unwrap();
3668        assert_eq!(data, expected);
3669    }
3670
3671    #[test]
3672    fn export_completed_v2_matches_example_shape() {
3673        let raw =
3674            include_str!("../../../contracts/examples/execution-cell-github-oidc-s3.valid.json");
3675        let doc: ExecutionCellDocument = serde_json::from_str(raw).unwrap();
3676        let receipt = ExportReceipt {
3677            target_kind: ExportReceiptTargetKind::S3,
3678            target_name: Some("artifact-bucket".into()),
3679            destination: "s3://acme-cellos-artifacts/github/acme/widget/123456789/test-results"
3680                .into(),
3681            bytes_written: 1024,
3682        };
3683        let data = export_completed_data_v2(
3684            &doc.spec,
3685            "host-xyz",
3686            Some("run-test"),
3687            "test-results",
3688            &receipt,
3689            None,
3690        )
3691        .unwrap();
3692        assert_eq!(data["receipt"]["targetKind"], "s3");
3693        assert_eq!(data["receipt"]["targetName"], "artifact-bucket");
3694        assert_eq!(data["receipt"]["bytesWritten"], 1024);
3695    }
3696
3697    #[test]
3698    fn export_completed_v2_http_matches_example_shape() {
3699        let raw = include_str!(
3700            "../../../contracts/examples/execution-cell-github-oidc-multi-export.valid.json"
3701        );
3702        let doc: ExecutionCellDocument = serde_json::from_str(raw).unwrap();
3703        let expected: Value = serde_json::from_str(include_str!(
3704            "../../../contracts/examples/cell-export-v2-completed-data-http.valid.json"
3705        ))
3706        .unwrap();
3707        let receipt = ExportReceipt {
3708            target_kind: ExportReceiptTargetKind::Http,
3709            target_name: Some("artifact-api".into()),
3710            destination: "https://artifacts.acme.internal/upload/host-cell-demo/coverage-summary"
3711                .into(),
3712            bytes_written: 512,
3713        };
3714        let data = export_completed_data_v2(
3715            &doc.spec,
3716            "host-cell-demo",
3717            Some("run-002"),
3718            "coverage-summary",
3719            &receipt,
3720            None,
3721        )
3722        .unwrap();
3723        assert_eq!(data, expected);
3724    }
3725
3726    #[test]
3727    fn export_failed_v2_http_matches_example_shape() {
3728        let raw = include_str!(
3729            "../../../contracts/examples/execution-cell-github-oidc-multi-export.valid.json"
3730        );
3731        let doc: ExecutionCellDocument = serde_json::from_str(raw).unwrap();
3732        let expected: Value = serde_json::from_str(include_str!(
3733            "../../../contracts/examples/cell-export-v2-failed-data.valid.json"
3734        ))
3735        .unwrap();
3736        let data = export_failed_data_v2(
3737            &doc.spec,
3738            "host-cell-demo",
3739            Some("run-002"),
3740            "coverage-summary",
3741            ExportReceiptTargetKind::Http,
3742            Some("artifact-api"),
3743            Some("https://artifacts.acme.internal/upload/host-cell-demo/coverage-summary"),
3744            "http put returned 403 Forbidden",
3745            None,
3746        )
3747        .unwrap();
3748        assert_eq!(data, expected);
3749    }
3750
3751    #[test]
3752    fn compliance_summary_matches_example_shape() {
3753        let raw =
3754            include_str!("../../../contracts/examples/execution-cell-ci-correlation.valid.json");
3755        let doc: ExecutionCellDocument = serde_json::from_str(raw).unwrap();
3756        let expected: Value = serde_json::from_str(include_str!(
3757            "../../../contracts/examples/cell-compliance-summary-data.valid.json"
3758        ))
3759        .unwrap();
3760        let data =
3761            compliance_summary_data_v1(&doc.spec, "host-cell-demo", Some("run-003"), Some(0))
3762                .unwrap();
3763        assert_eq!(data, expected);
3764    }
3765
3766    #[test]
3767    fn compliance_summary_omits_placement_when_absent() {
3768        let spec = ExecutionCellSpec {
3769            id: "compliance-no-placement".into(),
3770            correlation: None,
3771            ingress: None,
3772            environment: None,
3773            placement: None,
3774            policy: None,
3775            identity: None,
3776            run: None,
3777            authority: Default::default(),
3778            lifetime: Lifetime { ttl_seconds: 60 },
3779            export: None,
3780            telemetry: None,
3781        };
3782        let data = compliance_summary_data_v1(&spec, "cell-001", None, None).unwrap();
3783        assert!(!data.as_object().unwrap().contains_key("placement"));
3784    }
3785
3786    /// P0-7 / G4: empty `subject_urns` slice MUST keep the payload byte-shape
3787    /// identical to the legacy 4-arg delegate — i.e. `subjectUrns` is omitted.
3788    /// This is the contract that lets supervisor.rs keep its current call
3789    /// site unchanged.
3790    #[test]
3791    fn compliance_summary_with_empty_subjects_omits_field() {
3792        let raw =
3793            include_str!("../../../contracts/examples/execution-cell-ci-correlation.valid.json");
3794        let doc: ExecutionCellDocument = serde_json::from_str(raw).unwrap();
3795        let legacy =
3796            compliance_summary_data_v1(&doc.spec, "host-cell-demo", Some("run-003"), Some(0))
3797                .unwrap();
3798        let with_empty = compliance_summary_data_v1_with_subjects(
3799            &doc.spec,
3800            "host-cell-demo",
3801            Some("run-003"),
3802            Some(0),
3803            &[],
3804        )
3805        .unwrap();
3806        assert_eq!(legacy, with_empty);
3807        assert!(!with_empty.as_object().unwrap().contains_key("subjectUrns"));
3808    }
3809
3810    /// P0-7 / G4: non-empty subject set must round-trip against the
3811    /// `cell-compliance-summary-data-with-subjects.valid.json` golden fixture.
3812    #[test]
3813    fn compliance_summary_with_subjects_matches_example_shape() {
3814        let raw =
3815            include_str!("../../../contracts/examples/execution-cell-ci-correlation.valid.json");
3816        let doc: ExecutionCellDocument = serde_json::from_str(raw).unwrap();
3817        let expected: Value = serde_json::from_str(include_str!(
3818            "../../../contracts/examples/cell-compliance-summary-data-with-subjects.valid.json"
3819        ))
3820        .unwrap();
3821        let subjects: Vec<SubjectUrn> = vec![
3822            SubjectUrn::parse("urn:cellos:cell:host-cell-demo").unwrap(),
3823            SubjectUrn::parse("urn:tsafe:lease:lease-42").unwrap(),
3824            SubjectUrn::parse("urn:cellos:export:run-003%2Fartifact-1").unwrap(),
3825        ];
3826        let data = compliance_summary_data_v1_with_subjects(
3827            &doc.spec,
3828            "host-cell-demo",
3829            Some("run-003"),
3830            Some(0),
3831            &subjects,
3832        )
3833        .unwrap();
3834        assert_eq!(data, expected);
3835        let urns = data["subjectUrns"].as_array().unwrap();
3836        assert_eq!(urns.len(), 3);
3837        assert_eq!(urns[0], "urn:cellos:cell:host-cell-demo");
3838    }
3839
3840    /// P0-7 / G4 negative fixture: `cell-compliance-summary-data.invalid.json`
3841    /// is intentionally malformed (URNs that violate the schema regex). The
3842    /// `scripts/validate_contracts.py` walker only globs `*.valid.json`, so we
3843    /// load the negative fixture explicitly here and hand-check each entry
3844    /// against the schema regex shape. If this test ever passes the regex,
3845    /// the fixture has drifted away from "negative" and must be regenerated.
3846    #[test]
3847    fn compliance_summary_invalid_subject_urns_fixture_is_malformed() {
3848        let raw =
3849            include_str!("../../../contracts/examples/cell-compliance-summary-data.invalid.json");
3850        let v: Value = serde_json::from_str(raw).unwrap();
3851        let urns = v["subjectUrns"]
3852            .as_array()
3853            .expect("invalid fixture must carry subjectUrns array");
3854        assert!(!urns.is_empty(), "negative fixture must have entries");
3855
3856        // Schema regex from cell-compliance-summary-v1.schema.json. We don't
3857        // pull in a regex crate just for this — instead we apply a minimal
3858        // structural decomposition that the schema regex enforces:
3859        //   urn:<tool>:<kind>:<id>
3860        //   tool/kind = [a-z0-9][a-z0-9-]*  (lowercase, must start alnum)
3861        //   id        = non-empty, [A-Za-z0-9._:%-]+
3862        fn matches_schema_shape(s: &str) -> bool {
3863            let parts: Vec<&str> = s.splitn(4, ':').collect();
3864            if parts.len() != 4 {
3865                return false;
3866            }
3867            if parts[0] != "urn" {
3868                return false;
3869            }
3870            let segment_ok = |seg: &str| {
3871                let mut it = seg.chars();
3872                match it.next() {
3873                    Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() => {}
3874                    _ => return false,
3875                }
3876                it.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
3877            };
3878            if !segment_ok(parts[1]) || !segment_ok(parts[2]) {
3879                return false;
3880            }
3881            if parts[3].is_empty() {
3882                return false;
3883            }
3884            parts[3]
3885                .chars()
3886                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '%' | '-'))
3887        }
3888
3889        for (i, urn) in urns.iter().enumerate() {
3890            let s = urn.as_str().unwrap_or("");
3891            assert!(
3892                !matches_schema_shape(s),
3893                "invalid fixture entry [{i}] {s:?} unexpectedly matches the schema URN regex; \
3894                 fixture must remain a negative case"
3895            );
3896        }
3897    }
3898
3899    #[test]
3900    fn network_enforcement_matches_example_shape() {
3901        let raw = include_str!(
3902            "../../../contracts/examples/cell-observability-network-enforcement-data.valid.json"
3903        );
3904        let expected: Value = serde_json::from_str(raw).unwrap();
3905        let spec = ExecutionCellSpec {
3906            id: "net-enforcement-demo".into(),
3907            correlation: None,
3908            ingress: None,
3909            environment: None,
3910            placement: None,
3911            policy: None,
3912            identity: None,
3913            run: None,
3914            authority: Default::default(),
3915            lifetime: Lifetime { ttl_seconds: 60 },
3916            export: None,
3917            telemetry: None,
3918        };
3919        let data = observability_network_enforcement_data_v1(
3920            &spec,
3921            "net-enforcement-demo",
3922            Some("run-local-001"),
3923            true,
3924            1,
3925            1,
3926            None,
3927        )
3928        .unwrap();
3929        assert_eq!(data, expected);
3930    }
3931
3932    #[test]
3933    fn dns_resolution_matches_example_shape() {
3934        let raw = include_str!(
3935            "../../../contracts/examples/cell-observability-dns-resolution-data.valid.json"
3936        );
3937        let expected: Value = serde_json::from_str(raw).unwrap();
3938        let spec = ExecutionCellSpec {
3939            id: "demo-cell-dns".into(),
3940            correlation: None,
3941            ingress: None,
3942            environment: None,
3943            placement: None,
3944            policy: None,
3945            identity: None,
3946            run: None,
3947            authority: Default::default(),
3948            lifetime: Lifetime { ttl_seconds: 60 },
3949            export: None,
3950            telemetry: None,
3951        };
3952        let targets: &[(&str, &str, Option<u16>)] = &[
3953            ("203.0.113.10", "inet", Some(443)),
3954            ("2001:db8::1", "inet6", Some(443)),
3955        ];
3956        let data = observability_dns_resolution_data_v1(
3957            &spec,
3958            "demo-cell-dns",
3959            Some("run-001"),
3960            "api.example.com",
3961            "2026-04-30T12:00:00Z",
3962            targets,
3963            300,
3964            "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
3965            "keyset-demo-001",
3966            "kid-resolver-01",
3967            Some("rcpt-demo-0001"),
3968        )
3969        .unwrap();
3970        assert_eq!(data, expected);
3971    }
3972
3973    #[test]
3974    fn dns_target_set_matches_example_shape() {
3975        let raw = include_str!(
3976            "../../../contracts/examples/cell-observability-dns-target-set-data.valid.json"
3977        );
3978        let expected: Value = serde_json::from_str(raw).unwrap();
3979        let spec = ExecutionCellSpec {
3980            id: "demo-cell-dns".into(),
3981            correlation: None,
3982            ingress: None,
3983            environment: None,
3984            placement: None,
3985            policy: None,
3986            identity: None,
3987            run: None,
3988            authority: Default::default(),
3989            lifetime: Lifetime { ttl_seconds: 60 },
3990            export: None,
3991            telemetry: None,
3992        };
3993        let data = observability_dns_target_set_data_v1(
3994            &spec,
3995            "demo-cell-dns",
3996            Some("run-001"),
3997            "cdn.example.com",
3998            "empty",
3999            "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
4000            "refresh",
4001            "2026-04-30T12:05:00Z",
4002            "keyset-demo-001",
4003            "kid-resolver-01",
4004        )
4005        .unwrap();
4006        assert_eq!(data, expected);
4007    }
4008
4009    #[test]
4010    fn dns_query_data_v1_serializes_allow_path() {
4011        use crate::{DnsQueryDecision, DnsQueryEvent, DnsQueryReasonCode, DnsQueryType};
4012        let ev = DnsQueryEvent {
4013            schema_version: "1.0.0".into(),
4014            cell_id: "demo-cell-dns".into(),
4015            run_id: "run-2026-05-01-001".into(),
4016            query_id: "q-3b58b2a4-e4bb-4f89-9c4f-2a0a2c8b6f01".into(),
4017            query_name: "api.example.com".into(),
4018            query_type: DnsQueryType::A,
4019            decision: DnsQueryDecision::Allow,
4020            reason_code: DnsQueryReasonCode::AllowedByAllowlist,
4021            response_rcode: Some(0),
4022            upstream_resolver_id: Some("resolver-do53-internal".into()),
4023            upstream_latency_ms: Some(4),
4024            response_target_count: Some(2),
4025            keyset_id: Some("keyset-demo-001".into()),
4026            issuer_kid: Some("kid-resolver-01".into()),
4027            policy_digest: Some(
4028                "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".into(),
4029            ),
4030            correlation_id: Some("corr-demo-0001".into()),
4031            observed_at: "2026-05-01T12:34:56Z".into(),
4032        };
4033        let v = dns_query_data_v1(&ev).unwrap();
4034        assert_eq!(v["schemaVersion"], "1.0.0");
4035        assert_eq!(v["queryName"], "api.example.com");
4036        assert_eq!(v["queryType"], "A");
4037        assert_eq!(v["decision"], "allow");
4038        assert_eq!(v["reasonCode"], "allowed_by_allowlist");
4039        assert_eq!(v["upstreamResolverId"], "resolver-do53-internal");
4040        assert_eq!(v["responseTargetCount"], 2);
4041    }
4042
4043    #[test]
4044    fn dns_query_data_v1_omits_optionals_on_deny_path() {
4045        use crate::{DnsQueryDecision, DnsQueryEvent, DnsQueryReasonCode, DnsQueryType};
4046        let ev = DnsQueryEvent {
4047            schema_version: "1.0.0".into(),
4048            cell_id: "demo-cell-dns".into(),
4049            run_id: "run-2026-05-01-001".into(),
4050            query_id: "q-deny-001".into(),
4051            query_name: "blocked.example.com".into(),
4052            query_type: DnsQueryType::AAAA,
4053            decision: DnsQueryDecision::Deny,
4054            reason_code: DnsQueryReasonCode::DeniedNotInAllowlist,
4055            response_rcode: Some(5),
4056            upstream_resolver_id: None,
4057            upstream_latency_ms: None,
4058            response_target_count: Some(0),
4059            keyset_id: None,
4060            issuer_kid: None,
4061            policy_digest: None,
4062            correlation_id: None,
4063            observed_at: "2026-05-01T12:35:00Z".into(),
4064        };
4065        let v = dns_query_data_v1(&ev).unwrap();
4066        let obj = v.as_object().unwrap();
4067        assert!(!obj.contains_key("upstreamResolverId"));
4068        assert!(!obj.contains_key("upstreamLatencyMs"));
4069        assert!(!obj.contains_key("keysetId"));
4070        assert!(!obj.contains_key("issuerKid"));
4071        assert!(!obj.contains_key("policyDigest"));
4072        assert!(!obj.contains_key("correlationId"));
4073        assert_eq!(v["decision"], "deny");
4074        assert_eq!(v["reasonCode"], "denied_not_in_allowlist");
4075        assert_eq!(v["responseRcode"], 5);
4076    }
4077
4078    #[test]
4079    fn cloud_event_v1_dns_query_envelope() {
4080        use crate::{DnsQueryDecision, DnsQueryEvent, DnsQueryReasonCode, DnsQueryType};
4081        let ev = DnsQueryEvent {
4082            schema_version: "1.0.0".into(),
4083            cell_id: "c1".into(),
4084            run_id: "r1".into(),
4085            query_id: "q1".into(),
4086            query_name: "api.example.com".into(),
4087            query_type: DnsQueryType::A,
4088            decision: DnsQueryDecision::Allow,
4089            reason_code: DnsQueryReasonCode::AllowedByAllowlist,
4090            response_rcode: Some(0),
4091            upstream_resolver_id: Some("r-001".into()),
4092            upstream_latency_ms: Some(3),
4093            response_target_count: Some(1),
4094            keyset_id: None,
4095            issuer_kid: None,
4096            policy_digest: None,
4097            correlation_id: None,
4098            observed_at: "2026-05-01T12:34:56Z".into(),
4099        };
4100        let env =
4101            cloud_event_v1_dns_query("cellos-dns-proxy", "2026-05-01T12:34:56Z", &ev).unwrap();
4102        assert_eq!(env.specversion, "1.0");
4103        assert_eq!(env.ty, "dev.cellos.events.cell.observability.v1.dns_query");
4104        assert_eq!(env.source, "cellos-dns-proxy");
4105        assert_eq!(env.datacontenttype.as_deref(), Some("application/json"));
4106        assert!(env.data.is_some());
4107    }
4108
4109    #[test]
4110    fn qtype_mapping_covers_phase1_set() {
4111        use crate::{qtype_to_dns_query_type, DnsQueryType};
4112        assert_eq!(qtype_to_dns_query_type(1), Some(DnsQueryType::A));
4113        assert_eq!(qtype_to_dns_query_type(2), Some(DnsQueryType::NS));
4114        assert_eq!(qtype_to_dns_query_type(5), Some(DnsQueryType::CNAME));
4115        assert_eq!(qtype_to_dns_query_type(12), Some(DnsQueryType::PTR));
4116        assert_eq!(qtype_to_dns_query_type(15), Some(DnsQueryType::MX));
4117        assert_eq!(qtype_to_dns_query_type(16), Some(DnsQueryType::TXT));
4118        assert_eq!(qtype_to_dns_query_type(28), Some(DnsQueryType::AAAA));
4119        assert_eq!(qtype_to_dns_query_type(33), Some(DnsQueryType::SRV));
4120        assert_eq!(qtype_to_dns_query_type(64), Some(DnsQueryType::SVCB));
4121        assert_eq!(qtype_to_dns_query_type(65), Some(DnsQueryType::HTTPS));
4122        // Unmapped types fall outside the allowlist set.
4123        assert_eq!(qtype_to_dns_query_type(0), None);
4124        assert_eq!(qtype_to_dns_query_type(99), None);
4125        assert_eq!(qtype_to_dns_query_type(255), None); // ANY
4126    }
4127
4128    #[test]
4129    fn l7_egress_decision_matches_example_shape() {
4130        let raw = include_str!(
4131            "../../../contracts/examples/cell-observability-l7-egress-decision-data.valid.json"
4132        );
4133        let expected: Value = serde_json::from_str(raw).unwrap();
4134        let spec = ExecutionCellSpec {
4135            id: "demo-cell-dns".into(),
4136            correlation: None,
4137            ingress: None,
4138            environment: None,
4139            placement: None,
4140            policy: None,
4141            identity: None,
4142            run: None,
4143            authority: Default::default(),
4144            lifetime: Lifetime { ttl_seconds: 60 },
4145            export: None,
4146            telemetry: None,
4147        };
4148        let data = observability_l7_egress_decision_data_v1(
4149            &spec,
4150            "demo-cell-dns",
4151            Some("run-001"),
4152            "l7-demo-0002",
4153            "deny",
4154            "blocked.example.com",
4155            "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
4156            "keyset-demo-001",
4157            "kid-l7-01",
4158            "deny_default",
4159            Some("authority.egressRules.default"),
4160            None, // streamId omitted on the SNI/HTTP path; Phase 3g.1 only populates for h2.
4161        )
4162        .unwrap();
4163        assert_eq!(data, expected);
4164    }
4165
4166    #[test]
4167    fn l7_egress_decision_with_stream_id_emits_field() {
4168        let spec = ExecutionCellSpec {
4169            id: "demo-cell-dns".into(),
4170            correlation: None,
4171            ingress: None,
4172            environment: None,
4173            placement: None,
4174            policy: None,
4175            identity: None,
4176            run: None,
4177            authority: Default::default(),
4178            lifetime: Lifetime { ttl_seconds: 60 },
4179            export: None,
4180            telemetry: None,
4181        };
4182        let data = observability_l7_egress_decision_data_v1(
4183            &spec,
4184            "demo-cell-dns",
4185            Some("run-001"),
4186            "l7-demo-0003",
4187            "deny",
4188            "evil.example.com",
4189            "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
4190            "keyset-demo-001",
4191            "kid-l7-01",
4192            "l7_h2_authority_allowlist_miss",
4193            None,
4194            Some(3), // h2 stream id
4195        )
4196        .unwrap();
4197        assert_eq!(data["streamId"], serde_json::json!(3));
4198    }
4199
4200    // ── Tranche-1 seam-freeze G1 + G2 provenance/correlation tests ───────────
4201
4202    fn _seam_g1_g2_minimal_spec(id: &str) -> ExecutionCellSpec {
4203        ExecutionCellSpec {
4204            id: id.into(),
4205            correlation: None,
4206            ingress: None,
4207            environment: None,
4208            placement: None,
4209            policy: None,
4210            identity: None,
4211            run: None,
4212            authority: Default::default(),
4213            lifetime: Lifetime { ttl_seconds: 60 },
4214            export: None,
4215            telemetry: None,
4216        }
4217    }
4218
4219    #[test]
4220    fn seam_g2_identity_revoked_includes_provenance_when_set() {
4221        // G2 — provenance.parent on revoke event.
4222        let mut spec = _seam_g1_g2_minimal_spec("seam-g2-revoke");
4223        spec.identity = Some(WorkloadIdentity {
4224            kind: WorkloadIdentityKind::FederatedOidc,
4225            provider: "github-actions".into(),
4226            audience: "sts.amazonaws.com".into(),
4227            subject: None,
4228            ttl_seconds: Some(900),
4229            secret_ref: "AWS_WEB_IDENTITY".into(),
4230        });
4231        let identity = spec.identity.as_ref().unwrap();
4232        let prov = Provenance {
4233            parent: "urn:cellos:event:00000000-0000-0000-0000-00000000abcd".into(),
4234            parent_type: "dev.cellos.events.cell.lifecycle.v1.started".into(),
4235        };
4236        let data = identity_revoked_data_v1(
4237            &spec,
4238            "cell-seam-g2",
4239            Some("run-seam-g2"),
4240            identity,
4241            Some("teardown"),
4242            Some(&prov),
4243        )
4244        .unwrap();
4245        assert_eq!(
4246            data["provenance"]["parent"],
4247            "urn:cellos:event:00000000-0000-0000-0000-00000000abcd"
4248        );
4249        assert_eq!(
4250            data["provenance"]["parentType"],
4251            "dev.cellos.events.cell.lifecycle.v1.started"
4252        );
4253    }
4254
4255    #[test]
4256    fn seam_g2_identity_revoked_omits_provenance_when_none() {
4257        // Backward-compat: producers without a parent reference must not
4258        // emit a provenance key at all.
4259        let mut spec = _seam_g1_g2_minimal_spec("seam-g2-revoke-no-prov");
4260        spec.identity = Some(WorkloadIdentity {
4261            kind: WorkloadIdentityKind::FederatedOidc,
4262            provider: "github-actions".into(),
4263            audience: "sts.amazonaws.com".into(),
4264            subject: None,
4265            ttl_seconds: Some(900),
4266            secret_ref: "AWS_WEB_IDENTITY".into(),
4267        });
4268        let identity = spec.identity.as_ref().unwrap();
4269        let data =
4270            identity_revoked_data_v1(&spec, "cell-x", None, identity, Some("teardown"), None)
4271                .unwrap();
4272        assert!(!data.as_object().unwrap().contains_key("provenance"));
4273    }
4274
4275    #[test]
4276    fn seam_g2_export_completed_v2_includes_provenance_when_set() {
4277        // G2 — provenance.parent on export receipt completed event.
4278        let spec = _seam_g1_g2_minimal_spec("seam-g2-export");
4279        let receipt = ExportReceipt {
4280            target_kind: ExportReceiptTargetKind::Local,
4281            target_name: None,
4282            destination: "/tmp/out/run-1/artifact.json".into(),
4283            bytes_written: 42,
4284        };
4285        let prov = Provenance {
4286            parent: "urn:cellos:event:11111111-1111-1111-1111-111111111111".into(),
4287            parent_type: "dev.cellos.events.cell.lifecycle.v1.started".into(),
4288        };
4289        let data = export_completed_data_v2(
4290            &spec,
4291            "cell-export",
4292            Some("run-export"),
4293            "artifact",
4294            &receipt,
4295            Some(&prov),
4296        )
4297        .unwrap();
4298        assert_eq!(
4299            data["provenance"]["parent"],
4300            "urn:cellos:event:11111111-1111-1111-1111-111111111111"
4301        );
4302        assert_eq!(
4303            data["provenance"]["parentType"],
4304            "dev.cellos.events.cell.lifecycle.v1.started"
4305        );
4306    }
4307
4308    #[test]
4309    fn seam_g2_export_failed_v2_includes_provenance_when_set() {
4310        // G2 — provenance.parent on export receipt failed event.
4311        let spec = _seam_g1_g2_minimal_spec("seam-g2-export-failed");
4312        let prov = Provenance {
4313            parent: "urn:cellos:event:22222222-2222-2222-2222-222222222222".into(),
4314            parent_type: "dev.cellos.events.cell.lifecycle.v1.started".into(),
4315        };
4316        let data = export_failed_data_v2(
4317            &spec,
4318            "cell-fail",
4319            Some("run-fail"),
4320            "artifact",
4321            ExportReceiptTargetKind::S3,
4322            Some("bucket"),
4323            Some("s3://bucket/artifact"),
4324            "denied by policy",
4325            Some(&prov),
4326        )
4327        .unwrap();
4328        assert_eq!(
4329            data["provenance"]["parent"],
4330            "urn:cellos:event:22222222-2222-2222-2222-222222222222"
4331        );
4332        assert_eq!(data["reason"], "denied by policy");
4333    }
4334
4335    #[test]
4336    fn seam_g1_correlation_id_propagates_when_present_in_spec() {
4337        // G1 — when the broker (or operator) supplies correlation_id,
4338        // every event that mirrors `Correlation` carries it through into
4339        // its `data.correlation.correlationId` payload field. We exercise
4340        // the property on a representative subset of events that share the
4341        // same `if let Some(c) = &spec.correlation { ... }` pattern.
4342        let mut spec = _seam_g1_g2_minimal_spec("seam-g1-corr");
4343        spec.correlation = Some(Correlation {
4344            platform: None,
4345            external_run_id: None,
4346            external_job_id: None,
4347            tenant_id: None,
4348            labels: None,
4349            correlation_id: Some("urn:tsafe:corr:01J".into()),
4350        });
4351        spec.identity = Some(WorkloadIdentity {
4352            kind: WorkloadIdentityKind::FederatedOidc,
4353            provider: "github-actions".into(),
4354            audience: "sts.amazonaws.com".into(),
4355            subject: None,
4356            ttl_seconds: Some(900),
4357            secret_ref: "AWS_WEB_IDENTITY".into(),
4358        });
4359        let identity = spec.identity.as_ref().unwrap();
4360
4361        // identity_revoked
4362        let data =
4363            identity_revoked_data_v1(&spec, "cell-1", Some("r"), identity, Some("teardown"), None)
4364                .unwrap();
4365        assert_eq!(
4366            data["correlation"]["correlationId"], "urn:tsafe:corr:01J",
4367            "identity.revoked must mirror correlationId from spec"
4368        );
4369
4370        // export_completed v2
4371        let receipt = ExportReceipt {
4372            target_kind: ExportReceiptTargetKind::Local,
4373            target_name: None,
4374            destination: "/tmp/x".into(),
4375            bytes_written: 1,
4376        };
4377        let data = export_completed_data_v2(&spec, "c", Some("r"), "art", &receipt, None).unwrap();
4378        assert_eq!(
4379            data["correlation"]["correlationId"], "urn:tsafe:corr:01J",
4380            "export.v2.completed must mirror correlationId from spec"
4381        );
4382
4383        // export_failed v2
4384        let data = export_failed_data_v2(
4385            &spec,
4386            "c",
4387            Some("r"),
4388            "art",
4389            ExportReceiptTargetKind::Local,
4390            None,
4391            None,
4392            "boom",
4393            None,
4394        )
4395        .unwrap();
4396        assert_eq!(
4397            data["correlation"]["correlationId"], "urn:tsafe:corr:01J",
4398            "export.v2.failed must mirror correlationId from spec"
4399        );
4400
4401        // command_completed
4402        let data =
4403            command_completed_data_v1(&spec, "c", Some("r"), &["echo".to_string()], 0, 5, None)
4404                .unwrap();
4405        assert_eq!(
4406            data["correlation"]["correlationId"], "urn:tsafe:corr:01J",
4407            "command.completed must mirror correlationId from spec"
4408        );
4409
4410        // compliance_summary
4411        let data = compliance_summary_data_v1(&spec, "c", Some("r"), Some(0)).unwrap();
4412        assert_eq!(
4413            data["correlation"]["correlationId"], "urn:tsafe:corr:01J",
4414            "compliance.summary must mirror correlationId from spec"
4415        );
4416    }
4417
4418    // ---------------------------------------------------------------------
4419    // Seam-freeze G3 (P0-6): SubjectUrn validation tests.
4420    //
4421    // Every literal `urn:...` / `cell:...` string in this section is for a
4422    // negative or positive test case and is intentionally allow-listed by
4423    // `scripts/audit/subject-urn-check.sh` (which excludes `events.rs`).
4424    // ---------------------------------------------------------------------
4425
4426    #[test]
4427    fn subject_urn_accepts_canonical_cell_form() {
4428        // Positive case: the canonical CellOS cell subject shape.
4429        let urn = SubjectUrn::parse("urn:cellos:cell:abc-123").expect("must parse");
4430        assert_eq!(urn.as_str(), "urn:cellos:cell:abc-123");
4431    }
4432
4433    #[test]
4434    fn subject_urn_accepts_id_with_internal_colons() {
4435        // Forward-compat: <id> may carry colons (e.g. nested identifiers).
4436        // Validation only constrains tool/kind charset, not <id>.
4437        let urn = SubjectUrn::parse("urn:cellos:event:abc:01j").expect("must parse");
4438        assert_eq!(urn.as_str(), "urn:cellos:event:abc:01j");
4439    }
4440
4441    #[test]
4442    fn subject_urn_rejects_when_no_urn_scheme() {
4443        // Negative #1: legacy `cell:<id>` shape — no `urn:` scheme.
4444        let err = SubjectUrn::parse("cell:abc-123").unwrap_err();
4445        assert_eq!(err, SubjectUrnError::MissingUrnScheme);
4446    }
4447
4448    #[test]
4449    fn subject_urn_rejects_three_segment_form() {
4450        // Negative #2: only three colon-separated segments.
4451        let err = SubjectUrn::parse("urn:cellos:cell").unwrap_err();
4452        assert_eq!(err, SubjectUrnError::TooFewSegments);
4453    }
4454
4455    #[test]
4456    fn subject_urn_rejects_empty_id() {
4457        // Negative #3: trailing empty `<id>`.
4458        let err = SubjectUrn::parse("urn:cellos:cell:").unwrap_err();
4459        assert_eq!(err, SubjectUrnError::EmptySegment);
4460    }
4461
4462    #[test]
4463    fn subject_urn_rejects_uppercase_tool_or_kind() {
4464        // Negative #4: charset [a-z0-9-] excludes uppercase.
4465        let err = SubjectUrn::parse("urn:CellOS:cell:abc-123").unwrap_err();
4466        assert_eq!(err, SubjectUrnError::InvalidToolOrKindCharset);
4467    }
4468
4469    #[test]
4470    fn subject_urn_rejects_embedded_whitespace() {
4471        // Negative #5: any whitespace (including in <id>) is rejected.
4472        let err = SubjectUrn::parse("urn:cellos:cell:abc 123").unwrap_err();
4473        assert_eq!(err, SubjectUrnError::ControlOrWhitespace);
4474    }
4475
4476    #[test]
4477    fn subject_urn_rejects_empty_tool_segment() {
4478        // Defense: `urn::kind:id` (empty <tool>) is empty, not charset.
4479        let err = SubjectUrn::parse("urn::cell:abc-123").unwrap_err();
4480        assert_eq!(err, SubjectUrnError::EmptySegment);
4481    }
4482
4483    #[test]
4484    fn cell_subject_urn_helper_round_trips() {
4485        // Helper must produce a SubjectUrn that parses identically.
4486        let urn = cell_subject_urn("cell-host-7").expect("helper must accept ASCII id");
4487        assert_eq!(urn.as_str(), "urn:cellos:cell:cell-host-7");
4488        // And re-parsing the same string must succeed without normalization.
4489        let reparsed = SubjectUrn::parse(urn.as_str()).expect("must reparse");
4490        assert_eq!(reparsed, urn);
4491    }
4492
4493    #[test]
4494    fn cell_subject_urn_helper_rejects_empty_id() {
4495        let err = cell_subject_urn("").unwrap_err();
4496        assert_eq!(err, SubjectUrnError::EmptySegment);
4497    }
4498
4499    // ── Formation lifecycle events ────────────────────────────────────────
4500
4501    #[test]
4502    fn formation_data_v1_shape_happy_path() {
4503        let data = formation_data_v1("f-123", "demo-formation", 3, &[], None);
4504        assert_eq!(data["formationId"], json!("f-123"));
4505        assert_eq!(data["formationName"], json!("demo-formation"));
4506        assert_eq!(data["cellCount"], json!(3));
4507        assert_eq!(data["failedCellIds"], json!([] as [String; 0]));
4508        // `reason` is omitted (not null) when None — auditors check key presence.
4509        let obj = data.as_object().unwrap();
4510        assert!(!obj.contains_key("reason"));
4511    }
4512
4513    #[test]
4514    fn formation_data_v1_shape_degraded_path_includes_failed_cells_and_reason() {
4515        let failed = vec!["cell-a".to_string(), "cell-b".to_string()];
4516        let data = formation_data_v1(
4517            "f-123",
4518            "demo-formation",
4519            5,
4520            &failed,
4521            Some("2/5 cells exited non-zero"),
4522        );
4523        assert_eq!(data["failedCellIds"], json!(failed));
4524        assert_eq!(data["reason"], json!("2/5 cells exited non-zero"));
4525    }
4526
4527    #[test]
4528    fn formation_created_envelope_carries_correct_urn() {
4529        let ev = cloud_event_v1_formation_created(
4530            "cellos-supervisor",
4531            "2026-05-16T00:00:00Z",
4532            "f-1",
4533            "demo",
4534            2,
4535            &[],
4536            None,
4537        );
4538        assert_eq!(ev.ty, "dev.cellos.events.cell.formation.v1.created");
4539        assert_eq!(ev.specversion, "1.0");
4540        assert_eq!(ev.source, "cellos-supervisor");
4541        assert!(ev.data.is_some());
4542    }
4543
4544    #[test]
4545    fn formation_launching_envelope_carries_correct_urn() {
4546        let ev = cloud_event_v1_formation_launching(
4547            "cellos-supervisor",
4548            "2026-05-16T00:00:00Z",
4549            "f-1",
4550            "demo",
4551            2,
4552            &[],
4553            None,
4554        );
4555        assert_eq!(ev.ty, "dev.cellos.events.cell.formation.v1.launching");
4556    }
4557
4558    #[test]
4559    fn formation_running_envelope_carries_correct_urn() {
4560        let ev = cloud_event_v1_formation_running(
4561            "cellos-supervisor",
4562            "2026-05-16T00:00:00Z",
4563            "f-1",
4564            "demo",
4565            2,
4566            &[],
4567            None,
4568        );
4569        assert_eq!(ev.ty, "dev.cellos.events.cell.formation.v1.running");
4570    }
4571
4572    #[test]
4573    fn formation_degraded_envelope_carries_correct_urn_and_failed_cells() {
4574        let failed = vec!["cell-a".to_string()];
4575        let ev = cloud_event_v1_formation_degraded(
4576            "cellos-supervisor",
4577            "2026-05-16T00:00:00Z",
4578            "f-1",
4579            "demo",
4580            3,
4581            &failed,
4582            Some("one cell exited 1"),
4583        );
4584        assert_eq!(ev.ty, "dev.cellos.events.cell.formation.v1.degraded");
4585        let data = ev.data.unwrap();
4586        assert_eq!(data["failedCellIds"], json!(failed));
4587        assert_eq!(data["reason"], json!("one cell exited 1"));
4588    }
4589
4590    #[test]
4591    fn formation_completed_envelope_carries_correct_urn() {
4592        let ev = cloud_event_v1_formation_completed(
4593            "cellos-supervisor",
4594            "2026-05-16T00:00:00Z",
4595            "f-1",
4596            "demo",
4597            2,
4598            &[],
4599            None,
4600        );
4601        assert_eq!(ev.ty, "dev.cellos.events.cell.formation.v1.completed");
4602    }
4603
4604    #[test]
4605    fn formation_failed_envelope_carries_correct_urn_and_reason() {
4606        let failed = vec!["cell-a".to_string(), "cell-b".to_string()];
4607        let ev = cloud_event_v1_formation_failed(
4608            "cellos-supervisor",
4609            "2026-05-16T00:00:00Z",
4610            "f-1",
4611            "demo",
4612            2,
4613            &failed,
4614            Some("all cells exited non-zero"),
4615        );
4616        assert_eq!(ev.ty, "dev.cellos.events.cell.formation.v1.failed");
4617        let data = ev.data.unwrap();
4618        assert_eq!(data["failedCellIds"], json!(failed));
4619        assert_eq!(data["reason"], json!("all cells exited non-zero"));
4620    }
4621
4622    #[test]
4623    fn formation_type_constants_match_envelope_urns() {
4624        assert_eq!(
4625            FORMATION_CREATED_TYPE,
4626            "dev.cellos.events.cell.formation.v1.created"
4627        );
4628        assert_eq!(
4629            FORMATION_LAUNCHING_TYPE,
4630            "dev.cellos.events.cell.formation.v1.launching"
4631        );
4632        assert_eq!(
4633            FORMATION_RUNNING_TYPE,
4634            "dev.cellos.events.cell.formation.v1.running"
4635        );
4636        assert_eq!(
4637            FORMATION_DEGRADED_TYPE,
4638            "dev.cellos.events.cell.formation.v1.degraded"
4639        );
4640        assert_eq!(
4641            FORMATION_COMPLETED_TYPE,
4642            "dev.cellos.events.cell.formation.v1.completed"
4643        );
4644        assert_eq!(
4645            FORMATION_FAILED_TYPE,
4646            "dev.cellos.events.cell.formation.v1.failed"
4647        );
4648    }
4649}