use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;

/// Top-level document stored in `contracts/examples/*.json` and API bodies.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExecutionCellDocument {
    pub api_version: String,
    pub kind: String,
    pub spec: ExecutionCellSpec,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExecutionCellSpec {
    pub id: String,
    #[serde(default)]
    pub correlation: Option<Correlation>,
    #[serde(default)]
    pub ingress: Option<Ingress>,
    #[serde(default)]
    pub environment: Option<EnvironmentSpec>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub placement: Option<PlacementSpec>,
    #[serde(default)]
    pub policy: Option<PolicyRef>,
    #[serde(default)]
    pub identity: Option<WorkloadIdentity>,
    #[serde(default)]
    pub run: Option<RunSpec>,
    pub authority: AuthorityBundle,
    pub lifetime: Lifetime,
    #[serde(default)]
    pub export: Option<ExportChannels>,
    /// F4a — declared telemetry intent for the cell.
    ///
    /// When present, the cell's guest agent emits structured CloudEvents over
    /// the declared channel for the listed event names. Validation enforces a
    /// cross-reference between `telemetry.events` entries that begin with
    /// `net.` and the cell's `authority.egressRules` — emitting a `net.*`
    /// event without any declared egress is treated as an unbacked
    /// telemetry/authority claim.
    ///
    /// Optional: cells without a telemetry block emit no agent events. The
    /// supervisor enforces presence via `enforce_telemetry_declared` only when
    /// `CELLOS_REQUIRE_TELEMETRY_DECLARED` is set (auto-enabled by hardened
    /// profile).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub telemetry: Option<TelemetrySpec>,
}

/// F4a — declared telemetry block on [`ExecutionCellSpec`].
///
/// Carries the operator's intent for what the in-guest agent will emit over
/// the supervisor-side telemetry channel. Validated at admission time
/// ([`crate::validate_execution_cell_document`]); not consumed by the
/// supervisor's runtime path today (purely contract-layer).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TelemetrySpec {
    /// Wire channel the guest agent uses to emit telemetry to the host.
    pub channel: TelemetryChannel,
    /// Stable event names the guest agent intends to emit. Must be non-empty.
    /// Any entry beginning with `net.` requires a corresponding
    /// `authority.egressRules` entry — see admission validation.
    pub events: Vec<String>,
    /// Optional per-event rate-limiting hints honored by the guest agent.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rate_limits: Option<TelemetryRateLimits>,
    /// Optional split that names which event fields are stamped on the host
    /// side (post-admission, by the supervisor) versus inside the guest agent.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub host_vs_guest_fields: Option<TelemetryFieldSplit>,
    /// Semver-shaped agent version string (e.g. `1.0.0`, `1.2.3-rc.1`). The
    /// supervisor pins this into `lifecycle.started` so consumers can
    /// correlate event shape to a specific agent build.
    pub agent_version: String,
}

/// F4a — wire channel for [`TelemetrySpec`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TelemetryChannel {
    /// vsock transport carrying CBOR-encoded CloudEvents (1.0 default).
    VsockCbor,
}

/// F4a — per-event rate-limit hints inside [`TelemetrySpec`].
///
/// Both fields are optional. When set, the guest agent SHOULD shed events
/// that exceed the declared rate. Enforcement is best-effort — the host-side
/// admission gate validates structural shape only.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TelemetryRateLimits {
    /// Soft cap on events emitted per second across all event names.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub events_per_second: Option<u32>,
    /// Soft cap on bytes emitted per second across all event names.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub bytes_per_second: Option<u32>,
}

/// F4a — names which event fields are stamped on the host vs inside the guest
/// agent. Pure metadata; informs SIEM consumers about which fields the
/// supervisor backfills (e.g. `runId`, `cellId`) versus which fields the
/// guest agent populates from its own observations.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TelemetryFieldSplit {
    /// Field names the host (supervisor) injects after the guest emits.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub host_fields: Vec<String>,
    /// Field names the guest agent populates from in-cell observations.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub guest_fields: Vec<String>,
}

/// Subprocess invocation (MVP supervisor shim; L2 should move execution inside isolation).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RunSpec {
    pub argv: Vec<String>,
    #[serde(default)]
    pub working_directory: Option<String>,
    #[serde(default)]
    pub timeout_ms: Option<u64>,
    #[serde(default)]
    pub limits: Option<RunLimits>,
    #[serde(default)]
    pub secret_delivery: SecretDeliveryMode,
}

/// How resolved secrets are exposed to `spec.run`.
///
/// # Default (1.0 → 1.1 evolution)
///
/// Specs that omit `run.secretDelivery` now default to
/// [`SecretDeliveryMode::RuntimeLeasedBroker`] (A3-01). Previously the
/// implicit default was [`SecretDeliveryMode::Env`], which materialized
/// secret values directly into the child environment — the weakest of the
/// three modes (no revoke path, no TTL, no rotation). Operators that still
/// rely on env-injection must now set `run.secretDelivery: "env"` explicitly.
///
/// This is a **behavior change** for any spec that omits the field. Existing
/// specs that explicitly set `Env` continue to work unchanged.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SecretDeliveryMode {
    /// Materialize secret values directly into the child environment.
    ///
    /// Requires explicit opt-in as of A3-01 — no longer the implicit default.
    Env,
    /// Expose secrets through a runtime broker socket; the child fetches them on demand.
    RuntimeBroker,
    /// Expose secrets through a runtime broker socket backed by an upstream lease/token
    /// that the broker can revoke on teardown.
    ///
    /// Recommended default (A3-01). Selected when `run.secretDelivery` is
    /// omitted because it is the only mode with a host-driven revoke path
    /// for upstream credentials.
    RuntimeLeasedBroker,
}

impl Default for SecretDeliveryMode {
    /// A3-01: omitting `run.secretDelivery` now selects
    /// [`SecretDeliveryMode::RuntimeLeasedBroker`]. See the type-level
    /// doc comment for the migration note.
    fn default() -> Self {
        Self::RuntimeLeasedBroker
    }
}

/// Resource bounds requested for `spec.run`.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RunLimits {
    #[serde(default)]
    pub memory_max_bytes: Option<u64>,
    #[serde(default)]
    pub cpu_max: Option<RunCpuMax>,
    /// Per-spec graceful shutdown window for VM-backed cells, in seconds
    /// (FC-21). When `Some(n)`, the host backend waits up to `n` seconds for
    /// the VM to exit after a graceful shutdown signal before sending
    /// SIGKILL. When `None`, the host falls back to its compiled-in default
    /// (see `cellos-host-firecracker::GRACEFUL_SHUTDOWN_TIMEOUT`).
    ///
    /// Schema-bounded to `[1, 120]` seconds; values outside that range are
    /// rejected at admission. Long-running cells with shutdown hooks (e.g.
    /// flushing a write-ahead log) can raise this; latency-sensitive cells
    /// can drop it to fail fast.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub graceful_shutdown_seconds: Option<u64>,
}

/// Typed cgroup-v2 style CPU quota request for `spec.run`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RunCpuMax {
    pub quota_micros: u64,
    #[serde(default)]
    pub period_micros: Option<u64>,
}

/// Typed egress rule from L0 (`network-egress-rule-v1`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EgressRule {
    pub host: String,
    pub port: u16,
    #[serde(default)]
    pub protocol: Option<String>,
    /// Free-text operator justification required when the
    /// `requireDnsEgressJustification` policy is enabled and `protocol` is
    /// `"dns-acknowledged"`. Records why DNS egress was accepted on this rule.
    /// Treated as authority **metadata** — does not participate in capability
    /// subset checks ([`AuthorityCapability::is_superset_of`]).
    #[serde(
        default,
        rename = "dnsEgressJustification",
        skip_serializing_if = "Option::is_none"
    )]
    pub dns_egress_justification: Option<String>,
}

/// Opaque linkage to an external job or workflow (mirror into CloudEvents `data` when emitting).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Correlation {
    #[serde(default)]
    pub platform: Option<String>,
    #[serde(default)]
    pub external_run_id: Option<String>,
    #[serde(default)]
    pub external_job_id: Option<String>,
    /// Multi-tenant boundary for events and quotas (A2-03).
    ///
    /// When set on `spec.correlation`, the supervisor surfaces this id as a
    /// top-level `tenantId` field on event `data` (see
    /// `crate::events::lifecycle_started_data_v1` /
    /// `lifecycle_destroyed_data_v1`) and propagates it into JetStream
    /// subjects via the `{tenantId}` template substitution
    /// (`cellos-sink-jetstream`). When `None`, the field is OMITTED from
    /// JSON entirely so single-tenant deployments produce byte-identical
    /// output to pre-A2-03 builds (backward-compatible).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tenant_id: Option<String>,
    #[serde(default)]
    pub labels: Option<HashMap<String, String>>,
    /// Stable cross-tool correlation ID (Tranche-1 seam-freeze G1).
    ///
    /// Populated automatically from [`SecretBroker::broker_correlation_id`]
    /// when the operator did not supply one — see SEC-16. An operator-supplied
    /// value in the spec takes precedence over broker-derived IDs. Allows
    /// downstream consumers (audit pipelines, taudit) to stitch a CellOS run
    /// together with the broker's exec session ID or rotation event ID.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub correlation_id: Option<String>,
}

