1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
19#[serde(rename_all = "lowercase")]
20pub enum LifecycleDestroyOutcome {
21 Succeeded,
22 Failed,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
43#[serde(rename_all = "lowercase")]
44pub enum LifecycleTerminalState {
45 Clean,
46 Forced,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
66#[non_exhaustive]
67pub enum LifecycleReason {
68 Oom,
70 TtlExceeded,
72 VmmCrashed,
74 BootFailed,
76 SignalKilled,
79 InitCrashed,
81 KernelCannotMountRoot,
84 Other(String),
87}
88
89impl LifecycleReason {
90 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
120#[serde(rename_all = "lowercase")]
121pub enum IdentityFailureOperation {
122 Materialize,
123 Revoke,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
153#[serde(rename_all = "camelCase")]
154pub struct Provenance {
155 pub parent: String,
157 pub parent_type: String,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
187#[serde(transparent)]
188pub struct SubjectUrn(String);
189
190#[derive(Debug, Clone, PartialEq, Eq)]
192pub enum SubjectUrnError {
193 MissingUrnScheme,
195 TooFewSegments,
197 EmptySegment,
199 InvalidToolOrKindCharset,
201 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 pub fn parse(s: impl Into<String>) -> Result<Self, SubjectUrnError> {
231 let s = s.into();
232
233 if s.bytes()
235 .any(|b| b.is_ascii_control() || (b as char).is_whitespace())
236 {
237 return Err(SubjectUrnError::ControlOrWhitespace);
238 }
239
240 let rest = match s.strip_prefix("urn:") {
242 Some(r) => r,
243 None => return Err(SubjectUrnError::MissingUrnScheme),
244 };
245
246 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 if tool.is_empty() || kind.is_empty() || id.is_empty() {
256 return Err(SubjectUrnError::EmptySegment);
257 }
258
259 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 pub fn as_str(&self) -> &str {
273 &self.0
274 }
275
276 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
294pub fn cell_subject_urn(cell_id: &str) -> Result<SubjectUrn, SubjectUrnError> {
305 SubjectUrn::parse(format!("urn:cellos:cell:{cell_id}"))
306}
307
308#[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#[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
492pub const LIFECYCLE_MANIFEST_FAILED_TYPE: &str =
500 "dev.cellos.events.cell.lifecycle.v1.manifest-failed";
501
502pub 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#[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
574pub 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
596pub fn identity_revoked_data_v1(
606 spec: &ExecutionCellSpec,
607 cell_id: &str,
608 run_id: Option<&str>,
609 identity: &WorkloadIdentity,
610 reason: Option<&str>,
611 provenance: Option<&Provenance>,
612) -> Result<Value, serde_json::Error> {
613 let mut m = Map::new();
614 m.insert("cellId".to_string(), json!(cell_id));
615 m.insert("specId".to_string(), json!(&spec.id));
616 m.insert("identity".to_string(), serde_json::to_value(identity)?);
617 if let Some(r) = run_id {
618 m.insert("runId".to_string(), json!(r));
619 }
620 if let Some(c) = &spec.correlation {
621 m.insert("correlation".to_string(), serde_json::to_value(c)?);
622 }
623 if let Some(s) = reason {
624 m.insert("reason".to_string(), json!(s));
625 }
626 if let Some(p) = provenance {
627 m.insert("provenance".to_string(), serde_json::to_value(p)?);
628 }
629 Ok(Value::Object(m))
630}
631
632pub fn identity_failed_data_v1(
636 spec: &ExecutionCellSpec,
637 cell_id: &str,
638 run_id: Option<&str>,
639 identity: &WorkloadIdentity,
640 operation: IdentityFailureOperation,
641 reason: &str,
642) -> Result<Value, serde_json::Error> {
643 let mut m = Map::new();
644 m.insert("cellId".to_string(), json!(cell_id));
645 m.insert("specId".to_string(), json!(&spec.id));
646 m.insert("identity".to_string(), serde_json::to_value(identity)?);
647 m.insert("operation".to_string(), serde_json::to_value(operation)?);
648 m.insert("reason".to_string(), json!(reason));
649 if let Some(r) = run_id {
650 m.insert("runId".to_string(), json!(r));
651 }
652 if let Some(c) = &spec.correlation {
653 m.insert("correlation".to_string(), serde_json::to_value(c)?);
654 }
655 Ok(Value::Object(m))
656}
657
658pub fn command_completed_data_v1(
662 spec: &ExecutionCellSpec,
663 cell_id: &str,
664 run_id: Option<&str>,
665 argv: &[String],
666 exit_code: i32,
667 duration_ms: u64,
668 spawn_error: Option<&str>,
669) -> Result<Value, serde_json::Error> {
670 let mut m = Map::new();
671 m.insert("cellId".to_string(), json!(cell_id));
672 m.insert("specId".to_string(), json!(&spec.id));
673 m.insert("exitCode".to_string(), json!(exit_code));
674 m.insert("durationMs".to_string(), json!(duration_ms));
675 m.insert("argv".to_string(), json!(argv));
676 if let Some(r) = run_id {
677 m.insert("runId".to_string(), json!(r));
678 }
679 if let Some(c) = &spec.correlation {
680 m.insert("correlation".to_string(), serde_json::to_value(c)?);
681 }
682 if let Some(s) = spawn_error {
683 m.insert("spawnError".to_string(), json!(s));
684 }
685 Ok(Value::Object(m))
686}
687
688pub fn observability_network_scope_data_v1(
692 spec: &ExecutionCellSpec,
693 cell_id: &str,
694 run_id: Option<&str>,
695 egress_rule_count: usize,
696 has_opaque_network_authority: bool,
697) -> Result<Value, serde_json::Error> {
698 let mut m = Map::new();
699 m.insert("cellId".to_string(), json!(cell_id));
700 m.insert("specId".to_string(), json!(&spec.id));
701 m.insert("egressRuleCount".to_string(), json!(egress_rule_count));
702 m.insert(
703 "hasOpaqueNetworkAuthority".to_string(),
704 json!(has_opaque_network_authority),
705 );
706 if let Some(r) = run_id {
707 m.insert("runId".to_string(), json!(r));
708 }
709 if let Some(c) = &spec.correlation {
710 m.insert("correlation".to_string(), serde_json::to_value(c)?);
711 }
712 Ok(Value::Object(m))
713}
714
715pub fn observability_process_spawned_data_v1(
719 spec: &ExecutionCellSpec,
720 cell_id: &str,
721 run_id: Option<&str>,
722 program: &str,
723 argc: usize,
724) -> Result<Value, serde_json::Error> {
725 let mut m = Map::new();
726 m.insert("cellId".to_string(), json!(cell_id));
727 m.insert("specId".to_string(), json!(&spec.id));
728 m.insert("program".to_string(), json!(program));
729 m.insert("argc".to_string(), json!(argc));
730 if let Some(r) = run_id {
731 m.insert("runId".to_string(), json!(r));
732 }
733 if let Some(c) = &spec.correlation {
734 m.insert("correlation".to_string(), serde_json::to_value(c)?);
735 }
736 Ok(Value::Object(m))
737}
738
739pub fn observability_fs_touch_export_data_v1(
743 spec: &ExecutionCellSpec,
744 cell_id: &str,
745 run_id: Option<&str>,
746 source_path: &str,
747 artifact_name: &str,
748) -> Result<Value, serde_json::Error> {
749 let mut m = Map::new();
750 m.insert("cellId".to_string(), json!(cell_id));
751 m.insert("specId".to_string(), json!(&spec.id));
752 m.insert("purpose".to_string(), json!("export"));
753 m.insert("sourcePath".to_string(), json!(source_path));
754 m.insert("artifactName".to_string(), json!(artifact_name));
755 if let Some(r) = run_id {
756 m.insert("runId".to_string(), json!(r));
757 }
758 if let Some(c) = &spec.correlation {
759 m.insert("correlation".to_string(), serde_json::to_value(c)?);
760 }
761 Ok(Value::Object(m))
762}
763
764pub fn export_completed_data_v1(
768 spec: &ExecutionCellSpec,
769 cell_id: &str,
770 run_id: Option<&str>,
771 artifact_name: &str,
772 bytes_written: u64,
773 destination_relative: &str,
774) -> Result<Value, serde_json::Error> {
775 let mut m = Map::new();
776 m.insert("cellId".to_string(), json!(cell_id));
777 m.insert("specId".to_string(), json!(&spec.id));
778 m.insert("artifactName".to_string(), json!(artifact_name));
779 m.insert("bytesWritten".to_string(), json!(bytes_written));
780 m.insert(
781 "destinationRelative".to_string(),
782 json!(destination_relative),
783 );
784 if let Some(r) = run_id {
785 m.insert("runId".to_string(), json!(r));
786 }
787 if let Some(c) = &spec.correlation {
788 m.insert("correlation".to_string(), serde_json::to_value(c)?);
789 }
790 Ok(Value::Object(m))
791}
792
793pub fn export_completed_data_v2(
804 spec: &ExecutionCellSpec,
805 cell_id: &str,
806 run_id: Option<&str>,
807 artifact_name: &str,
808 receipt: &ExportReceipt,
809 provenance: Option<&Provenance>,
810) -> Result<Value, serde_json::Error> {
811 let mut m = Map::new();
812 m.insert("cellId".to_string(), json!(cell_id));
813 m.insert("specId".to_string(), json!(&spec.id));
814 m.insert("artifactName".to_string(), json!(artifact_name));
815 m.insert("receipt".to_string(), serde_json::to_value(receipt)?);
816 if let Some(r) = run_id {
817 m.insert("runId".to_string(), json!(r));
818 }
819 if let Some(c) = &spec.correlation {
820 m.insert("correlation".to_string(), serde_json::to_value(c)?);
821 }
822 if let Some(p) = provenance {
823 m.insert("provenance".to_string(), serde_json::to_value(p)?);
824 }
825 Ok(Value::Object(m))
826}
827
828#[allow(clippy::too_many_arguments)] pub fn export_failed_data_v2(
840 spec: &ExecutionCellSpec,
841 cell_id: &str,
842 run_id: Option<&str>,
843 artifact_name: &str,
844 target_kind: crate::ExportReceiptTargetKind,
845 target_name: Option<&str>,
846 destination: Option<&str>,
847 reason: &str,
848 provenance: Option<&Provenance>,
849) -> Result<Value, serde_json::Error> {
850 let mut m = Map::new();
851 m.insert("cellId".to_string(), json!(cell_id));
852 m.insert("specId".to_string(), json!(&spec.id));
853 m.insert("artifactName".to_string(), json!(artifact_name));
854 m.insert("targetKind".to_string(), serde_json::to_value(target_kind)?);
855 m.insert("reason".to_string(), json!(reason));
856 if let Some(name) = target_name {
857 m.insert("targetName".to_string(), json!(name));
858 }
859 if let Some(dest) = destination {
860 m.insert("destination".to_string(), json!(dest));
861 }
862 if let Some(r) = run_id {
863 m.insert("runId".to_string(), json!(r));
864 }
865 if let Some(c) = &spec.correlation {
866 m.insert("correlation".to_string(), serde_json::to_value(c)?);
867 }
868 if let Some(p) = provenance {
869 m.insert("provenance".to_string(), serde_json::to_value(p)?);
870 }
871 Ok(Value::Object(m))
872}
873
874pub fn observability_network_policy_data_v1(
881 spec: &ExecutionCellSpec,
882 cell_id: &str,
883 run_id: Option<&str>,
884 isolation_mode: &str,
885 egress_rules: &[crate::EgressRule],
886) -> Result<Value, serde_json::Error> {
887 let mut m = Map::new();
888 m.insert("cellId".to_string(), json!(cell_id));
889 m.insert("specId".to_string(), json!(&spec.id));
890 m.insert("isolationMode".to_string(), json!(isolation_mode));
891 m.insert("declaredEgressCount".to_string(), json!(egress_rules.len()));
892 m.insert(
893 "declaredEgress".to_string(),
894 serde_json::to_value(egress_rules)?,
895 );
896 if let Some(r) = run_id {
897 m.insert("runId".to_string(), json!(r));
898 }
899 if let Some(c) = &spec.correlation {
900 m.insert("correlation".to_string(), serde_json::to_value(c)?);
901 }
902 Ok(Value::Object(m))
903}
904
905#[allow(clippy::too_many_arguments)]
912pub fn observability_network_enforcement_data_v1(
913 spec: &ExecutionCellSpec,
914 cell_id: &str,
915 run_id: Option<&str>,
916 nft_rules_applied: bool,
917 declared_egress_rule_count: usize,
918 command_exit_code: i32,
919 spawn_error: Option<&str>,
920) -> Result<Value, serde_json::Error> {
921 let supplementary = nft_rules_applied && declared_egress_rule_count > 0;
922 let mut m = Map::new();
923 m.insert("cellId".to_string(), json!(cell_id));
924 m.insert("specId".to_string(), json!(&spec.id));
925 m.insert("isolationMode".to_string(), json!("clone_newnet"));
926 m.insert("nftRulesApplied".to_string(), json!(nft_rules_applied));
927 m.insert(
928 "declaredEgressRuleCount".to_string(),
929 json!(declared_egress_rule_count),
930 );
931 m.insert(
932 "supplementaryEgressFilterActive".to_string(),
933 json!(supplementary),
934 );
935 m.insert("commandExitCode".to_string(), json!(command_exit_code));
936 if let Some(r) = run_id {
937 m.insert("runId".to_string(), json!(r));
938 }
939 if let Some(s) = spawn_error {
940 m.insert("spawnError".to_string(), json!(s));
941 }
942 if let Some(c) = &spec.correlation {
943 m.insert("correlation".to_string(), serde_json::to_value(c)?);
944 }
945 Ok(Value::Object(m))
946}
947
948pub const TRUST_PLANE_BUILTIN_KEYSET_ID: &str = "cellos:builtin-v0";
952
953pub const TRUST_PLANE_BUILTIN_RESOLVER_KID: &str = "cellos-local-resolve-v0";
955
956pub const TRUST_PLANE_BUILTIN_L7_KID: &str = "cellos-local-l7-v0";
958
959pub const TRUST_PLANE_AGGREGATE_EGRESS_FQDN: &str = "declared-egress.trust.cellos.internal";
961
962#[allow(clippy::too_many_arguments)]
966pub fn observability_dns_resolution_data_v1(
967 spec: &ExecutionCellSpec,
968 cell_id: &str,
969 run_id: Option<&str>,
970 fqdn: &str,
971 resolved_at: &str,
972 targets: &[(&str, &str, Option<u16>)],
973 ttl_seconds: i64,
974 policy_digest: &str,
975 keyset_id: &str,
976 issuer_kid: &str,
977 receipt_id: Option<&str>,
978) -> Result<Value, serde_json::Error> {
979 let mut rows = Vec::with_capacity(targets.len());
980 for (addr, family, port) in targets {
981 let mut row = Map::new();
982 row.insert("address".to_string(), json!(addr));
983 row.insert("family".to_string(), json!(family));
984 if let Some(p) = port {
985 row.insert("port".to_string(), json!(p));
986 }
987 rows.push(Value::Object(row));
988 }
989 let mut m = Map::new();
990 m.insert("cellId".to_string(), json!(cell_id));
991 m.insert("specId".to_string(), json!(&spec.id));
992 if let Some(r) = run_id {
993 m.insert("runId".to_string(), json!(r));
994 }
995 if let Some(rid) = receipt_id {
996 m.insert("receiptId".to_string(), json!(rid));
997 }
998 m.insert("fqdn".to_string(), json!(fqdn));
999 m.insert("resolvedAt".to_string(), json!(resolved_at));
1000 m.insert("targets".to_string(), Value::Array(rows));
1001 m.insert("ttlSeconds".to_string(), json!(ttl_seconds));
1002 m.insert("policyDigest".to_string(), json!(policy_digest));
1003 m.insert("keysetId".to_string(), json!(keyset_id));
1004 m.insert("issuerKid".to_string(), json!(issuer_kid));
1005 if let Some(c) = &spec.correlation {
1006 m.insert("correlation".to_string(), serde_json::to_value(c)?);
1007 }
1008 Ok(Value::Object(m))
1009}
1010
1011#[allow(clippy::too_many_arguments)]
1015pub fn observability_dns_target_set_data_v1(
1016 spec: &ExecutionCellSpec,
1017 cell_id: &str,
1018 run_id: Option<&str>,
1019 fqdn: &str,
1020 previous_digest: &str,
1021 current_digest: &str,
1022 reason: &str,
1023 updated_at: &str,
1024 keyset_id: &str,
1025 issuer_kid: &str,
1026) -> Result<Value, serde_json::Error> {
1027 let mut m = Map::new();
1028 m.insert("cellId".to_string(), json!(cell_id));
1029 m.insert("specId".to_string(), json!(&spec.id));
1030 if let Some(r) = run_id {
1031 m.insert("runId".to_string(), json!(r));
1032 }
1033 m.insert("fqdn".to_string(), json!(fqdn));
1034 m.insert("previousDigest".to_string(), json!(previous_digest));
1035 m.insert("currentDigest".to_string(), json!(current_digest));
1036 m.insert("reason".to_string(), json!(reason));
1037 m.insert("updatedAt".to_string(), json!(updated_at));
1038 m.insert("keysetId".to_string(), json!(keyset_id));
1039 m.insert("issuerKid".to_string(), json!(issuer_kid));
1040 if let Some(c) = &spec.correlation {
1041 m.insert("correlation".to_string(), serde_json::to_value(c)?);
1042 }
1043 Ok(Value::Object(m))
1044}
1045
1046#[allow(clippy::too_many_arguments)]
1056pub fn dns_authority_drift_data_v1(drift: &DnsAuthorityDrift) -> Result<Value, serde_json::Error> {
1057 serde_json::to_value(drift)
1058}
1059
1060pub fn cloud_event_v1_dns_authority_drift(
1071 source: &str,
1072 time: &str,
1073 drift: &DnsAuthorityDrift,
1074) -> Result<CloudEventV1, serde_json::Error> {
1075 Ok(CloudEventV1 {
1076 specversion: "1.0".into(),
1077 id: uuid::Uuid::new_v4().to_string(),
1078 source: source.to_string(),
1079 ty: "dev.cellos.events.cell.observability.v1.dns_authority_drift".into(),
1080 datacontenttype: Some("application/json".into()),
1081 data: Some(dns_authority_drift_data_v1(drift)?),
1082 time: Some(time.to_string()),
1083 traceparent: None,
1084 })
1085}
1086
1087pub fn dns_authority_rebind_threshold_data_v1(
1099 payload: &DnsAuthorityRebindThreshold,
1100) -> Result<Value, serde_json::Error> {
1101 serde_json::to_value(payload)
1102}
1103
1104pub fn cloud_event_v1_dns_authority_rebind_threshold(
1116 source: &str,
1117 time: &str,
1118 payload: &DnsAuthorityRebindThreshold,
1119) -> Result<CloudEventV1, serde_json::Error> {
1120 Ok(CloudEventV1 {
1121 specversion: "1.0".into(),
1122 id: uuid::Uuid::new_v4().to_string(),
1123 source: source.to_string(),
1124 ty: "dev.cellos.events.cell.observability.v1.dns_authority_rebind_threshold".into(),
1125 datacontenttype: Some("application/json".into()),
1126 data: Some(dns_authority_rebind_threshold_data_v1(payload)?),
1127 time: Some(time.to_string()),
1128 traceparent: None,
1129 })
1130}
1131
1132pub fn dns_authority_rebind_rejected_data_v1(
1144 payload: &DnsAuthorityRebindRejected,
1145) -> Result<Value, serde_json::Error> {
1146 serde_json::to_value(payload)
1147}
1148
1149pub fn cloud_event_v1_dns_authority_rebind_rejected(
1156 source: &str,
1157 time: &str,
1158 payload: &DnsAuthorityRebindRejected,
1159) -> Result<CloudEventV1, serde_json::Error> {
1160 Ok(CloudEventV1 {
1161 specversion: "1.0".into(),
1162 id: uuid::Uuid::new_v4().to_string(),
1163 source: source.to_string(),
1164 ty: "dev.cellos.events.cell.observability.v1.dns_authority_rebind_rejected".into(),
1165 datacontenttype: Some("application/json".into()),
1166 data: Some(dns_authority_rebind_rejected_data_v1(payload)?),
1167 time: Some(time.to_string()),
1168 traceparent: None,
1169 })
1170}
1171
1172pub fn dns_authority_dnssec_failed_data_v1(
1184 payload: &DnsAuthorityDnssecFailed,
1185) -> Result<Value, serde_json::Error> {
1186 serde_json::to_value(payload)
1187}
1188
1189pub fn cloud_event_v1_dns_authority_dnssec_failed(
1201 source: &str,
1202 time: &str,
1203 payload: &DnsAuthorityDnssecFailed,
1204) -> Result<CloudEventV1, serde_json::Error> {
1205 Ok(CloudEventV1 {
1206 specversion: "1.0".into(),
1207 id: uuid::Uuid::new_v4().to_string(),
1208 source: source.to_string(),
1209 ty: "dev.cellos.events.cell.observability.v1.dns_authority_dnssec_failed".into(),
1210 datacontenttype: Some("application/json".into()),
1211 data: Some(dns_authority_dnssec_failed_data_v1(payload)?),
1212 time: Some(time.to_string()),
1213 traceparent: None,
1214 })
1215}
1216
1217pub fn dns_query_data_v1(event: &DnsQueryEvent) -> Result<Value, serde_json::Error> {
1226 serde_json::to_value(event)
1227}
1228
1229pub fn cloud_event_v1_dns_query(
1240 source: &str,
1241 time: &str,
1242 event: &DnsQueryEvent,
1243) -> Result<CloudEventV1, serde_json::Error> {
1244 Ok(CloudEventV1 {
1245 specversion: "1.0".into(),
1246 id: uuid::Uuid::new_v4().to_string(),
1247 source: source.to_string(),
1248 ty: "dev.cellos.events.cell.observability.v1.dns_query".into(),
1249 datacontenttype: Some("application/json".into()),
1250 data: Some(dns_query_data_v1(event)?),
1251 time: Some(time.to_string()),
1252 traceparent: None,
1253 })
1254}
1255
1256#[must_use]
1272pub fn dns_query_permitted_data_v1(
1273 qname: &str,
1274 qtype: &str,
1275 cell_id: &str,
1276 resolver: &str,
1277) -> Value {
1278 json!({
1279 "schemaVersion": "1.0.0",
1280 "queryName": qname,
1281 "queryType": qtype,
1282 "cellId": cell_id,
1283 "resolver": resolver,
1284 })
1285}
1286
1287pub fn cloud_event_v1_dns_query_permitted(
1289 source: &str,
1290 time: &str,
1291 qname: &str,
1292 qtype: &str,
1293 cell_id: &str,
1294 resolver: &str,
1295) -> CloudEventV1 {
1296 CloudEventV1 {
1297 specversion: "1.0".into(),
1298 id: uuid::Uuid::new_v4().to_string(),
1299 source: source.to_string(),
1300 ty: "dev.cellos.events.cell.dns.v1.query_permitted".into(),
1301 datacontenttype: Some("application/json".into()),
1302 data: Some(dns_query_permitted_data_v1(qname, qtype, cell_id, resolver)),
1303 time: Some(time.to_string()),
1304 traceparent: None,
1305 }
1306}
1307
1308#[must_use]
1322pub fn dns_query_refused_data_v1(qname: &str, qtype: &str, cell_id: &str, reason: &str) -> Value {
1323 json!({
1324 "schemaVersion": "1.0.0",
1325 "queryName": qname,
1326 "queryType": qtype,
1327 "cellId": cell_id,
1328 "reason": reason,
1329 })
1330}
1331
1332pub fn cloud_event_v1_dns_query_refused(
1334 source: &str,
1335 time: &str,
1336 qname: &str,
1337 qtype: &str,
1338 cell_id: &str,
1339 reason: &str,
1340) -> CloudEventV1 {
1341 CloudEventV1 {
1342 specversion: "1.0".into(),
1343 id: uuid::Uuid::new_v4().to_string(),
1344 source: source.to_string(),
1345 ty: "dev.cellos.events.cell.dns.v1.query_refused".into(),
1346 datacontenttype: Some("application/json".into()),
1347 data: Some(dns_query_refused_data_v1(qname, qtype, cell_id, reason)),
1348 time: Some(time.to_string()),
1349 traceparent: None,
1350 }
1351}
1352
1353pub fn keyset_verified_data_v1(
1366 keyset_id: &str,
1367 payload_digest: &str,
1368 verified_signer_kid: &str,
1369 verified_at: &str,
1370 correlation_id: Option<&str>,
1371) -> Result<Value, serde_json::Error> {
1372 let mut m = Map::new();
1373 m.insert("schemaVersion".to_string(), json!("1.0.0"));
1374 m.insert("keysetId".to_string(), json!(keyset_id));
1375 m.insert("payloadDigest".to_string(), json!(payload_digest));
1376 m.insert("verifiedSignerKid".to_string(), json!(verified_signer_kid));
1377 m.insert("verifiedAt".to_string(), json!(verified_at));
1378 if let Some(cid) = correlation_id {
1379 m.insert("correlationId".to_string(), json!(cid));
1380 }
1381 Ok(Value::Object(m))
1382}
1383
1384pub fn cloud_event_v1_keyset_verified(
1390 source: &str,
1391 time: &str,
1392 keyset_id: &str,
1393 payload_digest: &str,
1394 verified_signer_kid: &str,
1395 verified_at: &str,
1396 correlation_id: Option<&str>,
1397) -> Result<CloudEventV1, serde_json::Error> {
1398 Ok(CloudEventV1 {
1399 specversion: "1.0".into(),
1400 id: uuid::Uuid::new_v4().to_string(),
1401 source: source.to_string(),
1402 ty: "dev.cellos.events.cell.trust.v1.keyset_verified".into(),
1403 datacontenttype: Some("application/json".into()),
1404 data: Some(keyset_verified_data_v1(
1405 keyset_id,
1406 payload_digest,
1407 verified_signer_kid,
1408 verified_at,
1409 correlation_id,
1410 )?),
1411 time: Some(time.to_string()),
1412 traceparent: None,
1413 })
1414}
1415
1416pub fn keyset_verification_failed_data_v1(
1432 attempted_keyset_path: &str,
1433 reason: &str,
1434 failed_at: &str,
1435 correlation_id: Option<&str>,
1436) -> Result<Value, serde_json::Error> {
1437 let mut m = Map::new();
1438 m.insert("schemaVersion".to_string(), json!("1.0.0"));
1439 m.insert(
1440 "attemptedKeysetPath".to_string(),
1441 json!(attempted_keyset_path),
1442 );
1443 m.insert("reason".to_string(), json!(reason));
1444 m.insert("failedAt".to_string(), json!(failed_at));
1445 if let Some(cid) = correlation_id {
1446 m.insert("correlationId".to_string(), json!(cid));
1447 }
1448 Ok(Value::Object(m))
1449}
1450
1451pub fn cloud_event_v1_keyset_verification_failed(
1453 source: &str,
1454 time: &str,
1455 attempted_keyset_path: &str,
1456 reason: &str,
1457 failed_at: &str,
1458 correlation_id: Option<&str>,
1459) -> Result<CloudEventV1, serde_json::Error> {
1460 Ok(CloudEventV1 {
1461 specversion: "1.0".into(),
1462 id: uuid::Uuid::new_v4().to_string(),
1463 source: source.to_string(),
1464 ty: "dev.cellos.events.cell.trust.v1.keyset_verification_failed".into(),
1465 datacontenttype: Some("application/json".into()),
1466 data: Some(keyset_verification_failed_data_v1(
1467 attempted_keyset_path,
1468 reason,
1469 failed_at,
1470 correlation_id,
1471 )?),
1472 time: Some(time.to_string()),
1473 traceparent: None,
1474 })
1475}
1476
1477pub fn network_flow_decision_data_v1(
1491 decision: &NetworkFlowDecision,
1492) -> Result<Value, serde_json::Error> {
1493 serde_json::to_value(decision)
1494}
1495
1496pub fn cloud_event_v1_network_flow_decision(
1507 source: &str,
1508 time: &str,
1509 decision: &NetworkFlowDecision,
1510) -> Result<CloudEventV1, serde_json::Error> {
1511 Ok(CloudEventV1 {
1512 specversion: "1.0".into(),
1513 id: uuid::Uuid::new_v4().to_string(),
1514 source: source.to_string(),
1515 ty: "dev.cellos.events.cell.observability.v1.network_flow_decision".into(),
1516 datacontenttype: Some("application/json".into()),
1517 data: Some(network_flow_decision_data_v1(decision)?),
1518 time: Some(time.to_string()),
1519 traceparent: None,
1520 })
1521}
1522
1523#[allow(clippy::too_many_arguments)]
1527pub fn observability_l7_egress_decision_data_v1(
1528 spec: &ExecutionCellSpec,
1529 cell_id: &str,
1530 run_id: Option<&str>,
1531 decision_id: &str,
1532 action: &str,
1533 sni_host: &str,
1534 policy_digest: &str,
1535 keyset_id: &str,
1536 issuer_kid: &str,
1537 reason_code: &str,
1538 rule_ref: Option<&str>,
1539 stream_id: Option<u32>,
1544) -> Result<Value, serde_json::Error> {
1545 let mut m = Map::new();
1546 m.insert("cellId".to_string(), json!(cell_id));
1547 m.insert("specId".to_string(), json!(&spec.id));
1548 if let Some(r) = run_id {
1549 m.insert("runId".to_string(), json!(r));
1550 }
1551 m.insert("decisionId".to_string(), json!(decision_id));
1552 m.insert("action".to_string(), json!(action));
1553 m.insert("sniHost".to_string(), json!(sni_host));
1554 m.insert("policyDigest".to_string(), json!(policy_digest));
1555 m.insert("keysetId".to_string(), json!(keyset_id));
1556 m.insert("issuerKid".to_string(), json!(issuer_kid));
1557 m.insert("reasonCode".to_string(), json!(reason_code));
1558 if let Some(rr) = rule_ref {
1559 m.insert("ruleRef".to_string(), json!(rr));
1560 }
1561 if let Some(sid) = stream_id {
1562 m.insert("streamId".to_string(), json!(sid));
1563 }
1564 if let Some(c) = &spec.correlation {
1565 m.insert("correlation".to_string(), serde_json::to_value(c)?);
1566 }
1567 Ok(Value::Object(m))
1568}
1569
1570pub fn observability_container_security_data_v1(
1589 cell_id: &str,
1590 run_id: Option<&str>,
1591 cap_eff: Option<&str>,
1592 cap_prm: Option<&str>,
1593 cap_bnd: Option<&str>,
1594 cap_amb: Option<&str>,
1595 cap_inh: Option<&str>,
1596) -> Value {
1597 let mut m = Map::new();
1598 m.insert("cellId".to_string(), json!(cell_id));
1599 if let Some(r) = run_id {
1600 m.insert("runId".to_string(), json!(r));
1601 }
1602 if let (Some(eff), Some(prm), Some(bnd), Some(amb), Some(inh)) =
1603 (cap_eff, cap_prm, cap_bnd, cap_amb, cap_inh)
1604 {
1605 m.insert("capEff".to_string(), json!(eff));
1606 m.insert("capPrm".to_string(), json!(prm));
1607 m.insert("capBnd".to_string(), json!(bnd));
1608 m.insert("capAmb".to_string(), json!(amb));
1609 m.insert("capInh".to_string(), json!(inh));
1610 let privileged = eff == "0000001fffffffff";
1612 m.insert("privileged".to_string(), json!(privileged));
1613 }
1614 Value::Object(m)
1615}
1616
1617pub fn compliance_summary_data_v1(
1636 spec: &ExecutionCellSpec,
1637 cell_id: &str,
1638 run_id: Option<&str>,
1639 command_exit_code: Option<i32>,
1640) -> Result<Value, serde_json::Error> {
1641 compliance_summary_data_v1_with_subjects(spec, cell_id, run_id, command_exit_code, &[])
1642}
1643
1644pub fn compliance_summary_data_v1_with_subjects(
1660 spec: &ExecutionCellSpec,
1661 cell_id: &str,
1662 run_id: Option<&str>,
1663 command_exit_code: Option<i32>,
1664 subject_urns: &[SubjectUrn],
1665) -> Result<Value, serde_json::Error> {
1666 let egress_rule_count = spec
1667 .authority
1668 .egress_rules
1669 .as_ref()
1670 .map(|v| v.len())
1671 .unwrap_or(0);
1672 let export_target_count = spec
1673 .export
1674 .as_ref()
1675 .and_then(|e| e.targets.as_ref())
1676 .map(|t| t.len())
1677 .unwrap_or(0);
1678 let resource_limits_present = spec.run.as_ref().and_then(|r| r.limits.as_ref()).is_some();
1679 let secret_delivery_mode = spec
1680 .run
1681 .as_ref()
1682 .map(|r| serde_json::to_value(&r.secret_delivery))
1683 .transpose()?
1684 .unwrap_or(serde_json::Value::String("env".into()));
1685
1686 let mut m = Map::new();
1687 m.insert("cellId".to_string(), json!(cell_id));
1688 m.insert("specId".to_string(), json!(&spec.id));
1689 m.insert(
1690 "lifetimeTtlSeconds".to_string(),
1691 json!(spec.lifetime.ttl_seconds),
1692 );
1693 m.insert("egressRuleCount".to_string(), json!(egress_rule_count));
1694 m.insert(
1695 "resourceLimitsPresent".to_string(),
1696 json!(resource_limits_present),
1697 );
1698 m.insert("secretDeliveryMode".to_string(), secret_delivery_mode);
1699 m.insert("exportTargetCount".to_string(), json!(export_target_count));
1700
1701 if let Some(policy) = &spec.policy {
1703 if let Some(id) = &policy.pack_id {
1704 m.insert("policyPackId".to_string(), json!(id));
1705 }
1706 if let Some(ver) = &policy.pack_version {
1707 m.insert("policyPackVersion".to_string(), json!(ver));
1708 }
1709 if let Some(digest) = &policy.bundle_digest {
1710 m.insert("policyBundleDigest".to_string(), json!(digest));
1711 }
1712 }
1713
1714 if let Some(placement) = &spec.placement {
1715 let mut placement_map = Map::new();
1716 if let Some(pool_id) = &placement.pool_id {
1717 placement_map.insert("poolId".to_string(), json!(pool_id));
1718 }
1719 if let Some(namespace) = &placement.kubernetes_namespace {
1720 placement_map.insert("kubernetesNamespace".to_string(), json!(namespace));
1721 }
1722 if let Some(queue_name) = &placement.queue_name {
1723 placement_map.insert("queueName".to_string(), json!(queue_name));
1724 }
1725 if !placement_map.is_empty() {
1726 m.insert("placement".to_string(), Value::Object(placement_map));
1727 }
1728 }
1729
1730 if let Some(code) = command_exit_code {
1731 m.insert("commandExitCode".to_string(), json!(code));
1732 }
1733 if let Some(r) = run_id {
1734 m.insert("runId".to_string(), json!(r));
1735 }
1736 if let Some(c) = &spec.correlation {
1737 m.insert("correlation".to_string(), serde_json::to_value(c)?);
1738 }
1739
1740 if !subject_urns.is_empty() {
1744 let urns: Vec<Value> = subject_urns.iter().map(|u| json!(u)).collect();
1745 m.insert("subjectUrns".to_string(), Value::Array(urns));
1746 }
1747
1748 Ok(Value::Object(m))
1749}
1750
1751pub fn policy_rejected_data_v1(
1759 spec: &ExecutionCellSpec,
1760 violations: &[PolicyViolation],
1761) -> Result<Value, serde_json::Error> {
1762 let violation_values: Vec<Value> = violations
1763 .iter()
1764 .map(|v| {
1765 json!({
1766 "rule": v.rule,
1767 "message": v.message,
1768 })
1769 })
1770 .collect();
1771
1772 let mut m = Map::new();
1773 m.insert("specId".to_string(), json!(&spec.id));
1774 m.insert("violationCount".to_string(), json!(violations.len()));
1775 m.insert("violations".to_string(), Value::Array(violation_values));
1776
1777 if let Some(policy) = &spec.policy {
1779 if let Some(id) = &policy.pack_id {
1780 m.insert("policyPackId".to_string(), json!(id));
1781 }
1782 if let Some(ver) = &policy.pack_version {
1783 m.insert("policyPackVersion".to_string(), json!(ver));
1784 }
1785 }
1786
1787 if let Some(c) = &spec.correlation {
1788 m.insert("correlation".to_string(), serde_json::to_value(c)?);
1789 }
1790 Ok(Value::Object(m))
1791}
1792
1793pub fn authz_rejected_data_v1(
1806 spec: &ExecutionCellSpec,
1807 reason: &str,
1808 message: &str,
1809 denied_pool_id: Option<&str>,
1810 denied_policy_pack_id: Option<&str>,
1811) -> Result<Value, serde_json::Error> {
1812 let mut m = Map::new();
1813 m.insert("specId".to_string(), json!(&spec.id));
1814 m.insert("reason".to_string(), json!(reason));
1815 m.insert("message".to_string(), json!(message));
1816
1817 let tenant_id = spec
1818 .correlation
1819 .as_ref()
1820 .and_then(|c| c.tenant_id.as_deref());
1821 if let Some(t) = tenant_id {
1822 m.insert("tenantId".to_string(), json!(t));
1823 m.insert("subject".to_string(), json!(t));
1828 }
1829
1830 if let Some(p) = denied_pool_id {
1831 m.insert("deniedPoolId".to_string(), json!(p));
1832 }
1833 if let Some(p) = denied_policy_pack_id {
1834 m.insert("deniedPolicyPackId".to_string(), json!(p));
1835 }
1836
1837 if let Some(c) = &spec.correlation {
1838 m.insert("correlation".to_string(), serde_json::to_value(c)?);
1839 }
1840 Ok(Value::Object(m))
1841}
1842
1843pub fn homeostasis_signal_data_v1(
1850 spec: &ExecutionCellSpec,
1851 cell_id: &str,
1852 run_id: Option<&str>,
1853 signal: &crate::HomeostasisSignal,
1854) -> Result<Value, serde_json::Error> {
1855 let mut m = Map::new();
1856 m.insert("cellId".to_string(), json!(cell_id));
1857 m.insert("specId".to_string(), json!(&spec.id));
1858 m.insert("specHash".to_string(), json!(&signal.spec_hash));
1859 m.insert(
1860 "declaredEgressRules".to_string(),
1861 json!(signal.declared_egress_rules),
1862 );
1863 m.insert(
1868 "exercisedEgressConnections".to_string(),
1869 match signal.exercised_egress_connections {
1870 Some(n) => json!(n),
1871 None => Value::Null,
1872 },
1873 );
1874 if let Some(reason) = &signal.exercised_egress_reason {
1875 m.insert("exercisedEgressReason".to_string(), json!(reason));
1876 }
1877 m.insert(
1878 "declaredMountPaths".to_string(),
1879 json!(signal.declared_mount_paths),
1880 );
1881 m.insert(
1882 "accessedMountPaths".to_string(),
1883 json!(signal.accessed_mount_paths),
1884 );
1885 m.insert(
1886 "declaredSecretCount".to_string(),
1887 json!(signal.declared_secret_count),
1888 );
1889 m.insert(
1890 "authorityEfficiency".to_string(),
1891 json!(signal.authority_efficiency),
1892 );
1893 m.insert(
1894 "recommendedRemovals".to_string(),
1895 serde_json::to_value(&signal.recommended_removals)?,
1896 );
1897 if let Some(r) = run_id {
1898 m.insert("runId".to_string(), json!(r));
1899 }
1900 if let Some(c) = &spec.correlation {
1901 m.insert("correlation".to_string(), serde_json::to_value(c)?);
1902 }
1903 Ok(Value::Object(m))
1904}
1905
1906pub fn homeostasis_violation_data_v1(
1921 cell_id: &str,
1922 declared_egress: u64,
1923 exercised_egress: u64,
1924 spec_hash: &str,
1925) -> Value {
1926 let mut m = Map::new();
1927 m.insert("cellId".to_string(), json!(cell_id));
1928 m.insert(
1929 "declaredEgressRuleCount".to_string(),
1930 json!(declared_egress),
1931 );
1932 m.insert(
1933 "exercisedEgressConnections".to_string(),
1934 json!(exercised_egress),
1935 );
1936 m.insert(
1939 "overage".to_string(),
1940 json!(exercised_egress.saturating_sub(declared_egress)),
1941 );
1942 m.insert("specHash".to_string(), json!(spec_hash));
1943 m.insert("severity".to_string(), json!("critical"));
1944 Value::Object(m)
1945}
1946
1947#[allow(clippy::too_many_arguments)]
1972pub fn observability_host_fc_metrics_data_v1(
1973 spec: &ExecutionCellSpec,
1974 cell_id: &str,
1975 run_id: Option<&str>,
1976 spec_signature_hash: &str,
1977 sampled_at_unix_ms: u64,
1978 fc_socket_path: &str,
1979 vcpu_exits_total: Option<u64>,
1980 vsock_tx_bytes: Option<u64>,
1981 vsock_rx_bytes: Option<u64>,
1982 block_read_ops: Option<u64>,
1983 block_write_ops: Option<u64>,
1984 sample_error: Option<&str>,
1985) -> Result<Value, serde_json::Error> {
1986 let mut m = Map::new();
1987 m.insert("cellId".to_string(), json!(cell_id));
1988 m.insert("specId".to_string(), json!(&spec.id));
1989 m.insert("specSignatureHash".to_string(), json!(spec_signature_hash));
1990 m.insert("sampledAtUnixMs".to_string(), json!(sampled_at_unix_ms));
1991 m.insert("fcSocketPath".to_string(), json!(fc_socket_path));
1992 if let Some(v) = vcpu_exits_total {
1993 m.insert("vcpuExitsTotal".to_string(), json!(v));
1994 }
1995 if let Some(v) = vsock_tx_bytes {
1996 m.insert("vsockTxBytes".to_string(), json!(v));
1997 }
1998 if let Some(v) = vsock_rx_bytes {
1999 m.insert("vsockRxBytes".to_string(), json!(v));
2000 }
2001 if let Some(v) = block_read_ops {
2002 m.insert("blockReadOps".to_string(), json!(v));
2003 }
2004 if let Some(v) = block_write_ops {
2005 m.insert("blockWriteOps".to_string(), json!(v));
2006 }
2007 if let Some(e) = sample_error {
2008 m.insert("sampleError".to_string(), json!(e));
2009 }
2010 if let Some(r) = run_id {
2011 m.insert("runId".to_string(), json!(r));
2012 }
2013 if let Some(c) = &spec.correlation {
2014 m.insert("correlation".to_string(), serde_json::to_value(c)?);
2015 }
2016 Ok(Value::Object(m))
2017}
2018
2019#[derive(Debug, Default, Clone)]
2022pub struct CgroupSample<'a> {
2023 pub memory_events: Option<&'a [(&'a str, u64)]>,
2024 pub cpu_stat: Option<&'a [(&'a str, u64)]>,
2025 pub pids_events: Option<&'a [(&'a str, u64)]>,
2026}
2027
2028fn cgroup_section(keys: &[&str], pairs: Option<&[(&str, u64)]>) -> Option<Value> {
2040 let pairs = pairs?;
2041 let mut section = Map::new();
2042 for (k, v) in pairs {
2043 if keys.contains(k) {
2044 section.insert((*k).to_string(), json!(v));
2045 }
2046 }
2047 if section.is_empty() {
2048 None
2049 } else {
2050 Some(Value::Object(section))
2051 }
2052}
2053
2054#[allow(clippy::too_many_arguments)]
2056pub fn observability_host_cgroup_data_v1(
2057 spec: &ExecutionCellSpec,
2058 cell_id: &str,
2059 run_id: Option<&str>,
2060 spec_signature_hash: &str,
2061 sampled_at_unix_ms: u64,
2062 cgroup_path: &str,
2063 sample: &CgroupSample<'_>,
2064 sample_error: Option<&str>,
2065) -> Result<Value, serde_json::Error> {
2066 const MEM_KEYS: &[&str] = &["low", "high", "max", "oom", "oomKill"];
2067 const CPU_KEYS: &[&str] = &[
2068 "usageUsec",
2069 "userUsec",
2070 "systemUsec",
2071 "nrPeriods",
2072 "nrThrottled",
2073 "throttledUsec",
2074 ];
2075 const PIDS_KEYS: &[&str] = &["max"];
2076
2077 let mut m = Map::new();
2078 m.insert("cellId".to_string(), json!(cell_id));
2079 m.insert("specId".to_string(), json!(&spec.id));
2080 m.insert("specSignatureHash".to_string(), json!(spec_signature_hash));
2081 m.insert("sampledAtUnixMs".to_string(), json!(sampled_at_unix_ms));
2082 m.insert("cgroupPath".to_string(), json!(cgroup_path));
2083 if let Some(v) = cgroup_section(MEM_KEYS, sample.memory_events) {
2084 m.insert("memoryEvents".to_string(), v);
2085 }
2086 if let Some(v) = cgroup_section(CPU_KEYS, sample.cpu_stat) {
2087 m.insert("cpuStat".to_string(), v);
2088 }
2089 if let Some(v) = cgroup_section(PIDS_KEYS, sample.pids_events) {
2090 m.insert("pidsEvents".to_string(), v);
2091 }
2092 if let Some(e) = sample_error {
2093 m.insert("sampleError".to_string(), json!(e));
2094 }
2095 if let Some(r) = run_id {
2096 m.insert("runId".to_string(), json!(r));
2097 }
2098 if let Some(c) = &spec.correlation {
2099 m.insert("correlation".to_string(), serde_json::to_value(c)?);
2100 }
2101 Ok(Value::Object(m))
2102}
2103
2104#[derive(Debug, Clone)]
2106pub struct NftRuleCounter<'a> {
2107 pub rule_handle: &'a str,
2108 pub verdict: Option<&'a str>,
2109 pub packets: u64,
2110 pub bytes: u64,
2111 pub r#match: Option<&'a str>,
2112}
2113
2114#[allow(clippy::too_many_arguments)]
2121pub fn observability_host_nftables_data_v1(
2122 spec: &ExecutionCellSpec,
2123 cell_id: &str,
2124 run_id: Option<&str>,
2125 spec_signature_hash: &str,
2126 sampled_at_unix_ms: u64,
2127 table_name: &str,
2128 rule_counters: &[NftRuleCounter<'_>],
2129 sample_error: Option<&str>,
2130) -> Result<Value, serde_json::Error> {
2131 let mut m = Map::new();
2132 m.insert("cellId".to_string(), json!(cell_id));
2133 m.insert("specId".to_string(), json!(&spec.id));
2134 m.insert("specSignatureHash".to_string(), json!(spec_signature_hash));
2135 m.insert("sampledAtUnixMs".to_string(), json!(sampled_at_unix_ms));
2136 m.insert("tableName".to_string(), json!(table_name));
2137 let counters: Vec<Value> = rule_counters
2138 .iter()
2139 .map(|c| {
2140 let mut row = Map::new();
2141 row.insert("ruleHandle".to_string(), json!(c.rule_handle));
2142 if let Some(v) = c.verdict {
2143 row.insert("verdict".to_string(), json!(v));
2144 }
2145 row.insert("packets".to_string(), json!(c.packets));
2146 row.insert("bytes".to_string(), json!(c.bytes));
2147 if let Some(mt) = c.r#match {
2148 row.insert("match".to_string(), json!(mt));
2149 }
2150 Value::Object(row)
2151 })
2152 .collect();
2153 m.insert("ruleCounters".to_string(), Value::Array(counters));
2154 if let Some(e) = sample_error {
2155 m.insert("sampleError".to_string(), json!(e));
2156 }
2157 if let Some(r) = run_id {
2158 m.insert("runId".to_string(), json!(r));
2159 }
2160 if let Some(c) = &spec.correlation {
2161 m.insert("correlation".to_string(), serde_json::to_value(c)?);
2162 }
2163 Ok(Value::Object(m))
2164}
2165
2166#[derive(Debug, Default, Clone, Copy)]
2168pub struct TapStats {
2169 pub rx_packets: Option<u64>,
2170 pub tx_packets: Option<u64>,
2171 pub rx_bytes: Option<u64>,
2172 pub tx_bytes: Option<u64>,
2173 pub rx_errors: Option<u64>,
2174 pub tx_errors: Option<u64>,
2175 pub rx_dropped: Option<u64>,
2176 pub tx_dropped: Option<u64>,
2177}
2178
2179#[allow(clippy::too_many_arguments)]
2186pub fn observability_host_tap_data_v1(
2187 spec: &ExecutionCellSpec,
2188 cell_id: &str,
2189 run_id: Option<&str>,
2190 spec_signature_hash: &str,
2191 sampled_at_unix_ms: u64,
2192 tap_name: &str,
2193 link_state: &str,
2194 stats: &TapStats,
2195 sample_error: Option<&str>,
2196) -> Result<Value, serde_json::Error> {
2197 let mut m = Map::new();
2198 m.insert("cellId".to_string(), json!(cell_id));
2199 m.insert("specId".to_string(), json!(&spec.id));
2200 m.insert("specSignatureHash".to_string(), json!(spec_signature_hash));
2201 m.insert("sampledAtUnixMs".to_string(), json!(sampled_at_unix_ms));
2202 m.insert("tapName".to_string(), json!(tap_name));
2203 m.insert("linkState".to_string(), json!(link_state));
2204 if let Some(v) = stats.rx_packets {
2205 m.insert("rxPackets".to_string(), json!(v));
2206 }
2207 if let Some(v) = stats.tx_packets {
2208 m.insert("txPackets".to_string(), json!(v));
2209 }
2210 if let Some(v) = stats.rx_bytes {
2211 m.insert("rxBytes".to_string(), json!(v));
2212 }
2213 if let Some(v) = stats.tx_bytes {
2214 m.insert("txBytes".to_string(), json!(v));
2215 }
2216 if let Some(v) = stats.rx_errors {
2217 m.insert("rxErrors".to_string(), json!(v));
2218 }
2219 if let Some(v) = stats.tx_errors {
2220 m.insert("txErrors".to_string(), json!(v));
2221 }
2222 if let Some(v) = stats.rx_dropped {
2223 m.insert("rxDropped".to_string(), json!(v));
2224 }
2225 if let Some(v) = stats.tx_dropped {
2226 m.insert("txDropped".to_string(), json!(v));
2227 }
2228 if let Some(e) = sample_error {
2229 m.insert("sampleError".to_string(), json!(e));
2230 }
2231 if let Some(r) = run_id {
2232 m.insert("runId".to_string(), json!(r));
2233 }
2234 if let Some(c) = &spec.correlation {
2235 m.insert("correlation".to_string(), serde_json::to_value(c)?);
2236 }
2237 Ok(Value::Object(m))
2238}
2239
2240#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
2243#[serde(rename_all = "snake_case")]
2244pub enum ResidueClass {
2245 None,
2247 DocumentedException,
2250}
2251
2252#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
2258#[serde(rename_all = "camelCase")]
2259pub enum LifecycleResidueClass {
2260 None,
2261 MetadataOnly,
2262 DocumentedException,
2263 Unknown,
2264}
2265
2266#[derive(Debug, Default, Clone)]
2275pub struct EvidenceBundleRefs<'a> {
2276 pub started_event_ref: &'a str,
2278 pub cell_destroyed_event_ref: &'a str,
2280 pub command_completed_event_ref: Option<&'a str>,
2281 pub spawned_event_refs: &'a [&'a str],
2282 pub fc_metrics_event_refs: &'a [&'a str],
2283 pub cgroup_event_refs: &'a [&'a str],
2284 pub nftables_event_refs: &'a [&'a str],
2285 pub tap_event_refs: &'a [&'a str],
2286 pub homeostasis_event_ref: Option<&'a str>,
2287 pub compliance_summary_event_ref: Option<&'a str>,
2288 pub guest_event_refs: &'a [(&'a str, &'a str, &'a str)],
2291 pub residue_exception: Option<&'a str>,
2293}
2294
2295pub fn evidence_bundle_emitted_data_v1(
2310 spec: &ExecutionCellSpec,
2311 cell_id: &str,
2312 run_id: Option<&str>,
2313 spec_signature_hash: &str,
2314 emitted_at_unix_ms: u64,
2315 residue_class: ResidueClass,
2316 refs: &EvidenceBundleRefs<'_>,
2317) -> Result<Value, serde_json::Error> {
2318 let mut m = Map::new();
2319 m.insert("cellId".to_string(), json!(cell_id));
2320 m.insert("specId".to_string(), json!(&spec.id));
2321 m.insert("specSignatureHash".to_string(), json!(spec_signature_hash));
2322 m.insert("emittedAtUnixMs".to_string(), json!(emitted_at_unix_ms));
2323
2324 let mut lifecycle = Map::new();
2325 lifecycle.insert("started".to_string(), json!(refs.started_event_ref));
2326 lifecycle.insert(
2327 "destroyed".to_string(),
2328 json!(refs.cell_destroyed_event_ref),
2329 );
2330 if let Some(cc) = refs.command_completed_event_ref {
2331 lifecycle.insert("commandCompleted".to_string(), json!(cc));
2332 }
2333 if !refs.spawned_event_refs.is_empty() {
2334 lifecycle.insert("spawned".to_string(), json!(refs.spawned_event_refs));
2335 }
2336 m.insert("lifecycleEventRefs".to_string(), Value::Object(lifecycle));
2337
2338 m.insert(
2339 "cellDestroyedEventRef".to_string(),
2340 json!(refs.cell_destroyed_event_ref),
2341 );
2342 m.insert(
2343 "residueClass".to_string(),
2344 serde_json::to_value(residue_class)?,
2345 );
2346 if let Some(rx) = refs.residue_exception {
2347 m.insert("residueException".to_string(), json!(rx));
2348 }
2349
2350 let any_host = !refs.fc_metrics_event_refs.is_empty()
2351 || !refs.cgroup_event_refs.is_empty()
2352 || !refs.nftables_event_refs.is_empty()
2353 || !refs.tap_event_refs.is_empty();
2354 if any_host {
2355 let mut host = Map::new();
2356 if !refs.fc_metrics_event_refs.is_empty() {
2357 host.insert("fcMetrics".to_string(), json!(refs.fc_metrics_event_refs));
2358 }
2359 if !refs.cgroup_event_refs.is_empty() {
2360 host.insert("cgroup".to_string(), json!(refs.cgroup_event_refs));
2361 }
2362 if !refs.nftables_event_refs.is_empty() {
2363 host.insert("nftables".to_string(), json!(refs.nftables_event_refs));
2364 }
2365 if !refs.tap_event_refs.is_empty() {
2366 host.insert("tap".to_string(), json!(refs.tap_event_refs));
2367 }
2368 m.insert("hostProbeEventRefs".to_string(), Value::Object(host));
2369 }
2370
2371 if !refs.guest_event_refs.is_empty() {
2372 let rows: Vec<Value> = refs
2373 .guest_event_refs
2374 .iter()
2375 .map(|(id, ty, rc)| {
2376 let mut row = Map::new();
2377 row.insert("eventId".to_string(), json!(id));
2378 row.insert("eventType".to_string(), json!(ty));
2379 row.insert("ruleClass".to_string(), json!(rc));
2380 Value::Object(row)
2381 })
2382 .collect();
2383 m.insert("guestEventRefs".to_string(), Value::Array(rows));
2384 }
2385
2386 if let Some(h) = refs.homeostasis_event_ref {
2387 m.insert("homeostasisEventRef".to_string(), json!(h));
2388 }
2389 if let Some(c) = refs.compliance_summary_event_ref {
2390 m.insert("complianceSummaryEventRef".to_string(), json!(c));
2391 }
2392 if let Some(r) = run_id {
2393 m.insert("runId".to_string(), json!(r));
2394 }
2395 if let Some(c) = &spec.correlation {
2396 m.insert("correlation".to_string(), serde_json::to_value(c)?);
2397 }
2398 Ok(Value::Object(m))
2399}
2400
2401pub fn cortex_dispatched_data_v1(pack_id: &str, cell_id: &str, doctrine_refs: &[String]) -> Value {
2410 let mut m = Map::new();
2411 m.insert("packId".to_string(), json!(pack_id));
2412 m.insert("cellId".to_string(), json!(cell_id));
2413 m.insert("doctrineRefs".to_string(), json!(doctrine_refs));
2414 m.insert("bridgeVersion".to_string(), json!("1.0"));
2415 Value::Object(m)
2416}
2417
2418pub fn firecracker_pool_event_data_v1(cell_id: &str, pool_hit: bool, slot_count: usize) -> Value {
2428 let mut m = Map::new();
2429 m.insert("cellId".to_string(), json!(cell_id));
2430 m.insert("poolHit".to_string(), json!(pool_hit));
2431 m.insert("slotCount".to_string(), json!(slot_count));
2432 Value::Object(m)
2433}
2434
2435pub fn cloud_event_v1_cortex_dispatched(
2442 source: &str,
2443 time: &str,
2444 pack_id: &str,
2445 cell_id: &str,
2446 doctrine_refs: &[String],
2447) -> CloudEventV1 {
2448 CloudEventV1 {
2449 specversion: "1.0".into(),
2450 id: uuid::Uuid::new_v4().to_string(),
2451 source: source.to_string(),
2452 ty: "dev.cellos.events.cell.cortex.v1.dispatched".into(),
2453 datacontenttype: Some("application/json".into()),
2454 data: Some(cortex_dispatched_data_v1(pack_id, cell_id, doctrine_refs)),
2455 time: Some(time.to_string()),
2456 traceparent: None,
2457 }
2458}
2459
2460pub fn cloud_event_v1_firecracker_pool_checkout(
2469 source: &str,
2470 time: &str,
2471 cell_id: &str,
2472 pool_hit: bool,
2473 slot_count: usize,
2474) -> CloudEventV1 {
2475 CloudEventV1 {
2476 specversion: "1.0".into(),
2477 id: uuid::Uuid::new_v4().to_string(),
2478 source: source.to_string(),
2479 ty: "dev.cellos.events.cell.firecracker.v1.pool_checkout".into(),
2480 datacontenttype: Some("application/json".into()),
2481 data: Some(firecracker_pool_event_data_v1(
2482 cell_id, pool_hit, slot_count,
2483 )),
2484 time: Some(time.to_string()),
2485 traceparent: None,
2486 }
2487}
2488
2489pub const FORMATION_CREATED_TYPE: &str = "dev.cellos.events.cell.formation.v1.created";
2506
2507pub const FORMATION_LAUNCHING_TYPE: &str = "dev.cellos.events.cell.formation.v1.launching";
2509
2510pub const FORMATION_RUNNING_TYPE: &str = "dev.cellos.events.cell.formation.v1.running";
2512
2513pub const FORMATION_DEGRADED_TYPE: &str = "dev.cellos.events.cell.formation.v1.degraded";
2515
2516pub const FORMATION_COMPLETED_TYPE: &str = "dev.cellos.events.cell.formation.v1.completed";
2518
2519pub const FORMATION_FAILED_TYPE: &str = "dev.cellos.events.cell.formation.v1.failed";
2521
2522fn formation_data_v1(
2540 formation_id: &str,
2541 formation_name: &str,
2542 cell_count: u32,
2543 failed_cell_ids: &[String],
2544 reason: Option<&str>,
2545) -> Value {
2546 let mut m = Map::new();
2547 m.insert("formationId".to_string(), json!(formation_id));
2548 m.insert("formationName".to_string(), json!(formation_name));
2549 m.insert("cellCount".to_string(), json!(cell_count));
2550 m.insert("failedCellIds".to_string(), json!(failed_cell_ids));
2551 if let Some(r) = reason {
2552 m.insert("reason".to_string(), json!(r));
2553 }
2554 Value::Object(m)
2555}
2556
2557pub fn cloud_event_v1_formation_created(
2564 source: &str,
2565 time: &str,
2566 formation_id: &str,
2567 formation_name: &str,
2568 cell_count: u32,
2569 failed_cell_ids: &[String],
2570 reason: Option<&str>,
2571) -> CloudEventV1 {
2572 CloudEventV1 {
2573 specversion: "1.0".into(),
2574 id: uuid::Uuid::new_v4().to_string(),
2575 source: source.to_string(),
2576 ty: FORMATION_CREATED_TYPE.to_string(),
2577 datacontenttype: Some("application/json".into()),
2578 data: Some(formation_data_v1(
2579 formation_id,
2580 formation_name,
2581 cell_count,
2582 failed_cell_ids,
2583 reason,
2584 )),
2585 time: Some(time.to_string()),
2586 traceparent: None,
2587 }
2588}
2589
2590pub fn cloud_event_v1_formation_launching(
2598 source: &str,
2599 time: &str,
2600 formation_id: &str,
2601 formation_name: &str,
2602 cell_count: u32,
2603 failed_cell_ids: &[String],
2604 reason: Option<&str>,
2605) -> CloudEventV1 {
2606 CloudEventV1 {
2607 specversion: "1.0".into(),
2608 id: uuid::Uuid::new_v4().to_string(),
2609 source: source.to_string(),
2610 ty: FORMATION_LAUNCHING_TYPE.to_string(),
2611 datacontenttype: Some("application/json".into()),
2612 data: Some(formation_data_v1(
2613 formation_id,
2614 formation_name,
2615 cell_count,
2616 failed_cell_ids,
2617 reason,
2618 )),
2619 time: Some(time.to_string()),
2620 traceparent: None,
2621 }
2622}
2623
2624pub fn cloud_event_v1_formation_running(
2630 source: &str,
2631 time: &str,
2632 formation_id: &str,
2633 formation_name: &str,
2634 cell_count: u32,
2635 failed_cell_ids: &[String],
2636 reason: Option<&str>,
2637) -> CloudEventV1 {
2638 CloudEventV1 {
2639 specversion: "1.0".into(),
2640 id: uuid::Uuid::new_v4().to_string(),
2641 source: source.to_string(),
2642 ty: FORMATION_RUNNING_TYPE.to_string(),
2643 datacontenttype: Some("application/json".into()),
2644 data: Some(formation_data_v1(
2645 formation_id,
2646 formation_name,
2647 cell_count,
2648 failed_cell_ids,
2649 reason,
2650 )),
2651 time: Some(time.to_string()),
2652 traceparent: None,
2653 }
2654}
2655
2656pub fn cloud_event_v1_formation_degraded(
2663 source: &str,
2664 time: &str,
2665 formation_id: &str,
2666 formation_name: &str,
2667 cell_count: u32,
2668 failed_cell_ids: &[String],
2669 reason: Option<&str>,
2670) -> CloudEventV1 {
2671 CloudEventV1 {
2672 specversion: "1.0".into(),
2673 id: uuid::Uuid::new_v4().to_string(),
2674 source: source.to_string(),
2675 ty: FORMATION_DEGRADED_TYPE.to_string(),
2676 datacontenttype: Some("application/json".into()),
2677 data: Some(formation_data_v1(
2678 formation_id,
2679 formation_name,
2680 cell_count,
2681 failed_cell_ids,
2682 reason,
2683 )),
2684 time: Some(time.to_string()),
2685 traceparent: None,
2686 }
2687}
2688
2689pub fn cloud_event_v1_formation_completed(
2696 source: &str,
2697 time: &str,
2698 formation_id: &str,
2699 formation_name: &str,
2700 cell_count: u32,
2701 failed_cell_ids: &[String],
2702 reason: Option<&str>,
2703) -> CloudEventV1 {
2704 CloudEventV1 {
2705 specversion: "1.0".into(),
2706 id: uuid::Uuid::new_v4().to_string(),
2707 source: source.to_string(),
2708 ty: FORMATION_COMPLETED_TYPE.to_string(),
2709 datacontenttype: Some("application/json".into()),
2710 data: Some(formation_data_v1(
2711 formation_id,
2712 formation_name,
2713 cell_count,
2714 failed_cell_ids,
2715 reason,
2716 )),
2717 time: Some(time.to_string()),
2718 traceparent: None,
2719 }
2720}
2721
2722pub fn cloud_event_v1_formation_failed(
2729 source: &str,
2730 time: &str,
2731 formation_id: &str,
2732 formation_name: &str,
2733 cell_count: u32,
2734 failed_cell_ids: &[String],
2735 reason: Option<&str>,
2736) -> CloudEventV1 {
2737 CloudEventV1 {
2738 specversion: "1.0".into(),
2739 id: uuid::Uuid::new_v4().to_string(),
2740 source: source.to_string(),
2741 ty: FORMATION_FAILED_TYPE.to_string(),
2742 datacontenttype: Some("application/json".into()),
2743 data: Some(formation_data_v1(
2744 formation_id,
2745 formation_name,
2746 cell_count,
2747 failed_cell_ids,
2748 reason,
2749 )),
2750 time: Some(time.to_string()),
2751 traceparent: None,
2752 }
2753}
2754
2755#[cfg(test)]
2756mod tests {
2757 use super::*;
2758 use crate::{
2759 Correlation, ExecutionCellDocument, ExportReceipt, ExportReceiptTargetKind, Lifetime,
2760 WorkloadIdentity, WorkloadIdentityKind,
2761 };
2762
2763 #[test]
2764 fn lifecycle_started_matches_example_shape() {
2765 let raw =
2766 include_str!("../../../contracts/examples/execution-cell-ci-correlation.valid.json");
2767 let doc: ExecutionCellDocument = serde_json::from_str(raw).unwrap();
2768 let expected: Value = serde_json::from_str(include_str!(
2769 "../../../contracts/examples/cell-lifecycle-started-data.valid.json"
2770 ))
2771 .unwrap();
2772 let data = lifecycle_started_data_v1(
2773 &doc.spec,
2774 "host-cell-abc123",
2775 Some("run-2026-04-06-001"),
2776 None,
2777 None,
2778 None,
2779 None,
2780 None,
2781 None,
2782 None,
2783 )
2784 .unwrap();
2785 assert_eq!(data, expected);
2786 }
2787
2788 #[test]
2789 fn lifecycle_started_without_correlation() {
2790 let spec = ExecutionCellSpec {
2791 id: "s1".into(),
2792 correlation: None,
2793 ingress: None,
2794 environment: None,
2795 placement: None,
2796 policy: None,
2797 identity: None,
2798 run: None,
2799 authority: Default::default(),
2800 lifetime: Lifetime { ttl_seconds: 60 },
2801 export: None,
2802 telemetry: None,
2803 };
2804 let data =
2805 lifecycle_started_data_v1(&spec, "c1", None, None, None, None, None, None, None, None)
2806 .unwrap();
2807 assert!(!data.as_object().unwrap().contains_key("correlation"));
2808 assert!(!data.as_object().unwrap().contains_key("runId"));
2809 assert!(!data.as_object().unwrap().contains_key("derivationVerified"));
2810 assert!(!data.as_object().unwrap().contains_key("roleRoot"));
2811 assert!(!data.as_object().unwrap().contains_key("parentRunId"));
2812 assert!(!data.as_object().unwrap().contains_key("kernelDigestSha256"));
2813 assert!(!data.as_object().unwrap().contains_key("rootfsDigestSha256"));
2814 assert!(!data
2815 .as_object()
2816 .unwrap()
2817 .contains_key("firecrackerDigestSha256"));
2818 }
2819
2820 #[test]
2821 fn lifecycle_started_partial_correlation_serializes() {
2822 let spec = ExecutionCellSpec {
2823 id: "s2".into(),
2824 correlation: Some(Correlation {
2825 platform: Some("custom".into()),
2826 external_run_id: None,
2827 external_job_id: None,
2828 tenant_id: None,
2829 labels: None,
2830 correlation_id: None,
2831 }),
2832 ingress: None,
2833 environment: None,
2834 placement: None,
2835 policy: None,
2836 identity: None,
2837 run: None,
2838 authority: Default::default(),
2839 lifetime: Lifetime { ttl_seconds: 1 },
2840 export: None,
2841 telemetry: None,
2842 };
2843 let data =
2844 lifecycle_started_data_v1(&spec, "c2", None, None, None, None, None, None, None, None)
2845 .unwrap();
2846 assert_eq!(data["correlation"]["platform"], "custom");
2847 }
2848
2849 #[test]
2850 fn lifecycle_started_with_derivation_fields_emits_them() {
2851 let spec = ExecutionCellSpec {
2852 id: "deriv-1".into(),
2853 correlation: None,
2854 ingress: None,
2855 environment: None,
2856 placement: None,
2857 policy: None,
2858 identity: None,
2859 run: None,
2860 authority: Default::default(),
2861 lifetime: Lifetime { ttl_seconds: 60 },
2862 export: None,
2863 telemetry: None,
2864 };
2865 let data = lifecycle_started_data_v1(
2866 &spec,
2867 "cell-deriv",
2868 Some("run-deriv-1"),
2869 Some(false),
2870 Some("role-prod-ci"),
2871 Some("run-parent-001"),
2872 Some("abc123def456"),
2873 None,
2874 None,
2875 None,
2876 )
2877 .unwrap();
2878 assert_eq!(data["derivationVerified"], false);
2879 assert_eq!(data["roleRoot"], "role-prod-ci");
2880 assert_eq!(data["parentRunId"], "run-parent-001");
2881 }
2882
2883 #[test]
2884 fn lifecycle_destroyed_succeeded_shape() {
2885 let raw =
2886 include_str!("../../../contracts/examples/execution-cell-ci-correlation.valid.json");
2887 let doc: ExecutionCellDocument = serde_json::from_str(raw).unwrap();
2888 let data = lifecycle_destroyed_data_v1(
2889 &doc.spec,
2890 "host-xyz",
2891 Some("run-test"),
2892 LifecycleDestroyOutcome::Succeeded,
2893 None,
2894 None,
2895 None,
2896 None,
2897 )
2898 .unwrap();
2899 assert_eq!(data["outcome"], "succeeded");
2900 assert!(!data.as_object().unwrap().contains_key("reason"));
2901 assert!(
2902 !data.as_object().unwrap().contains_key("terminalState"),
2903 "terminalState must be omitted when None for backward-compat"
2904 );
2905 assert!(
2906 !data.as_object().unwrap().contains_key("evidenceBundleRef"),
2907 "evidenceBundleRef must be omitted when None for backward-compat"
2908 );
2909 assert!(
2910 !data.as_object().unwrap().contains_key("residueClass"),
2911 "residueClass must be omitted when None for backward-compat"
2912 );
2913 assert_eq!(data["ttlSeconds"], 3600);
2914 }
2915
2916 #[test]
2917 fn lifecycle_destroyed_failed_includes_reason() {
2918 let spec = ExecutionCellSpec {
2919 id: "s1".into(),
2920 correlation: None,
2921 ingress: None,
2922 environment: None,
2923 placement: None,
2924 policy: None,
2925 identity: None,
2926 run: None,
2927 authority: Default::default(),
2928 lifetime: Lifetime { ttl_seconds: 60 },
2929 export: None,
2930 telemetry: None,
2931 };
2932 let data = lifecycle_destroyed_data_v1(
2933 &spec,
2934 "c1",
2935 None,
2936 LifecycleDestroyOutcome::Failed,
2937 Some("secret resolve: denied"),
2938 None,
2939 None,
2940 None,
2941 )
2942 .unwrap();
2943 assert_eq!(data["outcome"], "failed");
2944 assert_eq!(data["reason"], "secret resolve: denied");
2945 }
2946
2947 #[test]
2948 fn lifecycle_destroyed_terminal_state_clean_serializes() {
2949 let spec = ExecutionCellSpec {
2950 id: "term-clean".into(),
2951 correlation: None,
2952 ingress: None,
2953 environment: None,
2954 placement: None,
2955 policy: None,
2956 identity: None,
2957 run: None,
2958 authority: Default::default(),
2959 lifetime: Lifetime { ttl_seconds: 60 },
2960 export: None,
2961 telemetry: None,
2962 };
2963 let data = lifecycle_destroyed_data_v1(
2964 &spec,
2965 "c-clean",
2966 None,
2967 LifecycleDestroyOutcome::Succeeded,
2968 None,
2969 Some(LifecycleTerminalState::Clean),
2970 None,
2971 None,
2972 )
2973 .unwrap();
2974 assert_eq!(data["terminalState"], "clean");
2975 }
2976
2977 #[test]
2978 fn lifecycle_destroyed_terminal_state_forced_serializes() {
2979 let spec = ExecutionCellSpec {
2980 id: "term-forced".into(),
2981 correlation: None,
2982 ingress: None,
2983 environment: None,
2984 placement: None,
2985 policy: None,
2986 identity: None,
2987 run: None,
2988 authority: Default::default(),
2989 lifetime: Lifetime { ttl_seconds: 60 },
2990 export: None,
2991 telemetry: None,
2992 };
2993 let data = lifecycle_destroyed_data_v1(
2994 &spec,
2995 "c-forced",
2996 None,
2997 LifecycleDestroyOutcome::Failed,
2998 Some("in-VM exit bridge: vsock closed"),
2999 Some(LifecycleTerminalState::Forced),
3000 None,
3001 None,
3002 )
3003 .unwrap();
3004 assert_eq!(data["terminalState"], "forced");
3005 assert_eq!(data["outcome"], "failed");
3006 }
3007
3008 #[test]
3009 fn lifecycle_destroyed_evidence_bundle_and_residue_class_serialize_when_populated() {
3010 let spec = ExecutionCellSpec {
3011 id: "f5-populated".into(),
3012 correlation: None,
3013 ingress: None,
3014 environment: None,
3015 placement: None,
3016 policy: None,
3017 identity: None,
3018 run: None,
3019 authority: Default::default(),
3020 lifetime: Lifetime { ttl_seconds: 60 },
3021 export: None,
3022 telemetry: None,
3023 };
3024 let bundle = SubjectUrn::parse("urn:cellos:evidence-bundle:run-1").unwrap();
3025 let data = lifecycle_destroyed_data_v1(
3026 &spec,
3027 "c-f5",
3028 Some("run-1"),
3029 LifecycleDestroyOutcome::Succeeded,
3030 None,
3031 None,
3032 Some(&bundle),
3033 Some(ResidueClass::DocumentedException),
3034 )
3035 .unwrap();
3036 assert_eq!(
3037 data["evidenceBundleRef"],
3038 "urn:cellos:evidence-bundle:run-1"
3039 );
3040 assert_eq!(data["residueClass"], "documented_exception");
3041 }
3042
3043 #[test]
3044 fn lifecycle_destroyed_evidence_bundle_and_residue_class_omitted_when_none() {
3045 let spec = ExecutionCellSpec {
3046 id: "f5-omitted".into(),
3047 correlation: None,
3048 ingress: None,
3049 environment: None,
3050 placement: None,
3051 policy: None,
3052 identity: None,
3053 run: None,
3054 authority: Default::default(),
3055 lifetime: Lifetime { ttl_seconds: 60 },
3056 export: None,
3057 telemetry: None,
3058 };
3059 let data = lifecycle_destroyed_data_v1(
3060 &spec,
3061 "c-f5-omit",
3062 None,
3063 LifecycleDestroyOutcome::Succeeded,
3064 None,
3065 None,
3066 None,
3067 None,
3068 )
3069 .unwrap();
3070 let obj = data.as_object().unwrap();
3071 assert!(
3072 !obj.contains_key("evidenceBundleRef"),
3073 "evidenceBundleRef must be omitted when None"
3074 );
3075 assert!(
3076 !obj.contains_key("residueClass"),
3077 "residueClass must be omitted when None"
3078 );
3079 }
3080
3081 #[test]
3082 fn identity_materialized_matches_example_shape() {
3083 let raw =
3084 include_str!("../../../contracts/examples/execution-cell-github-oidc-s3.valid.json");
3085 let doc: ExecutionCellDocument = serde_json::from_str(raw).unwrap();
3086 let identity = doc.spec.identity.as_ref().expect("identity");
3087 let data = identity_materialized_data_v1(&doc.spec, "host-xyz", Some("run-test"), identity)
3088 .unwrap();
3089 assert_eq!(data["identity"]["kind"], "federatedOidc");
3090 assert_eq!(data["identity"]["provider"], "github-actions");
3091 assert_eq!(data["identity"]["secretRef"], "AWS_WEB_IDENTITY");
3092 assert_eq!(data["runId"], "run-test");
3093 }
3094
3095 #[test]
3096 fn identity_revoked_includes_reason() {
3097 let spec = ExecutionCellSpec {
3098 id: "s3".into(),
3099 correlation: None,
3100 ingress: None,
3101 environment: None,
3102 placement: None,
3103 policy: None,
3104 identity: Some(WorkloadIdentity {
3105 kind: WorkloadIdentityKind::FederatedOidc,
3106 provider: "github-actions".into(),
3107 audience: "sts.amazonaws.com".into(),
3108 subject: None,
3109 ttl_seconds: Some(900),
3110 secret_ref: "AWS_WEB_IDENTITY".into(),
3111 }),
3112 run: None,
3113 authority: Default::default(),
3114 lifetime: Lifetime { ttl_seconds: 3600 },
3115 export: None,
3116 telemetry: None,
3117 };
3118 let identity = spec.identity.as_ref().unwrap();
3119 let data =
3120 identity_revoked_data_v1(&spec, "c3", None, identity, Some("teardown"), None).unwrap();
3121 assert_eq!(data["identity"]["audience"], "sts.amazonaws.com");
3122 assert_eq!(data["reason"], "teardown");
3123 }
3124
3125 #[test]
3126 fn identity_failed_matches_example_shape() {
3127 let raw =
3128 include_str!("../../../contracts/examples/execution-cell-github-oidc-s3.valid.json");
3129 let doc: ExecutionCellDocument = serde_json::from_str(raw).unwrap();
3130 let expected: Value = serde_json::from_str(include_str!(
3131 "../../../contracts/examples/cell-identity-failed-data.valid.json"
3132 ))
3133 .unwrap();
3134 let identity = doc.spec.identity.as_ref().expect("identity");
3135 let data = identity_failed_data_v1(
3136 &doc.spec,
3137 "host-cell-demo",
3138 Some("run-001"),
3139 identity,
3140 IdentityFailureOperation::Materialize,
3141 "oidc exchange denied by upstream federation policy",
3142 )
3143 .unwrap();
3144 assert_eq!(data, expected);
3145 }
3146
3147 #[test]
3148 fn export_completed_v2_matches_example_shape() {
3149 let raw =
3150 include_str!("../../../contracts/examples/execution-cell-github-oidc-s3.valid.json");
3151 let doc: ExecutionCellDocument = serde_json::from_str(raw).unwrap();
3152 let receipt = ExportReceipt {
3153 target_kind: ExportReceiptTargetKind::S3,
3154 target_name: Some("artifact-bucket".into()),
3155 destination: "s3://acme-cellos-artifacts/github/acme/widget/123456789/test-results"
3156 .into(),
3157 bytes_written: 1024,
3158 };
3159 let data = export_completed_data_v2(
3160 &doc.spec,
3161 "host-xyz",
3162 Some("run-test"),
3163 "test-results",
3164 &receipt,
3165 None,
3166 )
3167 .unwrap();
3168 assert_eq!(data["receipt"]["targetKind"], "s3");
3169 assert_eq!(data["receipt"]["targetName"], "artifact-bucket");
3170 assert_eq!(data["receipt"]["bytesWritten"], 1024);
3171 }
3172
3173 #[test]
3174 fn export_completed_v2_http_matches_example_shape() {
3175 let raw = include_str!(
3176 "../../../contracts/examples/execution-cell-github-oidc-multi-export.valid.json"
3177 );
3178 let doc: ExecutionCellDocument = serde_json::from_str(raw).unwrap();
3179 let expected: Value = serde_json::from_str(include_str!(
3180 "../../../contracts/examples/cell-export-v2-completed-data-http.valid.json"
3181 ))
3182 .unwrap();
3183 let receipt = ExportReceipt {
3184 target_kind: ExportReceiptTargetKind::Http,
3185 target_name: Some("artifact-api".into()),
3186 destination: "https://artifacts.acme.internal/upload/host-cell-demo/coverage-summary"
3187 .into(),
3188 bytes_written: 512,
3189 };
3190 let data = export_completed_data_v2(
3191 &doc.spec,
3192 "host-cell-demo",
3193 Some("run-002"),
3194 "coverage-summary",
3195 &receipt,
3196 None,
3197 )
3198 .unwrap();
3199 assert_eq!(data, expected);
3200 }
3201
3202 #[test]
3203 fn export_failed_v2_http_matches_example_shape() {
3204 let raw = include_str!(
3205 "../../../contracts/examples/execution-cell-github-oidc-multi-export.valid.json"
3206 );
3207 let doc: ExecutionCellDocument = serde_json::from_str(raw).unwrap();
3208 let expected: Value = serde_json::from_str(include_str!(
3209 "../../../contracts/examples/cell-export-v2-failed-data.valid.json"
3210 ))
3211 .unwrap();
3212 let data = export_failed_data_v2(
3213 &doc.spec,
3214 "host-cell-demo",
3215 Some("run-002"),
3216 "coverage-summary",
3217 ExportReceiptTargetKind::Http,
3218 Some("artifact-api"),
3219 Some("https://artifacts.acme.internal/upload/host-cell-demo/coverage-summary"),
3220 "http put returned 403 Forbidden",
3221 None,
3222 )
3223 .unwrap();
3224 assert_eq!(data, expected);
3225 }
3226
3227 #[test]
3228 fn compliance_summary_matches_example_shape() {
3229 let raw =
3230 include_str!("../../../contracts/examples/execution-cell-ci-correlation.valid.json");
3231 let doc: ExecutionCellDocument = serde_json::from_str(raw).unwrap();
3232 let expected: Value = serde_json::from_str(include_str!(
3233 "../../../contracts/examples/cell-compliance-summary-data.valid.json"
3234 ))
3235 .unwrap();
3236 let data =
3237 compliance_summary_data_v1(&doc.spec, "host-cell-demo", Some("run-003"), Some(0))
3238 .unwrap();
3239 assert_eq!(data, expected);
3240 }
3241
3242 #[test]
3243 fn compliance_summary_omits_placement_when_absent() {
3244 let spec = ExecutionCellSpec {
3245 id: "compliance-no-placement".into(),
3246 correlation: None,
3247 ingress: None,
3248 environment: None,
3249 placement: None,
3250 policy: None,
3251 identity: None,
3252 run: None,
3253 authority: Default::default(),
3254 lifetime: Lifetime { ttl_seconds: 60 },
3255 export: None,
3256 telemetry: None,
3257 };
3258 let data = compliance_summary_data_v1(&spec, "cell-001", None, None).unwrap();
3259 assert!(!data.as_object().unwrap().contains_key("placement"));
3260 }
3261
3262 #[test]
3267 fn compliance_summary_with_empty_subjects_omits_field() {
3268 let raw =
3269 include_str!("../../../contracts/examples/execution-cell-ci-correlation.valid.json");
3270 let doc: ExecutionCellDocument = serde_json::from_str(raw).unwrap();
3271 let legacy =
3272 compliance_summary_data_v1(&doc.spec, "host-cell-demo", Some("run-003"), Some(0))
3273 .unwrap();
3274 let with_empty = compliance_summary_data_v1_with_subjects(
3275 &doc.spec,
3276 "host-cell-demo",
3277 Some("run-003"),
3278 Some(0),
3279 &[],
3280 )
3281 .unwrap();
3282 assert_eq!(legacy, with_empty);
3283 assert!(!with_empty.as_object().unwrap().contains_key("subjectUrns"));
3284 }
3285
3286 #[test]
3289 fn compliance_summary_with_subjects_matches_example_shape() {
3290 let raw =
3291 include_str!("../../../contracts/examples/execution-cell-ci-correlation.valid.json");
3292 let doc: ExecutionCellDocument = serde_json::from_str(raw).unwrap();
3293 let expected: Value = serde_json::from_str(include_str!(
3294 "../../../contracts/examples/cell-compliance-summary-data-with-subjects.valid.json"
3295 ))
3296 .unwrap();
3297 let subjects: Vec<SubjectUrn> = vec![
3298 SubjectUrn::parse("urn:cellos:cell:host-cell-demo").unwrap(),
3299 SubjectUrn::parse("urn:tsafe:lease:lease-42").unwrap(),
3300 SubjectUrn::parse("urn:cellos:export:run-003%2Fartifact-1").unwrap(),
3301 ];
3302 let data = compliance_summary_data_v1_with_subjects(
3303 &doc.spec,
3304 "host-cell-demo",
3305 Some("run-003"),
3306 Some(0),
3307 &subjects,
3308 )
3309 .unwrap();
3310 assert_eq!(data, expected);
3311 let urns = data["subjectUrns"].as_array().unwrap();
3312 assert_eq!(urns.len(), 3);
3313 assert_eq!(urns[0], "urn:cellos:cell:host-cell-demo");
3314 }
3315
3316 #[test]
3323 fn compliance_summary_invalid_subject_urns_fixture_is_malformed() {
3324 let raw =
3325 include_str!("../../../contracts/examples/cell-compliance-summary-data.invalid.json");
3326 let v: Value = serde_json::from_str(raw).unwrap();
3327 let urns = v["subjectUrns"]
3328 .as_array()
3329 .expect("invalid fixture must carry subjectUrns array");
3330 assert!(!urns.is_empty(), "negative fixture must have entries");
3331
3332 fn matches_schema_shape(s: &str) -> bool {
3339 let parts: Vec<&str> = s.splitn(4, ':').collect();
3340 if parts.len() != 4 {
3341 return false;
3342 }
3343 if parts[0] != "urn" {
3344 return false;
3345 }
3346 let segment_ok = |seg: &str| {
3347 let mut it = seg.chars();
3348 match it.next() {
3349 Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() => {}
3350 _ => return false,
3351 }
3352 it.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
3353 };
3354 if !segment_ok(parts[1]) || !segment_ok(parts[2]) {
3355 return false;
3356 }
3357 if parts[3].is_empty() {
3358 return false;
3359 }
3360 parts[3]
3361 .chars()
3362 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '%' | '-'))
3363 }
3364
3365 for (i, urn) in urns.iter().enumerate() {
3366 let s = urn.as_str().unwrap_or("");
3367 assert!(
3368 !matches_schema_shape(s),
3369 "invalid fixture entry [{i}] {s:?} unexpectedly matches the schema URN regex; \
3370 fixture must remain a negative case"
3371 );
3372 }
3373 }
3374
3375 #[test]
3376 fn network_enforcement_matches_example_shape() {
3377 let raw = include_str!(
3378 "../../../contracts/examples/cell-observability-network-enforcement-data.valid.json"
3379 );
3380 let expected: Value = serde_json::from_str(raw).unwrap();
3381 let spec = ExecutionCellSpec {
3382 id: "net-enforcement-demo".into(),
3383 correlation: None,
3384 ingress: None,
3385 environment: None,
3386 placement: None,
3387 policy: None,
3388 identity: None,
3389 run: None,
3390 authority: Default::default(),
3391 lifetime: Lifetime { ttl_seconds: 60 },
3392 export: None,
3393 telemetry: None,
3394 };
3395 let data = observability_network_enforcement_data_v1(
3396 &spec,
3397 "net-enforcement-demo",
3398 Some("run-local-001"),
3399 true,
3400 1,
3401 1,
3402 None,
3403 )
3404 .unwrap();
3405 assert_eq!(data, expected);
3406 }
3407
3408 #[test]
3409 fn dns_resolution_matches_example_shape() {
3410 let raw = include_str!(
3411 "../../../contracts/examples/cell-observability-dns-resolution-data.valid.json"
3412 );
3413 let expected: Value = serde_json::from_str(raw).unwrap();
3414 let spec = ExecutionCellSpec {
3415 id: "demo-cell-dns".into(),
3416 correlation: None,
3417 ingress: None,
3418 environment: None,
3419 placement: None,
3420 policy: None,
3421 identity: None,
3422 run: None,
3423 authority: Default::default(),
3424 lifetime: Lifetime { ttl_seconds: 60 },
3425 export: None,
3426 telemetry: None,
3427 };
3428 let targets: &[(&str, &str, Option<u16>)] = &[
3429 ("203.0.113.10", "inet", Some(443)),
3430 ("2001:db8::1", "inet6", Some(443)),
3431 ];
3432 let data = observability_dns_resolution_data_v1(
3433 &spec,
3434 "demo-cell-dns",
3435 Some("run-001"),
3436 "api.example.com",
3437 "2026-04-30T12:00:00Z",
3438 targets,
3439 300,
3440 "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
3441 "keyset-demo-001",
3442 "kid-resolver-01",
3443 Some("rcpt-demo-0001"),
3444 )
3445 .unwrap();
3446 assert_eq!(data, expected);
3447 }
3448
3449 #[test]
3450 fn dns_target_set_matches_example_shape() {
3451 let raw = include_str!(
3452 "../../../contracts/examples/cell-observability-dns-target-set-data.valid.json"
3453 );
3454 let expected: Value = serde_json::from_str(raw).unwrap();
3455 let spec = ExecutionCellSpec {
3456 id: "demo-cell-dns".into(),
3457 correlation: None,
3458 ingress: None,
3459 environment: None,
3460 placement: None,
3461 policy: None,
3462 identity: None,
3463 run: None,
3464 authority: Default::default(),
3465 lifetime: Lifetime { ttl_seconds: 60 },
3466 export: None,
3467 telemetry: None,
3468 };
3469 let data = observability_dns_target_set_data_v1(
3470 &spec,
3471 "demo-cell-dns",
3472 Some("run-001"),
3473 "cdn.example.com",
3474 "empty",
3475 "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
3476 "refresh",
3477 "2026-04-30T12:05:00Z",
3478 "keyset-demo-001",
3479 "kid-resolver-01",
3480 )
3481 .unwrap();
3482 assert_eq!(data, expected);
3483 }
3484
3485 #[test]
3486 fn dns_query_data_v1_serializes_allow_path() {
3487 use crate::{DnsQueryDecision, DnsQueryEvent, DnsQueryReasonCode, DnsQueryType};
3488 let ev = DnsQueryEvent {
3489 schema_version: "1.0.0".into(),
3490 cell_id: "demo-cell-dns".into(),
3491 run_id: "run-2026-05-01-001".into(),
3492 query_id: "q-3b58b2a4-e4bb-4f89-9c4f-2a0a2c8b6f01".into(),
3493 query_name: "api.example.com".into(),
3494 query_type: DnsQueryType::A,
3495 decision: DnsQueryDecision::Allow,
3496 reason_code: DnsQueryReasonCode::AllowedByAllowlist,
3497 response_rcode: Some(0),
3498 upstream_resolver_id: Some("resolver-do53-internal".into()),
3499 upstream_latency_ms: Some(4),
3500 response_target_count: Some(2),
3501 keyset_id: Some("keyset-demo-001".into()),
3502 issuer_kid: Some("kid-resolver-01".into()),
3503 policy_digest: Some(
3504 "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".into(),
3505 ),
3506 correlation_id: Some("corr-demo-0001".into()),
3507 observed_at: "2026-05-01T12:34:56Z".into(),
3508 };
3509 let v = dns_query_data_v1(&ev).unwrap();
3510 assert_eq!(v["schemaVersion"], "1.0.0");
3511 assert_eq!(v["queryName"], "api.example.com");
3512 assert_eq!(v["queryType"], "A");
3513 assert_eq!(v["decision"], "allow");
3514 assert_eq!(v["reasonCode"], "allowed_by_allowlist");
3515 assert_eq!(v["upstreamResolverId"], "resolver-do53-internal");
3516 assert_eq!(v["responseTargetCount"], 2);
3517 }
3518
3519 #[test]
3520 fn dns_query_data_v1_omits_optionals_on_deny_path() {
3521 use crate::{DnsQueryDecision, DnsQueryEvent, DnsQueryReasonCode, DnsQueryType};
3522 let ev = DnsQueryEvent {
3523 schema_version: "1.0.0".into(),
3524 cell_id: "demo-cell-dns".into(),
3525 run_id: "run-2026-05-01-001".into(),
3526 query_id: "q-deny-001".into(),
3527 query_name: "blocked.example.com".into(),
3528 query_type: DnsQueryType::AAAA,
3529 decision: DnsQueryDecision::Deny,
3530 reason_code: DnsQueryReasonCode::DeniedNotInAllowlist,
3531 response_rcode: Some(5),
3532 upstream_resolver_id: None,
3533 upstream_latency_ms: None,
3534 response_target_count: Some(0),
3535 keyset_id: None,
3536 issuer_kid: None,
3537 policy_digest: None,
3538 correlation_id: None,
3539 observed_at: "2026-05-01T12:35:00Z".into(),
3540 };
3541 let v = dns_query_data_v1(&ev).unwrap();
3542 let obj = v.as_object().unwrap();
3543 assert!(!obj.contains_key("upstreamResolverId"));
3544 assert!(!obj.contains_key("upstreamLatencyMs"));
3545 assert!(!obj.contains_key("keysetId"));
3546 assert!(!obj.contains_key("issuerKid"));
3547 assert!(!obj.contains_key("policyDigest"));
3548 assert!(!obj.contains_key("correlationId"));
3549 assert_eq!(v["decision"], "deny");
3550 assert_eq!(v["reasonCode"], "denied_not_in_allowlist");
3551 assert_eq!(v["responseRcode"], 5);
3552 }
3553
3554 #[test]
3555 fn cloud_event_v1_dns_query_envelope() {
3556 use crate::{DnsQueryDecision, DnsQueryEvent, DnsQueryReasonCode, DnsQueryType};
3557 let ev = DnsQueryEvent {
3558 schema_version: "1.0.0".into(),
3559 cell_id: "c1".into(),
3560 run_id: "r1".into(),
3561 query_id: "q1".into(),
3562 query_name: "api.example.com".into(),
3563 query_type: DnsQueryType::A,
3564 decision: DnsQueryDecision::Allow,
3565 reason_code: DnsQueryReasonCode::AllowedByAllowlist,
3566 response_rcode: Some(0),
3567 upstream_resolver_id: Some("r-001".into()),
3568 upstream_latency_ms: Some(3),
3569 response_target_count: Some(1),
3570 keyset_id: None,
3571 issuer_kid: None,
3572 policy_digest: None,
3573 correlation_id: None,
3574 observed_at: "2026-05-01T12:34:56Z".into(),
3575 };
3576 let env =
3577 cloud_event_v1_dns_query("cellos-dns-proxy", "2026-05-01T12:34:56Z", &ev).unwrap();
3578 assert_eq!(env.specversion, "1.0");
3579 assert_eq!(env.ty, "dev.cellos.events.cell.observability.v1.dns_query");
3580 assert_eq!(env.source, "cellos-dns-proxy");
3581 assert_eq!(env.datacontenttype.as_deref(), Some("application/json"));
3582 assert!(env.data.is_some());
3583 }
3584
3585 #[test]
3586 fn qtype_mapping_covers_phase1_set() {
3587 use crate::{qtype_to_dns_query_type, DnsQueryType};
3588 assert_eq!(qtype_to_dns_query_type(1), Some(DnsQueryType::A));
3589 assert_eq!(qtype_to_dns_query_type(2), Some(DnsQueryType::NS));
3590 assert_eq!(qtype_to_dns_query_type(5), Some(DnsQueryType::CNAME));
3591 assert_eq!(qtype_to_dns_query_type(12), Some(DnsQueryType::PTR));
3592 assert_eq!(qtype_to_dns_query_type(15), Some(DnsQueryType::MX));
3593 assert_eq!(qtype_to_dns_query_type(16), Some(DnsQueryType::TXT));
3594 assert_eq!(qtype_to_dns_query_type(28), Some(DnsQueryType::AAAA));
3595 assert_eq!(qtype_to_dns_query_type(33), Some(DnsQueryType::SRV));
3596 assert_eq!(qtype_to_dns_query_type(64), Some(DnsQueryType::SVCB));
3597 assert_eq!(qtype_to_dns_query_type(65), Some(DnsQueryType::HTTPS));
3598 assert_eq!(qtype_to_dns_query_type(0), None);
3600 assert_eq!(qtype_to_dns_query_type(99), None);
3601 assert_eq!(qtype_to_dns_query_type(255), None); }
3603
3604 #[test]
3605 fn l7_egress_decision_matches_example_shape() {
3606 let raw = include_str!(
3607 "../../../contracts/examples/cell-observability-l7-egress-decision-data.valid.json"
3608 );
3609 let expected: Value = serde_json::from_str(raw).unwrap();
3610 let spec = ExecutionCellSpec {
3611 id: "demo-cell-dns".into(),
3612 correlation: None,
3613 ingress: None,
3614 environment: None,
3615 placement: None,
3616 policy: None,
3617 identity: None,
3618 run: None,
3619 authority: Default::default(),
3620 lifetime: Lifetime { ttl_seconds: 60 },
3621 export: None,
3622 telemetry: None,
3623 };
3624 let data = observability_l7_egress_decision_data_v1(
3625 &spec,
3626 "demo-cell-dns",
3627 Some("run-001"),
3628 "l7-demo-0002",
3629 "deny",
3630 "blocked.example.com",
3631 "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
3632 "keyset-demo-001",
3633 "kid-l7-01",
3634 "deny_default",
3635 Some("authority.egressRules.default"),
3636 None, )
3638 .unwrap();
3639 assert_eq!(data, expected);
3640 }
3641
3642 #[test]
3643 fn l7_egress_decision_with_stream_id_emits_field() {
3644 let spec = ExecutionCellSpec {
3645 id: "demo-cell-dns".into(),
3646 correlation: None,
3647 ingress: None,
3648 environment: None,
3649 placement: None,
3650 policy: None,
3651 identity: None,
3652 run: None,
3653 authority: Default::default(),
3654 lifetime: Lifetime { ttl_seconds: 60 },
3655 export: None,
3656 telemetry: None,
3657 };
3658 let data = observability_l7_egress_decision_data_v1(
3659 &spec,
3660 "demo-cell-dns",
3661 Some("run-001"),
3662 "l7-demo-0003",
3663 "deny",
3664 "evil.example.com",
3665 "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
3666 "keyset-demo-001",
3667 "kid-l7-01",
3668 "l7_h2_authority_allowlist_miss",
3669 None,
3670 Some(3), )
3672 .unwrap();
3673 assert_eq!(data["streamId"], serde_json::json!(3));
3674 }
3675
3676 fn _seam_g1_g2_minimal_spec(id: &str) -> ExecutionCellSpec {
3679 ExecutionCellSpec {
3680 id: id.into(),
3681 correlation: None,
3682 ingress: None,
3683 environment: None,
3684 placement: None,
3685 policy: None,
3686 identity: None,
3687 run: None,
3688 authority: Default::default(),
3689 lifetime: Lifetime { ttl_seconds: 60 },
3690 export: None,
3691 telemetry: None,
3692 }
3693 }
3694
3695 #[test]
3696 fn seam_g2_identity_revoked_includes_provenance_when_set() {
3697 let mut spec = _seam_g1_g2_minimal_spec("seam-g2-revoke");
3699 spec.identity = Some(WorkloadIdentity {
3700 kind: WorkloadIdentityKind::FederatedOidc,
3701 provider: "github-actions".into(),
3702 audience: "sts.amazonaws.com".into(),
3703 subject: None,
3704 ttl_seconds: Some(900),
3705 secret_ref: "AWS_WEB_IDENTITY".into(),
3706 });
3707 let identity = spec.identity.as_ref().unwrap();
3708 let prov = Provenance {
3709 parent: "urn:cellos:event:00000000-0000-0000-0000-00000000abcd".into(),
3710 parent_type: "dev.cellos.events.cell.lifecycle.v1.started".into(),
3711 };
3712 let data = identity_revoked_data_v1(
3713 &spec,
3714 "cell-seam-g2",
3715 Some("run-seam-g2"),
3716 identity,
3717 Some("teardown"),
3718 Some(&prov),
3719 )
3720 .unwrap();
3721 assert_eq!(
3722 data["provenance"]["parent"],
3723 "urn:cellos:event:00000000-0000-0000-0000-00000000abcd"
3724 );
3725 assert_eq!(
3726 data["provenance"]["parentType"],
3727 "dev.cellos.events.cell.lifecycle.v1.started"
3728 );
3729 }
3730
3731 #[test]
3732 fn seam_g2_identity_revoked_omits_provenance_when_none() {
3733 let mut spec = _seam_g1_g2_minimal_spec("seam-g2-revoke-no-prov");
3736 spec.identity = Some(WorkloadIdentity {
3737 kind: WorkloadIdentityKind::FederatedOidc,
3738 provider: "github-actions".into(),
3739 audience: "sts.amazonaws.com".into(),
3740 subject: None,
3741 ttl_seconds: Some(900),
3742 secret_ref: "AWS_WEB_IDENTITY".into(),
3743 });
3744 let identity = spec.identity.as_ref().unwrap();
3745 let data =
3746 identity_revoked_data_v1(&spec, "cell-x", None, identity, Some("teardown"), None)
3747 .unwrap();
3748 assert!(!data.as_object().unwrap().contains_key("provenance"));
3749 }
3750
3751 #[test]
3752 fn seam_g2_export_completed_v2_includes_provenance_when_set() {
3753 let spec = _seam_g1_g2_minimal_spec("seam-g2-export");
3755 let receipt = ExportReceipt {
3756 target_kind: ExportReceiptTargetKind::Local,
3757 target_name: None,
3758 destination: "/tmp/out/run-1/artifact.json".into(),
3759 bytes_written: 42,
3760 };
3761 let prov = Provenance {
3762 parent: "urn:cellos:event:11111111-1111-1111-1111-111111111111".into(),
3763 parent_type: "dev.cellos.events.cell.lifecycle.v1.started".into(),
3764 };
3765 let data = export_completed_data_v2(
3766 &spec,
3767 "cell-export",
3768 Some("run-export"),
3769 "artifact",
3770 &receipt,
3771 Some(&prov),
3772 )
3773 .unwrap();
3774 assert_eq!(
3775 data["provenance"]["parent"],
3776 "urn:cellos:event:11111111-1111-1111-1111-111111111111"
3777 );
3778 assert_eq!(
3779 data["provenance"]["parentType"],
3780 "dev.cellos.events.cell.lifecycle.v1.started"
3781 );
3782 }
3783
3784 #[test]
3785 fn seam_g2_export_failed_v2_includes_provenance_when_set() {
3786 let spec = _seam_g1_g2_minimal_spec("seam-g2-export-failed");
3788 let prov = Provenance {
3789 parent: "urn:cellos:event:22222222-2222-2222-2222-222222222222".into(),
3790 parent_type: "dev.cellos.events.cell.lifecycle.v1.started".into(),
3791 };
3792 let data = export_failed_data_v2(
3793 &spec,
3794 "cell-fail",
3795 Some("run-fail"),
3796 "artifact",
3797 ExportReceiptTargetKind::S3,
3798 Some("bucket"),
3799 Some("s3://bucket/artifact"),
3800 "denied by policy",
3801 Some(&prov),
3802 )
3803 .unwrap();
3804 assert_eq!(
3805 data["provenance"]["parent"],
3806 "urn:cellos:event:22222222-2222-2222-2222-222222222222"
3807 );
3808 assert_eq!(data["reason"], "denied by policy");
3809 }
3810
3811 #[test]
3812 fn seam_g1_correlation_id_propagates_when_present_in_spec() {
3813 let mut spec = _seam_g1_g2_minimal_spec("seam-g1-corr");
3819 spec.correlation = Some(Correlation {
3820 platform: None,
3821 external_run_id: None,
3822 external_job_id: None,
3823 tenant_id: None,
3824 labels: None,
3825 correlation_id: Some("urn:tsafe:corr:01J".into()),
3826 });
3827 spec.identity = Some(WorkloadIdentity {
3828 kind: WorkloadIdentityKind::FederatedOidc,
3829 provider: "github-actions".into(),
3830 audience: "sts.amazonaws.com".into(),
3831 subject: None,
3832 ttl_seconds: Some(900),
3833 secret_ref: "AWS_WEB_IDENTITY".into(),
3834 });
3835 let identity = spec.identity.as_ref().unwrap();
3836
3837 let data =
3839 identity_revoked_data_v1(&spec, "cell-1", Some("r"), identity, Some("teardown"), None)
3840 .unwrap();
3841 assert_eq!(
3842 data["correlation"]["correlationId"], "urn:tsafe:corr:01J",
3843 "identity.revoked must mirror correlationId from spec"
3844 );
3845
3846 let receipt = ExportReceipt {
3848 target_kind: ExportReceiptTargetKind::Local,
3849 target_name: None,
3850 destination: "/tmp/x".into(),
3851 bytes_written: 1,
3852 };
3853 let data = export_completed_data_v2(&spec, "c", Some("r"), "art", &receipt, None).unwrap();
3854 assert_eq!(
3855 data["correlation"]["correlationId"], "urn:tsafe:corr:01J",
3856 "export.v2.completed must mirror correlationId from spec"
3857 );
3858
3859 let data = export_failed_data_v2(
3861 &spec,
3862 "c",
3863 Some("r"),
3864 "art",
3865 ExportReceiptTargetKind::Local,
3866 None,
3867 None,
3868 "boom",
3869 None,
3870 )
3871 .unwrap();
3872 assert_eq!(
3873 data["correlation"]["correlationId"], "urn:tsafe:corr:01J",
3874 "export.v2.failed must mirror correlationId from spec"
3875 );
3876
3877 let data =
3879 command_completed_data_v1(&spec, "c", Some("r"), &["echo".to_string()], 0, 5, None)
3880 .unwrap();
3881 assert_eq!(
3882 data["correlation"]["correlationId"], "urn:tsafe:corr:01J",
3883 "command.completed must mirror correlationId from spec"
3884 );
3885
3886 let data = compliance_summary_data_v1(&spec, "c", Some("r"), Some(0)).unwrap();
3888 assert_eq!(
3889 data["correlation"]["correlationId"], "urn:tsafe:corr:01J",
3890 "compliance.summary must mirror correlationId from spec"
3891 );
3892 }
3893
3894 #[test]
3903 fn subject_urn_accepts_canonical_cell_form() {
3904 let urn = SubjectUrn::parse("urn:cellos:cell:abc-123").expect("must parse");
3906 assert_eq!(urn.as_str(), "urn:cellos:cell:abc-123");
3907 }
3908
3909 #[test]
3910 fn subject_urn_accepts_id_with_internal_colons() {
3911 let urn = SubjectUrn::parse("urn:cellos:event:abc:01j").expect("must parse");
3914 assert_eq!(urn.as_str(), "urn:cellos:event:abc:01j");
3915 }
3916
3917 #[test]
3918 fn subject_urn_rejects_when_no_urn_scheme() {
3919 let err = SubjectUrn::parse("cell:abc-123").unwrap_err();
3921 assert_eq!(err, SubjectUrnError::MissingUrnScheme);
3922 }
3923
3924 #[test]
3925 fn subject_urn_rejects_three_segment_form() {
3926 let err = SubjectUrn::parse("urn:cellos:cell").unwrap_err();
3928 assert_eq!(err, SubjectUrnError::TooFewSegments);
3929 }
3930
3931 #[test]
3932 fn subject_urn_rejects_empty_id() {
3933 let err = SubjectUrn::parse("urn:cellos:cell:").unwrap_err();
3935 assert_eq!(err, SubjectUrnError::EmptySegment);
3936 }
3937
3938 #[test]
3939 fn subject_urn_rejects_uppercase_tool_or_kind() {
3940 let err = SubjectUrn::parse("urn:CellOS:cell:abc-123").unwrap_err();
3942 assert_eq!(err, SubjectUrnError::InvalidToolOrKindCharset);
3943 }
3944
3945 #[test]
3946 fn subject_urn_rejects_embedded_whitespace() {
3947 let err = SubjectUrn::parse("urn:cellos:cell:abc 123").unwrap_err();
3949 assert_eq!(err, SubjectUrnError::ControlOrWhitespace);
3950 }
3951
3952 #[test]
3953 fn subject_urn_rejects_empty_tool_segment() {
3954 let err = SubjectUrn::parse("urn::cell:abc-123").unwrap_err();
3956 assert_eq!(err, SubjectUrnError::EmptySegment);
3957 }
3958
3959 #[test]
3960 fn cell_subject_urn_helper_round_trips() {
3961 let urn = cell_subject_urn("cell-host-7").expect("helper must accept ASCII id");
3963 assert_eq!(urn.as_str(), "urn:cellos:cell:cell-host-7");
3964 let reparsed = SubjectUrn::parse(urn.as_str()).expect("must reparse");
3966 assert_eq!(reparsed, urn);
3967 }
3968
3969 #[test]
3970 fn cell_subject_urn_helper_rejects_empty_id() {
3971 let err = cell_subject_urn("").unwrap_err();
3972 assert_eq!(err, SubjectUrnError::EmptySegment);
3973 }
3974
3975 #[test]
3978 fn formation_data_v1_shape_happy_path() {
3979 let data = formation_data_v1("f-123", "demo-formation", 3, &[], None);
3980 assert_eq!(data["formationId"], json!("f-123"));
3981 assert_eq!(data["formationName"], json!("demo-formation"));
3982 assert_eq!(data["cellCount"], json!(3));
3983 assert_eq!(data["failedCellIds"], json!([] as [String; 0]));
3984 let obj = data.as_object().unwrap();
3986 assert!(!obj.contains_key("reason"));
3987 }
3988
3989 #[test]
3990 fn formation_data_v1_shape_degraded_path_includes_failed_cells_and_reason() {
3991 let failed = vec!["cell-a".to_string(), "cell-b".to_string()];
3992 let data = formation_data_v1(
3993 "f-123",
3994 "demo-formation",
3995 5,
3996 &failed,
3997 Some("2/5 cells exited non-zero"),
3998 );
3999 assert_eq!(data["failedCellIds"], json!(failed));
4000 assert_eq!(data["reason"], json!("2/5 cells exited non-zero"));
4001 }
4002
4003 #[test]
4004 fn formation_created_envelope_carries_correct_urn() {
4005 let ev = cloud_event_v1_formation_created(
4006 "cellos-supervisor",
4007 "2026-05-16T00:00:00Z",
4008 "f-1",
4009 "demo",
4010 2,
4011 &[],
4012 None,
4013 );
4014 assert_eq!(ev.ty, "dev.cellos.events.cell.formation.v1.created");
4015 assert_eq!(ev.specversion, "1.0");
4016 assert_eq!(ev.source, "cellos-supervisor");
4017 assert!(ev.data.is_some());
4018 }
4019
4020 #[test]
4021 fn formation_launching_envelope_carries_correct_urn() {
4022 let ev = cloud_event_v1_formation_launching(
4023 "cellos-supervisor",
4024 "2026-05-16T00:00:00Z",
4025 "f-1",
4026 "demo",
4027 2,
4028 &[],
4029 None,
4030 );
4031 assert_eq!(ev.ty, "dev.cellos.events.cell.formation.v1.launching");
4032 }
4033
4034 #[test]
4035 fn formation_running_envelope_carries_correct_urn() {
4036 let ev = cloud_event_v1_formation_running(
4037 "cellos-supervisor",
4038 "2026-05-16T00:00:00Z",
4039 "f-1",
4040 "demo",
4041 2,
4042 &[],
4043 None,
4044 );
4045 assert_eq!(ev.ty, "dev.cellos.events.cell.formation.v1.running");
4046 }
4047
4048 #[test]
4049 fn formation_degraded_envelope_carries_correct_urn_and_failed_cells() {
4050 let failed = vec!["cell-a".to_string()];
4051 let ev = cloud_event_v1_formation_degraded(
4052 "cellos-supervisor",
4053 "2026-05-16T00:00:00Z",
4054 "f-1",
4055 "demo",
4056 3,
4057 &failed,
4058 Some("one cell exited 1"),
4059 );
4060 assert_eq!(ev.ty, "dev.cellos.events.cell.formation.v1.degraded");
4061 let data = ev.data.unwrap();
4062 assert_eq!(data["failedCellIds"], json!(failed));
4063 assert_eq!(data["reason"], json!("one cell exited 1"));
4064 }
4065
4066 #[test]
4067 fn formation_completed_envelope_carries_correct_urn() {
4068 let ev = cloud_event_v1_formation_completed(
4069 "cellos-supervisor",
4070 "2026-05-16T00:00:00Z",
4071 "f-1",
4072 "demo",
4073 2,
4074 &[],
4075 None,
4076 );
4077 assert_eq!(ev.ty, "dev.cellos.events.cell.formation.v1.completed");
4078 }
4079
4080 #[test]
4081 fn formation_failed_envelope_carries_correct_urn_and_reason() {
4082 let failed = vec!["cell-a".to_string(), "cell-b".to_string()];
4083 let ev = cloud_event_v1_formation_failed(
4084 "cellos-supervisor",
4085 "2026-05-16T00:00:00Z",
4086 "f-1",
4087 "demo",
4088 2,
4089 &failed,
4090 Some("all cells exited non-zero"),
4091 );
4092 assert_eq!(ev.ty, "dev.cellos.events.cell.formation.v1.failed");
4093 let data = ev.data.unwrap();
4094 assert_eq!(data["failedCellIds"], json!(failed));
4095 assert_eq!(data["reason"], json!("all cells exited non-zero"));
4096 }
4097
4098 #[test]
4099 fn formation_type_constants_match_envelope_urns() {
4100 assert_eq!(
4101 FORMATION_CREATED_TYPE,
4102 "dev.cellos.events.cell.formation.v1.created"
4103 );
4104 assert_eq!(
4105 FORMATION_LAUNCHING_TYPE,
4106 "dev.cellos.events.cell.formation.v1.launching"
4107 );
4108 assert_eq!(
4109 FORMATION_RUNNING_TYPE,
4110 "dev.cellos.events.cell.formation.v1.running"
4111 );
4112 assert_eq!(
4113 FORMATION_DEGRADED_TYPE,
4114 "dev.cellos.events.cell.formation.v1.degraded"
4115 );
4116 assert_eq!(
4117 FORMATION_COMPLETED_TYPE,
4118 "dev.cellos.events.cell.formation.v1.completed"
4119 );
4120 assert_eq!(
4121 FORMATION_FAILED_TYPE,
4122 "dev.cellos.events.cell.formation.v1.failed"
4123 );
4124 }
4125}