/// Declared inputs for a cell (host still enforces via [`AuthorityBundle`]).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Ingress {
    #[serde(default)]
    pub git: Option<GitIngress>,
    #[serde(default)]
    pub oci_image: Option<OciImageIngress>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GitIngress {
    #[serde(default)]
    pub url: Option<String>,
    #[serde(default, rename = "ref")]
    pub git_ref: Option<String>,
    #[serde(default)]
    pub secret_ref: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OciImageIngress {
    pub reference: String,
    #[serde(default)]
    pub digest: Option<String>,
    #[serde(default)]
    pub secret_ref: Option<String>,
}

/// Declared execution environment for a cell run.
///
/// `imageReference` + `imageDigest` together pin the rootfs / container image
/// a backend should use for this cell. The backend MAY reject the spec when the
/// image is not locally available or does not match the declared digest.
///
/// `templateId` is an optional logical name that L4 / fleet schedulers can use
/// to select a pre-warmed pool or pre-pulled image cache rather than deriving
/// everything from the OCI reference.
///
/// **Guarantee:** the supervisor does **not** enforce image identity today —
/// that is an L2-06 Firecracker-backend responsibility. Declaring an environment
/// in the spec is the contract-forward move: the data is validated, carried in
/// CloudEvents, and will be consumed by the microVM backend.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EnvironmentSpec {
    /// OCI image reference used as the cell rootfs (e.g. `ubuntu:24.04`).
    pub image_reference: String,
    /// Optional content-addressable digest that pins the image to an exact layer
    /// set (e.g. `sha256:abc123…`). When set, backends SHOULD reject an image
    /// whose digest does not match.
    #[serde(default)]
    pub image_digest: Option<String>,
    /// Optional logical template identifier — a short portable name that fleet
    /// schedulers can use to route to a pre-warmed pool.  Must match
    /// `[A-Za-z0-9][A-Za-z0-9._-]{0,127}` if present.
    #[serde(default)]
    pub template_id: Option<String>,
}

/// Placement hints for multi-node fleet dispatchers (L3/L4 schedulers).
///
/// These fields are advisory — they guide where the cell runs but do not
/// change the authority model or isolation contract. The host backend ignores
/// them; only fleet schedulers and the state projector consume them.
///
/// (Formerly `PlacementHints`; renamed under T11 to align with the
/// `*Spec` naming used by the rest of `ExecutionCellSpec` member structs.)
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlacementSpec {
    /// Named pool of runners (e.g. `linux-amd64-standard`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pool_id: Option<String>,
    /// Kubernetes namespace the runner targets (fleet-scheduler hint).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub kubernetes_namespace: Option<String>,
    /// S3 / SQS queue name the fleet agent drains (cellos-fleet hint).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub queue_name: Option<String>,
}

/// Policy pack identity for attribution and compliance exports.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PolicyRef {
    #[serde(default)]
    pub pack_id: Option<String>,
    #[serde(default)]
    pub pack_version: Option<String>,
    #[serde(default)]
    pub bundle_digest: Option<String>,
}

/// Workload identity request for a cell. The broker materializes the resulting
/// credential under `secret_ref`, which should also appear in `authority.secretRefs`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorkloadIdentity {
    pub kind: WorkloadIdentityKind,
    pub provider: String,
    pub audience: String,
    #[serde(default)]
    pub subject: Option<String>,
    #[serde(default)]
    pub ttl_seconds: Option<u64>,
    pub secret_ref: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum WorkloadIdentityKind {
    FederatedOidc,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthorityBundle {
    #[serde(default)]
    pub filesystem: Option<Value>,
    #[serde(default)]
    pub network: Option<Value>,
    #[serde(default)]
    pub egress_rules: Option<Vec<EgressRule>>,
    #[serde(default)]
    pub secret_refs: Option<Vec<String>>,
    /// Optional cryptographic proof that this authority was narrowed from a root grant.
    /// When present, the supervisor verifies the proof before the cell runs.
    /// Optional at MVP — specs without a derivation token pass validation normally.
    #[serde(default)]
    pub authority_derivation: Option<AuthorityDerivationToken>,
    /// Declared DNS resolution intent (T13 / SEC-20). Carries resolver list,
    /// hostname allowlist, refresh hints, and the workload-DNS-block flag for
    /// SEC-21 (resolver refresh) and SEC-22 (L7 gate) dataplane work to consume.
    /// **Not enforced by the supervisor today** — pure contract layer.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dns_authority: Option<DnsAuthority>,
    /// Declared CDN intent for hostnames whose IPs churn frequently (T13 / SEC-20).
    /// Optional. Consumed by SEC-21/SEC-22 dataplane work; the supervisor does
    /// not enforce CDN policy today.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cdn_authority: Option<CdnAuthority>,
}

/// Declared DNS resolution intent — T13 / SEC-20 contract layer.
///
/// All fields are optional and advisory at MVP; the SEC-21 (resolver refresh)
/// and SEC-22 (L7 gate) dataplane components consume this struct to know which
/// resolvers a cell may use, which hostnames it may resolve, and how stale
/// cached answers may be.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DnsAuthority {
    /// Resolvers the cell may use. Empty = dataplane default policy applies.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub resolvers: Vec<DnsResolver>,
    /// Query types the dataplane DNS proxy permits. Empty = default
    /// `[A, AAAA, CNAME, HTTPS]`.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub allowed_query_types: Vec<DnsQueryType>,
    /// Hostnames (FQDN, optionally one leading `*.` wildcard) the cell may
    /// resolve. Enforcement happens at the DNS protocol layer in the L2-04
    /// extension.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub hostname_allowlist: Vec<String>,
    /// Hint to SEC-21 about how aggressively to refresh resolved IPs.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub refresh_policy: Option<DnsRefreshPolicy>,
    /// DNS rebinding mitigation policy (SEC-21 Phase 3e). When absent, no
    /// per-hostname response-IP tracking is applied — operator opts in by
    /// setting at least one field. Works alongside `refresh_policy.min_ttl_seconds`
    /// (TTL floor) which limits how often responses can change.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rebinding_policy: Option<DnsRebindingPolicy>,
    /// When true, SEC-22 dataplane blocks any direct port-53 traffic from the
    /// workload — resolution must go through declared resolvers.
    #[serde(default, skip_serializing_if = "is_false")]
    pub block_direct_workload_dns: bool,
    /// Block UDP/853 (DoQ default port) to non-resolver IPs. When true, the nft
    /// generator drops UDP/853 unless the destination matches a declared resolver
    /// in `dnsAuthority.resolvers[]`. This is the UDP-side mirror of
    /// `blockDirectWorkloadDns`. Phase 3d ships kernel-level blocking only — full
    /// QUIC ClientHello SNI inspection is Phase 4.
    #[serde(default, skip_serializing_if = "is_false")]
    pub block_udp_doq: bool,
    /// Block UDP/443 (HTTP/3) to non-resolver IPs. AGGRESSIVE — most modern web
    /// traffic uses HTTP/3, so this is operator-only opt-in for hardened cells
    /// that should never see HTTP/3 (e.g. CI runners that talk to TCP-only
    /// internal services). When true, the nft generator drops UDP/443 unless
    /// the destination matches a declared resolver. Default false.
    #[serde(default, skip_serializing_if = "is_false")]
    pub block_udp_http3: bool,
}

#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_false(b: &bool) -> bool {
    !*b
}

/// One resolver entry in [`DnsAuthority::resolvers`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DnsResolver {
    /// Stable identifier for this resolver within the spec — referenced by
    /// future drift events.
    pub resolver_id: String,
    /// Resolver endpoint. For `do53-*` this is `host:port`; for `dot`/`doh`/`doq`
    /// this is the URL/URI form the dataplane expects.
    pub endpoint: String,
    /// Wire protocol the dataplane uses to talk to this resolver.
    pub protocol: DnsResolverProtocol,
    /// Optional `kid` in `trust-keyset-v1` with `purpose=resolver_receipt` —
    /// binds resolver answers back to a signed key.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub trust_kid: Option<String>,
    /// SEC-21 Phase 3h — opt-in DNSSEC validation policy for this resolver.
    /// When absent (`None`), validation is disabled — the resolver behaves
    /// identically to P3a/P3e. When present, hickory-resolver's bundled
    /// DNSSEC validator (`ResolverOpts.validate=true`) runs against every
    /// A/AAAA response and validation failures emit
    /// `dns_authority_dnssec_failed` CloudEvents.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dnssec: Option<DnsResolverDnssecPolicy>,
}

/// SEC-21 Phase 3h — opt-in DNSSEC validation policy for a single
/// [`DnsResolver`].
///
/// When attached to a resolver, the SEC-21 supervisor-side resolver-refresh
/// path runs hickory-resolver's bundled DNSSEC validator against every
/// A/AAAA response. The validator chains back to the IANA root KSKs
/// (19036 + 20326) shipped in `hickory-proto`; operators MAY override the
/// trust-anchor source via `trust_anchors_path` or the
/// `CELLOS_DNSSEC_TRUST_ANCHORS_PATH` environment variable.
///
/// **Scope (Phase 3h):** validation runs ONLY on the supervisor-side
/// resolver-refresh ticker (closes the upstream-poisoning vector for the
/// audit/drift signal). The SEAM-1 in-netns DNS proxy that the workload
/// queries directly does NOT validate today — that's a separate Phase 3h.1
/// slice (workload-facing dataplane validation).
///
/// **Backward compat:** `Option<DnsResolverDnssecPolicy>` defaults to
/// `None` on every existing resolver entry, preserving the P3a/P3e
/// behaviour exactly. Operators opt in by setting the `dnssec` field on
/// the resolvers they want validated.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DnsResolverDnssecPolicy {
    /// When `true` (default when the block is present), every A/AAAA
    /// response from this resolver is DNSSEC-validated against the
    /// configured trust anchors. When `false`, the block is informational
    /// only — operators may set `false` during rollout to surface
    /// validation failures via events without dropping responses (the
    /// `failClosed` field still applies in audit-only mode for clarity).
    #[serde(default = "default_validate_true")]
    pub validate: bool,
    /// When `true` (production posture), DNSSEC validation failures DROP
    /// the resolver answer — the workload-facing target set is empty (the
    /// `dns_authority_drift` is still emitted with an empty
    /// `currentTargets`). When `false` (rollout posture, default), the
    /// answer is kept, the failure is observable via
    /// `dns_authority_dnssec_failed`, and the operator decides when to
    /// harden.
    #[serde(default)]
    pub fail_closed: bool,
    /// Optional override for the default trust-anchor file. Defaults to the
    /// IANA root KSK shipped with hickory-proto. The
    /// `CELLOS_DNSSEC_TRUST_ANCHORS_PATH` environment variable, when set,
    /// supersedes this field. **Hickory 0.24 limitation:** the resolver's
    /// public API does NOT accept a custom `TrustAnchor` injection point —
    /// `validate=true` always uses the bundled IANA defaults internally. This
    /// field is parsed and stamped into events as `trustAnchorSource` for
    /// audit/observability and forward compatibility; a future hickory
    /// release that exposes a public knob will let us plug in the
    /// operator-supplied keys.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub trust_anchors_path: Option<String>,
}

#[allow(clippy::trivially_copy_pass_by_ref)]
fn default_validate_true() -> bool {
    true
}

/// Wire protocol for a [`DnsResolver`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DnsResolverProtocol {
    Do53Udp,
    Do53Tcp,
    Dot,
    Doh,
    Doq,
}

/// DNS query types the dataplane DNS proxy may permit.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[allow(clippy::upper_case_acronyms)]
pub enum DnsQueryType {
    A,
    AAAA,
    CNAME,
    TXT,
    MX,
    SRV,
    NS,
    PTR,
    HTTPS,
    SVCB,
}

/// Map a wire-format DNS QTYPE (RFC 1035 + RFC 9460) to the bounded
/// [`DnsQueryType`] enum the proxy and authority contract speak.
///
/// Returns `None` when the QTYPE falls outside the policy-known set; the proxy
/// treats this as a `denied_query_type` decision rather than malformed input.
///
/// Mapping (decimal QTYPE → variant):
/// `1=A`, `2=NS`, `5=CNAME`, `12=PTR`, `15=MX`, `16=TXT`, `28=AAAA`, `33=SRV`,
/// `64=SVCB`, `65=HTTPS`. Everything else returns `None`.
#[must_use]
pub fn qtype_to_dns_query_type(qtype: u16) -> Option<DnsQueryType> {
    match qtype {
        1 => Some(DnsQueryType::A),
        2 => Some(DnsQueryType::NS),
        5 => Some(DnsQueryType::CNAME),
        12 => Some(DnsQueryType::PTR),
        15 => Some(DnsQueryType::MX),
        16 => Some(DnsQueryType::TXT),
        28 => Some(DnsQueryType::AAAA),
        33 => Some(DnsQueryType::SRV),
        64 => Some(DnsQueryType::SVCB),
        65 => Some(DnsQueryType::HTTPS),
        _ => None,
    }
}

/// Refresh hint for [`DnsAuthority`] consumed by SEC-21.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DnsRefreshPolicy {
    /// Floor for cached TTL — answers shorter than this are extended to this value.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub min_ttl_seconds: Option<u32>,
    /// Ceiling for serving stale answers when refresh fails.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_stale_seconds: Option<u32>,
    /// Refresh strategy. Defaults to `ttl-honor` at the dataplane when omitted.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub strategy: Option<DnsRefreshStrategy>,
}

/// Refresh strategy for [`DnsRefreshPolicy`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DnsRefreshStrategy {
    TtlHonor,
    FixedInterval,
    Manual,
}

/// SEC-21 Phase 3e — DNS rebinding mitigation policy.
///
/// Sibling to [`DnsRefreshPolicy`] on [`DnsAuthority`]. The TTL floor in
/// [`DnsRefreshPolicy::min_ttl_seconds`] (P3a) limits *how often* responses
/// can change; this struct limits *what* responses are allowed to change to.
/// Combined, the two policies structurally close the v0.4.0 honest residual
/// "DNS rebinding (TTL=0 / fast-flux) — resolver-side mitigation territory".
///
/// All fields are optional and the absence of the parent struct on
/// [`DnsAuthority`] means "no rebinding mitigation applied" — the operator
/// opts in by setting at least one field. Defaults reflect the audit-only
/// scope: starting posture is audit-only — operators observe novel-IP
/// behaviour for a baseline window before tightening to `reject_on_rebind=true`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DnsRebindingPolicy {
    /// Optional explicit per-hostname IP/CIDR allowlist that supersedes
    /// resolver answers. Format: `hostname:ip-or-cidr` strings. When set,
    /// resolver answers for `hostname` MUST be a subset of these IPs/CIDRs;
    /// novel IPs trigger `dns_authority_rebind_rejected` events. Empty list
    /// = no explicit allowlist (only the [`Self::max_novel_ips_per_hostname`]
    /// soft cap applies).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub response_ip_allowlist: Vec<String>,
    /// Soft cap on distinct IPs ever observed per hostname over the cell's
    /// lifetime. Default `4` covers typical CDN A-record rotation. Exceeding
    /// the cap triggers a `dns_authority_rebind_threshold` event and (when
    /// `reject_on_rebind=true`) drops the new IP from the resolved target
    /// set passed to the workload.
    #[serde(default = "default_max_novel_ips_per_hostname")]
    pub max_novel_ips_per_hostname: u32,
    /// When true, novel IPs that exceed [`Self::max_novel_ips_per_hostname`]
    /// OR are absent from [`Self::response_ip_allowlist`] (when set) are
    /// DROPPED from the resolved target set passed to the workload. When
    /// false (Phase 3e default), the events fire but the IP is still
    /// resolvable. Operators in audit-only mode start with `false`;
    /// production tightens to `true` after observing baseline.
    #[serde(default)]
    pub reject_on_rebind: bool,
}

fn default_max_novel_ips_per_hostname() -> u32 {
    4
}

impl Default for DnsRebindingPolicy {
    fn default() -> Self {
        Self {
            response_ip_allowlist: Vec::new(),
            max_novel_ips_per_hostname: default_max_novel_ips_per_hostname(),
            reject_on_rebind: false,
        }
    }
}

/// Declared CDN intent — T13 / SEC-20 contract layer.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CdnAuthority {
    /// CDN entries the cell expects to talk to.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub providers: Vec<CdnProvider>,
}

/// One CDN entry in [`CdnAuthority::providers`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CdnProvider {
    /// Stable identifier for the CDN provider (e.g. `cloudfront`, `fastly`).
    pub provider_id: String,
    /// FQDN or single-leading-wildcard hostname pattern this CDN entry applies to.
    pub hostname_pattern: String,
    /// Permit SNI != Host header for hostnames matching this pattern.
    /// Defaults to `false` (fronting forbidden).
    #[serde(default)]
    pub accept_fronting: bool,
}

/// Payload for `dev.cellos.events.cell.observability.v1.dns_authority_drift` (SEC-21).
///
/// Emitted by the host-controlled resolver refresh loop when the resolved
/// target set for a declared FQDN differs from the prior observation. Mirrors
/// `contracts/schemas/cell-observability-dns-authority-drift-v1.schema.json`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DnsAuthorityDrift {
    /// Schema version literal — always `"1.0.0"` for v1 payloads.
    pub schema_version: String,
    /// Portable cell identifier (matches `lifecycle.started` `cellId`).
    pub cell_id: String,
    /// Run UUID/identifier (matches `lifecycle.started` `runId`).
    pub run_id: String,
    /// Stable resolver identifier; references
    /// `spec.authority.dnsAuthority.resolvers[].resolverId`.
    pub resolver_id: String,
    /// FQDN whose resolution drifted.
    pub hostname: String,
    /// Targets observed in the prior resolution. Empty on first observation.
    pub previous_targets: Vec<String>,
    /// Targets observed in the current resolution.
    pub current_targets: Vec<String>,
    /// `currentTargets \ previousTargets`.
    pub added_targets: Vec<String>,
    /// `previousTargets \ currentTargets`.
    pub removed_targets: Vec<String>,
    /// Digest of the prior canonicalized target set; literal `"empty"` on
    /// first observation, otherwise `sha256:<hex>`.
    pub previous_digest: String,
    /// Digest of the current canonicalized target set: `sha256:<hex>`.
    pub current_digest: String,
    /// TTL the upstream returned for the current answer; `0` = unknown.
    pub ttl_seconds: u32,
    /// How long the previous answer was served past its TTL before this
    /// refresh fired.
    pub stale_seconds: u32,
    /// Optional — ties this drift event to a `trust-keyset-v1` document.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub keyset_id: Option<String>,
    /// Optional — ties this drift event to a specific kid in the keyset.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub issuer_kid: Option<String>,
    /// Optional — sha256 digest of the policy bundle in effect.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub policy_digest: Option<String>,
    /// Optional pass-through correlation id.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub correlation_id: Option<String>,
    /// SEC-21 Phase 3h — optional DNSSEC validation status for the resolver
    /// answer that produced this drift. Absent (`None`) means validation was
    /// not attempted (the resolver did not opt in via
    /// `dnsAuthority.resolvers[].dnssec`); the four enum string values are
    /// `"validated"` (chain of trust established), `"validation_failed"`
    /// (DNSSEC chain check rejected the response), `"unsigned"` (resolver
    /// returned answers but the zone is not signed), and `"not_attempted"`
    /// (validation was disabled even though a `dnssec` block was present —
    /// e.g. `validate=false`). Backward compat: P3a/P3e callers who have
    /// not opted into DNSSEC continue setting `None`, and the schema treats
    /// the field as optional.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dnssec_status: Option<String>,
    /// RFC3339 timestamp of when the drift was observed.
    pub observed_at: String,
}

/// Payload for `dev.cellos.events.cell.observability.v1.dns_authority_rebind_threshold`
/// (SEC-21 Phase 3e).
///
/// Emitted by the host-controlled resolver refresh loop when a novel IP for a
/// declared FQDN pushes the cumulative distinct-IP count past
/// `rebindingPolicy.maxNovelIpsPerHostname`. Mirrors
/// `contracts/schemas/cell-observability-dns-authority-rebind-threshold-v1.schema.json`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DnsAuthorityRebindThreshold {
    /// Schema version literal — always `"1.0.0"` for v1 payloads.
    pub schema_version: String,
    /// Portable cell identifier (matches `lifecycle.started` `cellId`).
    pub cell_id: String,
    /// Run UUID/identifier (matches `lifecycle.started` `runId`).
    pub run_id: String,
    /// FQDN whose cumulative observed IP count crossed the per-hostname cap.
    pub hostname: String,
    /// The novel IP whose addition pushed the cumulative count past the cap.
    /// Net-new — was not in the prior history.
    pub novel_ip: String,
    /// Distinct IPs observed for this hostname BEFORE the current tick.
    pub previous_ip_count: u32,
    /// Distinct IPs observed for this hostname AFTER the current tick
    /// (post-commit count). Always ≥ `previous_ip_count + 1` because at
    /// least the `novel_ip` was added.
    pub cumulative_ip_count: u32,
    /// Operator-declared cap from `rebindingPolicy.maxNovelIpsPerHostname`
    /// that was exceeded. Echoed into the event so the SIEM does not need to
    /// cross-reference the spec.
    pub max_novel_ips_per_hostname: u32,
    /// `sha256:<hex>` digest of the policy bundle in effect when this
    /// threshold was crossed.
    pub policy_digest: String,
    /// Optional — ties this event to a `trust-keyset-v1` document.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub keyset_id: Option<String>,
    /// Optional — ties this event to a specific kid in the keyset.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub issuer_kid: Option<String>,
    /// Optional pass-through correlation id.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub correlation_id: Option<String>,
    /// Optional resolver identifier; references
    /// `spec.authority.dnsAuthority.resolvers[].resolverId`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub resolver_id: Option<String>,
    /// RFC3339 timestamp of when the threshold was crossed.
    pub observed_at: String,
}

/// Payload for `dev.cellos.events.cell.observability.v1.dns_authority_rebind_rejected`
/// (SEC-21 Phase 3e).
///
/// Emitted by the host-controlled resolver refresh loop when a resolver answer
/// for a declared FQDN contains an IP NOT in the operator-declared
/// `rebindingPolicy.responseIpAllowlist`. Only emitted when the allowlist is
/// non-empty for the hostname; one event per offending IP per tick. Combined
/// with `rebindingPolicy.rejectOnRebind=true`, the IP is also dropped from
/// the resolved target set passed to the workload.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DnsAuthorityRebindRejected {
    /// Schema version literal — always `"1.0.0"` for v1 payloads.
    pub schema_version: String,
    /// Portable cell identifier (matches `lifecycle.started` `cellId`).
    pub cell_id: String,
    /// Run UUID/identifier (matches `lifecycle.started` `runId`).
    pub run_id: String,
    /// FQDN whose resolver answer contained an IP outside the operator
    /// allowlist.
    pub hostname: String,
    /// The IP that violated the allowlist. May or may not be net-new in the
    /// per-hostname history (allowlist enforcement is independent of the
    /// `max_novel_ips_per_hostname` cap).
    pub novel_ip: String,
    /// Distinct IPs observed for this hostname BEFORE the current tick.
    pub previous_ip_count: u32,
    /// Distinct IPs observed for this hostname AFTER the current tick
    /// (post-commit count).
    pub cumulative_ip_count: u32,
    /// Echo of the operator's `rebindingPolicy.responseIpAllowlist` entries
    /// that apply to this hostname (`hostname:ip-or-cidr` strings,
    /// hostname-prefix already filtered to the matching set). Stamped into
    /// the event so the SIEM does not need to cross-reference the spec.
    pub response_ip_allowlist: Vec<String>,
    /// `sha256:<hex>` digest of the policy bundle in effect when this
    /// rejection fired.
    pub policy_digest: String,
    /// Optional — ties this event to a `trust-keyset-v1` document.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub keyset_id: Option<String>,
    /// Optional — ties this event to a specific kid in the keyset.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub issuer_kid: Option<String>,
    /// Optional pass-through correlation id.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub correlation_id: Option<String>,
    /// Optional resolver identifier; references
    /// `spec.authority.dnsAuthority.resolvers[].resolverId`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub resolver_id: Option<String>,
    /// RFC3339 timestamp of when the rejection was observed.
    pub observed_at: String,
}

/// Why a SEC-21 Phase 3h DNSSEC validation failed for a resolver answer.
///
/// Mirrored as a string enum in
/// `contracts/schemas/cell-observability-dns-authority-dnssec-failed-v1.schema.json`
/// so the SIEM can dispatch on a stable code rather than the free-form
/// `reason_detail` field.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DnsAuthorityDnssecFailureReason {
    /// Hickory's bundled validator rejected the chain — RRSIG missing,
    /// signature bogus, or the response chained to a key not present in
    /// the configured trust anchors.
    ValidationFailed,
    /// Resolver returned answers, but the zone itself is not signed (no
    /// DNSKEY/DS chain). Operator policy decides whether this is allow or
    /// deny via `failClosed`.
    UnsignedZone,
    /// Configured trust-anchor source was missing or unreadable. The
    /// validator could not be wired and every response is treated as a
    /// failure for safety. Surfaces operator misconfiguration without
    /// crashing the supervisor.
    TrustAnchorMissing,
    /// Validator itself returned an unexpected internal error (parser
    /// crash, allocation failure, etc.). Reserved for future hickory
    /// upgrades; not emitted today.
    ValidatorInternal,
}

impl DnsAuthorityDnssecFailureReason {
    /// String form for direct stamping into the event payload (matches the
    /// schema's enum literals exactly).
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Self::ValidationFailed => "validation_failed",
            Self::UnsignedZone => "unsigned_zone",
            Self::TrustAnchorMissing => "trust_anchor_missing",
            Self::ValidatorInternal => "validator_internal",
        }
    }
}

/// Payload for `dev.cellos.events.cell.observability.v1.dns_authority_dnssec_failed`
/// (SEC-21 Phase 3h).
///
/// Emitted by the host-controlled resolver-refresh loop when an opt-in
/// DNSSEC-validating resolver returns a response that fails the chain-of-trust
/// check (or when the zone itself is not signed). Mirrors
/// `contracts/schemas/cell-observability-dns-authority-dnssec-failed-v1.schema.json`.
///
/// Combined with `dnssec.failClosed=true`, the offending answer is also
/// dropped before it reaches the rebinding evaluator / drift digest — this
/// closes the upstream-poisoning vector for the SEC-21 supervisor-side
/// resolver-refresh path. The SEAM-1 in-netns DNS proxy that the workload
/// queries directly does NOT validate today (Phase 3h.1 future).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DnsAuthorityDnssecFailed {
    /// Schema version literal — always `"1.0.0"` for v1 payloads.
    pub schema_version: String,
    /// Portable cell identifier (matches `lifecycle.started` `cellId`).
    pub cell_id: String,
    /// Run UUID/identifier (matches `lifecycle.started` `runId`).
    pub run_id: String,
    /// Stable resolver identifier; references
    /// `spec.authority.dnsAuthority.resolvers[].resolverId`. Empty string
    /// when no resolvers were declared (defensive — the supervisor
    /// production path always has at least one resolver before the ticker
    /// starts).
    pub resolver_id: String,
    /// FQDN whose resolution failed DNSSEC validation.
    pub hostname: String,
    /// Stable enum reason — operator alerting / SIEM dispatch keyer.
    pub reason: String,
    /// When `true`, the answer was DROPPED — the workload-facing target
    /// set is empty and the drift event reflects that. When `false`
    /// (audit-only mode, default), the answer was kept and the workload
    /// still resolves to it; this event records the failure but does not
    /// gate behaviour.
    pub fail_closed: bool,
    /// Source descriptor for the trust anchors the validator consulted —
    /// `"iana-default"` when the bundled root KSKs were used, otherwise the
    /// basename of the operator-provided file (full path NOT leaked).
    pub trust_anchor_source: String,
    /// Optional `sha256:<hex>` digest of the policy bundle in effect.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub policy_digest: Option<String>,
    /// Optional — ties this event to a `trust-keyset-v1` document.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub keyset_id: Option<String>,
    /// Optional — ties this event to a specific kid in the keyset.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub issuer_kid: Option<String>,
    /// Optional pass-through correlation id.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub correlation_id: Option<String>,
    /// SEC-21 Phase 3h.1 — additive optional discriminator: which validation
    /// surface produced this event. `"resolver_refresh"` for the
    /// supervisor-side resolver-refresh path (P3h, observability-only);
    /// `"dataplane"` for the in-netns DNS proxy validation path (P3h.1,
    /// enforcement). Optional in the v1 schema for one cycle so existing
    /// emitters without this field continue to validate; the next major
    /// schema bump should make this `required`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub source: Option<String>,
    /// RFC3339 timestamp of when the failure was observed.
    pub observed_at: String,
}

/// Proxy decision for a single DNS query observation (SEAM-1 / L2-04).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DnsQueryDecision {
    /// Query matched `dnsAuthority.hostnameAllowlist` and was forwarded upstream.
    Allow,
    /// Query did not match; proxy returned REFUSED (or dropped on malformed input).
    Deny,
}

/// Why the SEAM-1 / L2-04 DNS proxy emitted a particular [`DnsQueryEvent`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DnsQueryReasonCode {
    /// Hostname matched a literal or wildcard entry in `hostname_allowlist`.
    AllowedByAllowlist,
    /// Hostname was not in `hostname_allowlist`; proxy returned REFUSED.
    DeniedNotInAllowlist,
    /// Query type was not in `allowed_query_types` (or absent from the default
    /// `[A, AAAA, CNAME, HTTPS]` set when `allowed_query_types` was empty).
    DeniedQueryType,
    /// DNS message could not be parsed (truncated, multi-question, pointer
    /// compression, oversized labels). Proxy dropped the packet — no response.
    MalformedQuery,
    /// Upstream resolver timed out or returned an I/O error. Proxy responded
    /// with SERVFAIL.
    UpstreamFailure,
    /// SEC-21 Phase 3h.1 — dataplane DNSSEC validator rejected the
    /// upstream answer for an allowlisted query (require + Failed /
    /// Unsigned / Skip, OR best_effort + Failed). Proxy responded with
    /// SERVFAIL. The companion `dns_authority_dnssec_failed` CloudEvent
    /// (with `source: "dataplane"`) carries the per-failure detail
    /// (which validator outcome, which trust-anchor source).
    DeniedDnssec,
}

/// Payload for `dev.cellos.events.cell.observability.v1.dns_query` (SEAM-1 / L2-04).
///
/// Emitted by the in-netns DNS proxy on every query — allowed, denied,
/// malformed, or upstream-timed-out. Mirrors
/// `contracts/schemas/cell-observability-dns-query-v1.schema.json`.
///
/// Optional fields follow the same skip-when-None pattern as
/// [`DnsAuthorityDrift`] so payloads stay backward-compatible with the v1
/// schema as new optional fields land additively.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DnsQueryEvent {
    /// Schema version literal — always `"1.0.0"` for v1 payloads.
    pub schema_version: String,
    /// Portable cell identifier (matches `lifecycle.started` `cellId`).
    pub cell_id: String,
    /// Run UUID/identifier (matches `lifecycle.started` `runId`).
    pub run_id: String,
    /// Unique identifier for this emitted event (UUIDv4 in production).
    pub query_id: String,
    /// Hostname queried (lowercased, trailing-dot-stripped, RFC 1035 length
    /// bounds — at most 253 octets).
    pub query_name: String,
    /// DNS query type. See [`DnsQueryType`].
    pub query_type: DnsQueryType,
    /// Proxy decision: allow forwarded the query upstream; deny short-circuited
    /// with REFUSED (or dropped on malformed).
    pub decision: DnsQueryDecision,
    /// Why the proxy emitted this decision.
    pub reason_code: DnsQueryReasonCode,
    /// DNS RCODE returned to the workload (0=NOERROR, 2=SERVFAIL, 3=NXDOMAIN,
    /// 5=REFUSED). Omitted when no response was returned (malformed-query drop).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub response_rcode: Option<u8>,
    /// Stable resolver identifier; references
    /// `spec.authority.dnsAuthority.resolvers[].resolverId`. Omitted on denial /
    /// malformed paths where no upstream was contacted.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub upstream_resolver_id: Option<String>,
    /// Round-trip latency of the upstream forward, in milliseconds. Omitted on
    /// denial / malformed paths.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub upstream_latency_ms: Option<u64>,
    /// Number of A/AAAA records returned to the workload. `0` for non-A/AAAA
    /// queries, denials, or empty answers.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub response_target_count: Option<u32>,
    /// Optional — ties this query event to a `trust-keyset-v1` document.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub keyset_id: Option<String>,
    /// Optional — ties this query event to a specific kid in the keyset.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub issuer_kid: Option<String>,
    /// Optional — sha256 digest of the policy bundle in effect.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub policy_digest: Option<String>,
    /// Optional pass-through correlation id.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub correlation_id: Option<String>,
    /// RFC3339 timestamp of when the query was observed by the proxy.
    pub observed_at: String,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Lifetime {
    pub ttl_seconds: u64,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportChannels {
    #[serde(default)]
    pub artifacts: Option<Vec<ExportArtifact>>,
    #[serde(default)]
    pub targets: Option<Vec<ExportTarget>>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportArtifact {
    pub name: String,
    pub path: String,
    #[serde(default)]
    pub target: Option<String>,
    #[serde(default)]
    pub content_type: Option<String>,
}

/// Runtime metadata passed alongside an export push.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ExportArtifactMetadata {
    pub content_type: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum ExportTarget {
    Http(HttpExportTarget),
    S3(S3ExportTarget),
}

impl ExportTarget {
    pub fn name(&self) -> &str {
        match self {
            Self::Http(target) => &target.name,
            Self::S3(target) => &target.name,
        }
    }

    pub fn secret_ref(&self) -> Option<&str> {
        match self {
            Self::Http(target) => target.secret_ref.as_deref(),
            Self::S3(target) => target.secret_ref.as_deref(),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HttpExportTarget {
    pub name: String,
    pub base_url: String,
    #[serde(default)]
    pub secret_ref: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct S3ExportTarget {
    pub name: String,
    pub bucket: String,
    #[serde(default)]
    pub key_prefix: Option<String>,
    #[serde(default)]
    pub region: Option<String>,
    #[serde(default)]
    pub secret_ref: Option<String>,
}

/// Target-aware export result used for observability and audit.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportReceipt {
    pub target_kind: ExportReceiptTargetKind,
    #[serde(default)]
    pub target_name: Option<String>,
    pub destination: String,
    pub bytes_written: u64,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ExportReceiptTargetKind {
    Local,
    S3,
    Http,
}

// ── Inference broker types ─────────────────────────────────────────────────

/// Role of a message in an inference conversation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum InferenceRole {
    System,
    User,
    Assistant,
}

/// A single message in an [`InferenceRequest`] conversation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InferenceMessage {
    pub role: InferenceRole,
    pub content: String,
}

/// A request to the local inference runtime via [`crate::ports::InferenceBroker`].
///
/// `cell_id` is mandatory for attribution and audit — every inference call is tied
/// to the cell that issued it. `max_tokens` is an authority budget; the runtime
/// enforces it. `model_hint` is advisory; the runtime may ignore it.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InferenceRequest {
    /// Cell identity — required for attribution and observability.
    pub cell_id: String,
    pub messages: Vec<InferenceMessage>,
    #[serde(default)]
    pub max_tokens: Option<u32>,
    /// Advisory model preference; runtime selects the actual model.
    #[serde(default)]
    pub model_hint: Option<String>,
}

/// Response from the local inference runtime.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InferenceResponse {
    pub content: String,
    /// Model that actually served the request (for audit).
    pub model: String,
    pub input_tokens: u32,
    pub output_tokens: u32,
}

/// Resolved secret material from a [`crate::ports::SecretBroker`].
///
/// The `value` field is wrapped in [`zeroize::Zeroizing`] so its bytes are wiped
/// from memory when the struct (or any `Vec<SecretView>`) is dropped. The struct
/// also derives [`zeroize::ZeroizeOnDrop`] (E1-02) which provides a defensive
/// `Drop` impl that zeroes every field — including `key` — so that the secret
/// material and its identifier do not linger in freed allocations.
///
/// This implements the authority-shaping principle: secret material should not
/// outlive its use, and must not be silently duplicated — `SecretView` is
/// intentionally **not** `Clone` so that exposing the secret requires an
/// explicit `.as_str()` on the inner `Zeroizing<String>`.
///
/// Callers that hold a `Vec<SecretView>` should drop it explicitly after
/// injection rather than letting it linger for the remainder of the lifecycle.
///
/// # Debug redaction (D7)
///
/// `Zeroizing<String>` does **not** mask its `Debug` impl — printing one with
/// `{:?}` would dump the underlying secret. To keep stray `tracing::debug!`,
/// `panic!` formatting, or `dbg!()` calls from leaking secret material,
/// `SecretView` provides a hand-written `Debug` impl that emits the literal
/// string `<REDACTED>` in place of the value. The auto-derived Debug is
/// intentionally not used here.
#[derive(zeroize::ZeroizeOnDrop)]
pub struct SecretView {
    pub key: String,
    pub value: zeroize::Zeroizing<String>,
}

impl std::fmt::Debug for SecretView {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("SecretView")
            .field("kind", &self.key)
            .field("value", &"<REDACTED>")
            .finish()
    }
}

/// Minimal CloudEvents 1.0 JSON representation for [`crate::ports::EventSink`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CloudEventV1 {
    pub specversion: String,
    pub id: String,
    pub source: String,
    #[serde(rename = "type")]
    pub ty: String,
    #[serde(default)]
    pub datacontenttype: Option<String>,
    #[serde(default)]
    pub data: Option<Value>,
    #[serde(default)]
    pub time: Option<String>,
    #[serde(default)]
    pub traceparent: Option<String>,
}

// ── Authority lattice types (L5-01, L5-02) ────────────────────────────────

/// Typed, comparable slice of authority — the lattice element for subset checks.
/// Covers the two structured dimensions of `AuthorityBundle`; opaque `filesystem`
/// and `network` fields are handled separately via presence checks.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthorityCapability {
    #[serde(default)]
    pub egress_rules: Vec<EgressRule>,
    #[serde(default)]
    pub secret_refs: Vec<String>,
}

impl AuthorityCapability {
    /// Extracts the typed capability dimensions from a bundle (egress + secret refs only;
    /// filesystem is opaque and excluded until a typed FS authority schema is defined).
    pub fn from_bundle(bundle: &AuthorityBundle) -> Self {
        Self {
            egress_rules: bundle.egress_rules.clone().unwrap_or_default(),
            secret_refs: bundle.secret_refs.clone().unwrap_or_default(),
        }
    }

    /// Returns true if every egress rule and secret ref in `child` also appears in `self`.
    /// Treats `self` as the parent (broader) grant; `child` as the narrowed claim.
    pub fn is_superset_of(&self, child: &AuthorityCapability) -> bool {
        child.egress_rules.iter().all(|cr| {
            self.egress_rules.iter().any(|pr| {
                pr.host.eq_ignore_ascii_case(&cr.host)
                    && pr.port == cr.port
                    && pr.protocol == cr.protocol
            })
        }) && child
            .secret_refs
            .iter()
            .all(|cs| self.secret_refs.iter().any(|ps| ps == cs))
    }
}

/// Opaque role identifier — matches the key in the operator's authority-keys config.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoleId(pub String);

impl std::fmt::Display for RoleId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        self.0.fmt(f)
    }
}

/// A single narrowing step in a derivation chain (from broader to narrower).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthorityNarrowing {
    pub from_capability: AuthorityCapability,
    pub to_capability: AuthorityCapability,
}

/// Raw signature bytes with algorithm tag.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthoritySignature {
    /// Algorithm identifier — always "ed25519" for now.
    pub algorithm: String,
    /// Base64-encoded raw signature bytes.
    pub bytes: String,
}

/// Cryptographic proof that `leaf_capability` was narrowed from the `role_root` grant.
///
/// The grantor signs: sha256(canonical_json({ roleRoot, leafCapability, parentRunId }))
/// using their ED25519 private key. The supervisor verifies with the matching public key
/// loaded from `CELLOS_AUTHORITY_KEYS_PATH`.
///
/// `parent_run_id` is attribution metadata for taudit — it does NOT trigger a live
/// parent-spec lookup. The token is self-contained: `leaf_capability` embeds the
/// narrowed claim and the signature proves the grantor approved it.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthorityDerivationToken {
    /// Role identifier — must match a key in the operator's CELLOS_AUTHORITY_KEYS_PATH.
    pub role_root: RoleId,
    /// Optional parent run that produced this cell's authority — for taudit lineage.
    #[serde(default)]
    pub parent_run_id: Option<String>,
    /// Ordered chain of narrowing steps from root grant to leaf.
    #[serde(default)]
    pub derivation_steps: Vec<AuthorityNarrowing>,
    /// The narrowed authority this token grants — must be ⊆ spec.authority.
    pub leaf_capability: AuthorityCapability,
    /// ED25519 signature over the canonical JSON of { roleRoot, leafCapability, parentRunId }.
    pub grantor_signature: AuthoritySignature,
}

// ── Homeostasis types (L5-10) ──────────────────────────────────────────────

/// A component of authority that may be removable (feeds taudit recommendations).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum AuthorityComponent {
    EgressRule(EgressRule),
    SecretRef { name: String },
}

/// Per-run signal comparing exercised vs. declared authority.
/// Emitted as `dev.cellos.events.cell.authority.v1.homeostasis` between
/// `compliance.summary` and `lifecycle.destroyed`.
///
/// `exercised_egress_connections` is `None` at MVP (requires eBPF for real count); when
/// `None`, `exercised_egress_reason` carries the telemetry-pending diagnostic so consumers
/// can distinguish "telemetry not yet wired" from a true zero. Real connection counts
/// arrive with L2-04 full / L5-15.
///
/// Aggregation across many runs of the same `spec_hash` belongs in taudit, not CellOS core.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HomeostasisSignal {
    /// Canonical sha256 of the spec JSON (sorted keys, no whitespace) — taudit groups by this.
    pub spec_hash: String,
    pub declared_egress_rules: u32,
    /// `None` when exercised-egress telemetry is not yet available (see
    /// `exercised_egress_reason`). When `Some`, the value is the observed connection count.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub exercised_egress_connections: Option<u32>,
    /// Diagnostic reason explaining why `exercised_egress_connections` is `None` (e.g.
    /// `telemetry_pending_L2-04`). Only meaningful when the count is `None`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub exercised_egress_reason: Option<String>,
    pub declared_mount_paths: u32,
    pub accessed_mount_paths: u32,
    pub declared_secret_count: u32,
    /// exercised / declared, clamped to [0.0, 1.0]; 1.0 when declared == 0 or when
    /// `exercised_egress_connections` is `None` (telemetry pending — efficiency is not
    /// computable so we default to the most conservative non-actionable value).
    pub authority_efficiency: f64,
    /// Advisory — which components appear unused this run (multi-run aggregation in taudit).
    pub recommended_removals: Vec<AuthorityComponent>,
}

impl HomeostasisSignal {
    /// Compute exercised/declared efficiency, clamped to `[0.0, 1.0]`.
    ///
    /// Returns `1.0` when `declared == 0` (no surface to be inefficient about) **or** when
    /// `exercised` is `None` (telemetry pending — efficiency is not computable, so we
    /// default to the non-actionable end of the range rather than emitting a misleading
    /// 0.0 that would look like "all declared authority wasted").
    pub fn compute_efficiency(declared: u32, exercised: Option<u32>) -> f64 {
        if declared == 0 {
            return 1.0;
        }
        match exercised {
            None => 1.0,
            Some(e) => (e as f64 / declared as f64).min(1.0),
        }
    }
}

/// Compute a deterministic sha256 hex hash of the spec for cross-run grouping in taudit.
/// Uses JSON serialization with sorted keys (serde_json default for structs).
pub fn canonical_spec_hash(spec: &ExecutionCellSpec) -> String {
    use std::fmt::Write;
    let bytes = serde_json::to_vec(spec).unwrap_or_default();
    let digest = sha2_digest(&bytes);
    let mut hex = String::with_capacity(64);
    for b in &digest {
        write!(hex, "{b:02x}").ok();
    }
    format!("sha256:{hex}")
}

fn sha2_digest(data: &[u8]) -> [u8; 32] {
    use sha2::{Digest, Sha256};
    let mut hasher = Sha256::new();
    hasher.update(data);
    hasher.finalize().into()
}

// ── SEC-25: Signed trust keyset envelope ─────────────────────────────────────
//
// DSSE-shaped wrapper around a trust-keyset-v1 document. The schema of record
// is `contracts/schemas/signed-trust-keyset-envelope-v1.schema.json`; these
// Rust mirrors exist so cellos-core can verify envelopes and so callers can
// build them in tests. The inner keyset payload is intentionally NOT mirrored
// in Rust — verifiers receive raw decoded bytes from
// `crate::spec_validation::verify_signed_trust_keyset_envelope` and deserialize
// into whatever shape they want.

/// DSSE-shaped envelope wrapping a `trust-keyset-v1` document with one or more
/// Ed25519 signatures over the raw payload bytes (SEC-25 Phase 1).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SignedTrustKeysetEnvelope {
    /// Document format version. MUST be `"1.0.0"`.
    pub schema_version: String,
    /// DSSE-style media type. MUST be `"application/vnd.cellos.trust-keyset-v1+json"`.
    pub payload_type: String,
    /// Base64url-encoded canonical bytes of the inner trust-keyset-v1 document.
    pub payload: String,
    /// Non-empty list of signatures over the raw decoded payload bytes.
    pub signatures: Vec<TrustKeysetSignature>,
    /// `sha256:<hex>` over the raw decoded payload bytes (informational; verifiers recompute).
    pub payload_digest: String,
    /// RFC3339 instant the envelope was produced.
    pub produced_at: String,
    /// Optional `sha256:<hex>` of the immediately prior envelope this one supersedes.
    ///
    /// scope: when an oldest→head ordered chain of envelopes is fed through
    /// [`crate::spec_validation::verify_signed_trust_keyset_chain`], each
    /// non-genesis envelope MUST set this to `sha256:<hex>` of the prior
    /// envelope's raw decoded payload bytes. The genesis envelope MAY omit it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub replaces_envelope_digest: Option<String>,
    /// scope: multi-signer threshold — number of DISTINCT `signerKid`
    /// signatures that MUST verify for the envelope to be accepted.
    ///
    /// `None` and `Some(1)` both preserve single-signature semantics (any one
    /// signature suffices). The verifier clamps `Some(0)` to 1 defensively
    /// even though the schema's `minimum: 1` blocks that at the contract layer.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub required_signer_count: Option<u32>,
}

/// A single Ed25519 signature over the raw decoded payload bytes of a
/// `SignedTrustKeysetEnvelope`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TrustKeysetSignature {
    /// `kid` of the signing key. MUST reference a key in some trust-keyset-v1
    /// document the verifier trusts; cross-reference is operator-side.
    pub signer_kid: String,
    /// Signing algorithm. Phase 1 locks to `"ed25519"`.
    pub algorithm: String,
    /// Base64url-encoded raw 64-byte Ed25519 signature.
    pub signature: String,
    /// Optional RFC3339 instant before which this signature MUST NOT be accepted.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub not_before: Option<String>,
    /// Optional RFC3339 instant after which this signature MUST NOT be accepted.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub not_after: Option<String>,
}

/// Direction of a [`NetworkFlowDecision`] (FC-38).
///
/// scope: only [`NetworkFlowDirection::Egress`] is emitted today.
/// [`NetworkFlowDirection::Ingress`] is reserved for the future eBPF / nflog stream.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NetworkFlowDirection {
    /// Outbound traffic (workload -> external destination).
    Egress,
    /// Inbound traffic. Phase 1 does not emit this; Phase 2 placeholder.
    Ingress,
}

/// Allow / deny outcome attributed to a single nft rule (FC-38).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NetworkFlowDecisionOutcome {
    /// Rule was an `accept` (declared egress allow OR resolver allowlist allow).
    Allow,
    /// Rule was a `drop` (default-drop, workload-direct-DNS block, resolver-allowlist drop).
    Deny,
}

/// Payload for `dev.cellos.events.cell.observability.v1.network_flow_decision` (FC-38 Phase 1).
///
/// Per-rule attribution derived from the supervisor's post-run scan of
/// `nft list ruleset --json`. Honest scope: this is "which rule fired, with
/// what counter delta" — NOT a real-time per-packet stream. Phase 2 (future)
/// is eBPF / nflog real-time per-flow events. The `reason_code` field
/// distinguishes the four classes Phase 1 attributes today:
///
/// - `nft_default_drop` — chain default-policy DROP fired.
/// - `nft_workload_dns_block` — SEC-22 backstop `udp/tcp dport 53 drop` fired.
/// - `nft_resolver_allowlist_drop` — port-53 traffic hit the workload-DNS
///   block because it did not match the resolver allowlist (the upstream
///   `accept` rules for declared resolvers did not match this packet).
/// - `nft_egress_rule_match` — declared `authority.egressRules[]` accept
///   matched.
///
/// Mirrors `contracts/schemas/cell-observability-network-flow-decision-v1.schema.json`.
/// Optional fields follow the same skip-when-None pattern as
/// [`DnsAuthorityDrift`] so payloads stay backward-compatible with the v1
/// schema as new optional fields land additively.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NetworkFlowDecision {
    /// Schema version literal — always `"1.0.0"` for v1 payloads.
    pub schema_version: String,
    /// Portable cell identifier (matches `lifecycle.started` `cellId`).
    pub cell_id: String,
    /// Run UUID/identifier (matches `lifecycle.started` `runId`).
    pub run_id: String,
    /// Unique identifier for this emitted decision event (UUIDv4 in production).
    pub decision_id: String,
    /// Traffic direction. Phase 1 only emits [`NetworkFlowDirection::Egress`].
    pub direction: NetworkFlowDirection,
    /// Whether the matched rule was an accept (allow) or drop (deny).
    pub decision: NetworkFlowDecisionOutcome,
    /// Open-ended attribution code (Phase 1 emits one of the four classes
    /// documented on the struct doc). Kept as `String` rather than `enum` so
    /// new codes added in Phase 2 land additively without a SemVer break.
    pub reason_code: String,
    /// Optional. References the matched rule's declaration site
    /// (e.g. `authority.egressRules[0]`, `dnsAuthority.resolvers[0]`,
    /// or the synthetic `default-drop`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub nft_rule_ref: Option<String>,
    /// Optional. Destination IP/CIDR the rule matched. Omitted when the data
    /// source cannot surface it (e.g. policy-level default-drop has no daddr).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dst_addr: Option<String>,
    /// Optional. Destination port matched by the rule.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dst_port: Option<u16>,
    /// Optional. Transport protocol matched (`udp`, `tcp`, `icmp`, etc.).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub protocol: Option<String>,
    /// Optional. nft packet counter at observation time. `0` means the rule
    /// applied but no traffic matched.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub packet_count: Option<u64>,
    /// Optional. nft byte counter at observation time.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub byte_count: Option<u64>,
    /// Optional. sha256 digest of the policy bundle in effect.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub policy_digest: Option<String>,
    /// Optional — ties this decision event to a `trust-keyset-v1` document.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub keyset_id: Option<String>,
    /// Optional — ties this decision event to a specific kid in the keyset.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub issuer_kid: Option<String>,
    /// Optional pass-through correlation id.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub correlation_id: Option<String>,
    /// RFC3339 timestamp of when the supervisor scraped the nft counter.
    pub observed_at: String,
}

#[cfg(test)]
mod tests {
    use super::*;

    fn rule(host: &str, port: u16, protocol: Option<&str>) -> EgressRule {
        EgressRule {
            host: host.into(),
            port,
            protocol: protocol.map(|s| s.to_string()),
            dns_egress_justification: None,
        }
    }

    fn minimal_spec(id: &str) -> ExecutionCellSpec {
        ExecutionCellSpec {
            id: id.into(),
            correlation: None,
            ingress: None,
            environment: None,
            placement: None,
            policy: None,
            identity: None,
            run: None,
            authority: AuthorityBundle::default(),
            lifetime: Lifetime { ttl_seconds: 60 },
            export: None,
            telemetry: None,
        }
    }

    #[test]
    fn is_superset_of_egress_subset_passes() {
        let parent = AuthorityCapability {
            egress_rules: vec![
                rule("api.example.com", 443, Some("https")),
                rule("db.internal", 5432, Some("tcp")),
            ],
            secret_refs: vec![],
        };
        let child = AuthorityCapability {
            egress_rules: vec![rule("api.example.com", 443, Some("https"))],
            secret_refs: vec![],
        };
        assert!(parent.is_superset_of(&child));
    }

    #[test]
    fn is_superset_of_egress_not_subset_fails() {
        let parent = AuthorityCapability {
            egress_rules: vec![rule("api.example.com", 443, Some("https"))],
            secret_refs: vec![],
        };
        let child = AuthorityCapability {
            egress_rules: vec![rule("evil.example.com", 443, Some("https"))],
            secret_refs: vec![],
        };
        assert!(!parent.is_superset_of(&child));
    }

    #[test]
    fn is_superset_of_egress_protocol_mismatch_fails() {
        let parent = AuthorityCapability {
            egress_rules: vec![rule("api.example.com", 443, Some("https"))],
            secret_refs: vec![],
        };
        let child = AuthorityCapability {
            egress_rules: vec![rule("api.example.com", 443, Some("tcp"))],
            secret_refs: vec![],
        };
        assert!(!parent.is_superset_of(&child));
    }

    #[test]
    fn is_superset_of_egress_host_case_insensitive() {
        let parent = AuthorityCapability {
            egress_rules: vec![rule("API.example.com", 443, Some("https"))],
            secret_refs: vec![],
        };
        let child = AuthorityCapability {
            egress_rules: vec![rule("api.example.com", 443, Some("https"))],
            secret_refs: vec![],
        };
        assert!(parent.is_superset_of(&child));
    }

    #[test]
    fn is_superset_of_secrets_subset_passes() {
        let parent = AuthorityCapability {
            egress_rules: vec![],
            secret_refs: vec!["db-pass".into(), "api-key".into()],
        };
        let child = AuthorityCapability {
            egress_rules: vec![],
            secret_refs: vec!["api-key".into()],
        };
        assert!(parent.is_superset_of(&child));
    }

    #[test]
    fn is_superset_of_secrets_not_subset_fails() {
        let parent = AuthorityCapability {
            egress_rules: vec![],
            secret_refs: vec!["db-pass".into()],
        };
        let child = AuthorityCapability {
            egress_rules: vec![],
            secret_refs: vec!["root-token".into()],
        };
        assert!(!parent.is_superset_of(&child));
    }

    #[test]
    fn compute_efficiency_zero_declared_returns_one() {
        assert_eq!(HomeostasisSignal::compute_efficiency(0, Some(0)), 1.0);
        assert_eq!(HomeostasisSignal::compute_efficiency(0, Some(7)), 1.0);
        assert_eq!(HomeostasisSignal::compute_efficiency(0, None), 1.0);
    }

    #[test]
    fn compute_efficiency_normal_cases() {
        assert_eq!(HomeostasisSignal::compute_efficiency(4, Some(2)), 0.5);
        assert_eq!(HomeostasisSignal::compute_efficiency(4, Some(4)), 1.0);
        // Clamped to 1.0 when exercised somehow exceeds declared.
        assert_eq!(HomeostasisSignal::compute_efficiency(2, Some(5)), 1.0);
    }

    #[test]
    fn compute_efficiency_none_exercised_is_non_actionable() {
        // Telemetry pending: efficiency must default to 1.0 (non-actionable), not 0.0,
        // so taudit doesn't flag "all declared authority wasted" while L2-04 is unwired.
        assert_eq!(HomeostasisSignal::compute_efficiency(3, None), 1.0);
        assert_eq!(HomeostasisSignal::compute_efficiency(99, None), 1.0);
    }

    #[test]
    fn canonical_spec_hash_is_deterministic() {
        let spec = minimal_spec("hash-determinism");
        let h1 = canonical_spec_hash(&spec);
        let h2 = canonical_spec_hash(&spec);
        assert_eq!(h1, h2);
        assert!(h1.starts_with("sha256:"));
        assert_eq!(h1.len(), "sha256:".len() + 64);
    }

    #[test]
    fn canonical_spec_hash_differs_for_different_specs() {
        let a = minimal_spec("spec-a");
        let b = minimal_spec("spec-b");
        assert_ne!(canonical_spec_hash(&a), canonical_spec_hash(&b));
    }

    // ── SEC-16: broker-supplied correlation_id field ──────────────────────
    #[test]
    fn correlation_with_correlation_id_serialises_camel_case() {
        let c = Correlation {
            correlation_id: Some("abc".into()),
            ..Default::default()
        };
        let v = serde_json::to_value(&c).unwrap();
        assert_eq!(v.get("correlationId"), Some(&serde_json::json!("abc")));
        assert!(v.get("correlation_id").is_none());
    }

    #[test]
    fn correlation_without_correlation_id_omits_field() {
        let c = Correlation::default();
        let v = serde_json::to_value(&c).unwrap();
        assert!(
            v.get("correlationId").is_none(),
            "correlationId must be omitted when None (skip_serializing_if), got: {v}"
        );
    }

    // ── T11 PlacementSpec ─────────────────────────────────────────────────
    #[test]
    fn placement_spec_serialises_camel_case() {
        let placement = PlacementSpec {
            pool_id: Some("p1".into()),
            kubernetes_namespace: None,
            queue_name: None,
        };
        let json = serde_json::to_string(&placement).expect("serialize PlacementSpec");
        assert_eq!(json, r#"{"poolId":"p1"}"#);
    }

    #[test]
    fn execution_cell_spec_with_placement_roundtrips() {
        let raw = r#"{
            "apiVersion": "cellos.io/v1",
            "kind": "ExecutionCell",
            "spec": {
                "id": "placement-roundtrip-001",
                "placement": {
                    "poolId": "linux-amd64-standard",
                    "kubernetesNamespace": "cellos-runners",
                    "queueName": "fleet-jobs"
                },
                "authority": {},
                "lifetime": { "ttlSeconds": 60 }
            }
        }"#;
        let doc: ExecutionCellDocument = serde_json::from_str(raw).expect("parse example document");
        let placement = doc.spec.placement.clone().expect("placement present");
        assert_eq!(placement.pool_id.as_deref(), Some("linux-amd64-standard"));
        assert_eq!(
            placement.kubernetes_namespace.as_deref(),
            Some("cellos-runners")
        );
        assert_eq!(placement.queue_name.as_deref(), Some("fleet-jobs"));
        let serialized = serde_json::to_string(&doc).expect("serialize doc");
        let doc2: ExecutionCellDocument = serde_json::from_str(&serialized).expect("re-parse doc");
        assert_eq!(doc2.spec.placement, doc.spec.placement);
    }
}
