//! Source-level parser for `#[immune(..., requires = <predicate>)]` and
//! `#[antigen_tolerance(..., requires = <predicate>)]` macro arguments.
//!
//! ## Why this lives here (and not in `antigen-macros`)
//!
//! The substrate-witness predicate AST ([`crate::Predicate`]) is the runtime
//! type the audit evaluator consumes. The macro-side AST that parses
//! `requires = all_of([...])` syntax was originally a local mirror inside
//! `antigen-macros`, and the scan layer rediscovered the JSON via a
//! `#[doc = " antigen:requires:v1:<json>"]` marker emitted by the macro.
//!
//! That round-trip was load-bearing: scan walks **written source**, not
//! post-expansion source. The doc-marker only exists after macro expansion,
//! so scan never saw it. The substrate-witness pipeline silently failed
//! (`antigen audit` reported `tier = None, hint = NoneApplicable` for every
//! `requires = ...` site, even the shipped example).
//!
//! Fix: co-locate the source-attribute parser with the predicate type, gate
//! it behind a `parser` feature so the runtime crate stays syn-free by
//! default. Both the proc-macro crate and the scan layer turn the feature
//! on and use a single parser. The macro continues to emit the doc marker
//! for forward-compatibility, but discovery now happens via source-attr
//! parsing — independent of macro expansion.
//!
//! ## Format contract
//!
//! [`RequiresExpr::to_json`] produces the exact JSON that
//! `serde_json::to_string(&crate::Predicate)` produces. The scan layer
//! round-trips via `serde_json::from_str::<crate::Predicate>(&json)` to
//! feed the audit evaluator. The format is locked by the test fixtures
//! at the bottom of this module; any divergence between the macro-side
//! AST and the runtime [`crate::Predicate`] serde representation is a
//! bug.
//!
//! ## Grammar parsed (mirrors `Predicate` grammar, ADR-019 §M2)
//!
//! ```text
//! requires = leaf
//!         | all_of([predicate, ...])
//!         | any_of([predicate, ...])
//!         | not(predicate)
//!
//! leaf := ratified_doc[(path=..., min_version=..., anchor=..., sibling_json=...)]
//!       | signers(required = [...], roles = {name = "role"},
//!                against = "current"|"any",
//!                signature_allow = ["text-stamp"|"git-trust"|"crypto-signed", ...],
//!                signature_prefer = "text-stamp"|"git-trust"|"crypto-signed")
//!       | signed_trailer(key = "...", role = "...", count = N)
//!       | oracles_complete(files = ["...", ...])
//!       | fresh_within_days(N) | fresh_within_days(days = N)
//! ```

use proc_macro2::Span;
use syn::parse::{Parse, ParseStream};
use syn::{Ident, LitBool, LitInt, LitStr, Token};

// ============================================================================
// RequiresExpr — local mirror of crate::Predicate for compile-time parsing
//
// JSON format (must match crate::Predicate serde):
//   AllOf  -> {"kind":"all_of","children":[...]}
//   AnyOf  -> {"kind":"any_of","children":[...]}
//   Not    -> {"kind":"not","child":{...}}
//   Leaf   -> {"kind":"leaf","leaf":{"name":"<leaf_name>",...leaf_fields...}}
// ============================================================================

/// Local predicate AST — mirrors [`crate::Predicate`] without forcing
/// `proc-macro2`/`syn` on every consumer of the runtime crate.
#[derive(Debug, Clone)]
pub enum RequiresExpr {
    /// A single leaf primitive.
    Leaf(LeafExpr),
    /// `all_of([...])` combinator (every child must pass).
    AllOf(Vec<Self>),
    /// `any_of([...])` combinator (at least one child must pass).
    AnyOf(Vec<Self>),
    /// `not(...)` combinator (child must not pass).
    Not(Box<Self>),
}

/// Local leaf AST — mirrors [`crate::Leaf`].
#[derive(Debug, Clone)]
pub enum LeafExpr {
    /// `ratified_doc(path=..., min_version=..., anchor=..., sibling_json=...)`.
    RatifiedDoc {
        /// Optional doc path (relative to workspace root).
        path: Option<String>,
        /// Optional minimum frontmatter version (semver-cmp).
        min_version: Option<String>,
        /// Optional anchor string the doc body must contain.
        anchor: Option<String>,
        /// Whether to require a sibling JSON file at the same path.
        sibling_json: bool,
    },
    /// `signers(required = [...], roles = {name = "role"}, against = "current"|"any")`.
    Signers {
        /// Required signer names (non-empty per NFA-7).
        required: Vec<String>,
        /// Optional role assertions: signer name → expected role.
        /// Every key must appear in `required` (sub-clause F).
        roles: std::collections::BTreeMap<String, String>,
        /// Whether to require currency against the live fingerprint.
        against: SignerCurrencyExpr,
        /// Optional categorical allow-set of acceptable signature strengths
        /// (per ABO/Rh biology, decisions.md §grammar B6). Empty = any
        /// strength accepted.
        signature_allow: Vec<crate::tier::SignatureStrength>,
        /// Optional preferred signature strength (advisory; does not gate).
        signature_prefer: Option<crate::tier::SignatureStrength>,
    },
    /// `signed_trailer(key = "...", role = "...", count = N)`.
    SignedTrailer {
        /// The trailer key, e.g. `"Discipline-Verified-By"`.
        key: String,
        /// Optional role token filter.
        role: Option<String>,
        /// Required count (>= 1; default 1).
        count: u32,
    },
    /// `oracles_complete(files = ["a.md", "b.md"])`.
    OraclesComplete {
        /// Oracle file paths that must each report `status: complete`.
        files: Vec<String>,
    },
    /// `fresh_within_days(N)` / `fresh_within_days(days = N)`.
    FreshWithinDays {
        /// Maximum age in days for the most recent signature.
        days: u32,
    },
    /// `dep_pinned(crate?)` — ADR-025 supply-chain leaf. See
    /// [`crate::Leaf::DepPinned`] for semantics.
    DepPinned {
        /// Optional crate name to scope the check (positional first arg
        /// or `crate = "..."`).
        crate_name: Option<String>,
    },
    /// `dep_attested("crate", "version", exact_version=true,
    /// reviewable_artifact="...")` — ADR-025 supply-chain leaf. See
    /// [`crate::Leaf::DepAttested`].
    DepAttested {
        /// Crate name.
        crate_name: String,
        /// Version string.
        version: String,
        /// Whether the sidecar's version must match exactly.
        exact_version: bool,
        /// Optional specific reviewable-artifact path to require.
        reviewable_artifact: Option<String>,
    },
    /// `maintainer_unchanged("crate", "since_version")` — ADR-025
    /// supply-chain leaf. See [`crate::Leaf::MaintainerUnchanged`].
    MaintainerUnchanged {
        /// Crate name.
        crate_name: String,
        /// Anchor version.
        since_version: String,
    },
    /// `content_hash_matches("crate", "version")` — ADR-025 NON-NEGOTIABLE
    /// supply-chain leaf. See [`crate::Leaf::ContentHashMatches`].
    ContentHashMatches {
        /// Crate name.
        crate_name: String,
        /// Crate version.
        version: String,
    },
    /// `sandbox_clean("crate", sandbox_kind = "build"|"proc-macro")`
    /// — ADR-025 supply-chain leaf. See [`crate::Leaf::SandboxClean`].
    SandboxClean {
        /// Crate name.
        crate_name: String,
        /// Sandbox kind: `"build"` or `"proc-macro"`.
        sandbox_kind: String,
    },
}

/// Local mirror of [`crate::predicate::SignerCurrency`].
#[derive(Debug, Clone, Copy, Default)]
pub enum SignerCurrencyExpr {
    /// Signers must be current against the live fingerprint (serde default).
    #[default]
    Current,
    /// Signers may be against any historical fingerprint.
    Any,
}

impl RequiresExpr {
    /// Convert to the runtime [`crate::Predicate`] AST.
    ///
    /// This is the load-bearing lowering: scan and audit talk to each
    /// other via JSON, and the JSON shape is whatever
    /// `serde_json::to_string(&crate::Predicate)` produces. Going through
    /// the real Predicate type — rather than hand-rolling the JSON — keeps
    /// the wire format and the runtime type locked in step automatically.
    ///
    /// rc.1 history: the macro-side AST hand-rolled JSON that did NOT
    /// match the runtime Predicate serde format (used `{"leaf": {...}}`
    /// instead of flat fields), so the round-trip silently failed and
    /// every substrate-witness audit reported sidecar-schema-invalid.
    /// rc.2 routes through the real type, eliminating the class.
    #[must_use]
    pub fn to_predicate(&self) -> crate::Predicate {
        match self {
            Self::AllOf(children) => crate::Predicate::AllOf {
                children: children.iter().map(Self::to_predicate).collect(),
            },
            Self::AnyOf(children) => crate::Predicate::AnyOf {
                children: children.iter().map(Self::to_predicate).collect(),
            },
            Self::Not(child) => crate::Predicate::Not {
                child: Box::new(child.to_predicate()),
            },
            Self::Leaf(leaf) => crate::Predicate::Leaf(leaf.to_leaf()),
        }
    }

    /// Lower to the JSON string consumed by the scan layer.
    ///
    /// Format matches `serde_json::to_string(&crate::Predicate)` — by
    /// construction, because we go through the real type. The
    /// `serde_json::to_string` call is infallible for these inputs (no
    /// floats, no NaN, no maps with non-string keys), but we use
    /// `unwrap_or_else` with a hard-failure marker rather than `unwrap()`
    /// so a panic would surface a debuggable string in audit output
    /// instead of crashing the proc-macro.
    #[must_use]
    pub fn to_json(&self) -> String {
        serde_json::to_string(&self.to_predicate()).unwrap_or_else(|e| {
            format!("{{\"kind\":\"leaf\",\"name\":\"_to_json_failed\",\"error\":\"{e}\"}}")
        })
    }

    /// Validate semantic invariants at parse time (mirrors
    /// [`crate::Predicate::validate`]).
    ///
    /// # Errors
    ///
    /// Returns [`syn::Error`] anchored at `span` if any of:
    /// - empty `all_of`/`any_of` combinator (R-A6)
    /// - nesting depth exceeds [`crate::predicate::MAX_PREDICATE_DEPTH`]
    ///   (NFA-11)
    /// - any leaf fails its per-leaf validation (NFA-7, NFA-8, NFA-9,
    ///   NFA-14, NFA-15)
    pub fn validate(&self, span: Span) -> syn::Result<()> {
        // Depth + node-count guard (mirrors crate::Predicate::validate).
        // NOTE: these run AFTER the parse tree is constructed; they do not prevent
        // a stack overflow during parse of a truly pathological input. In practice,
        // proc-macro stack depth is generous enough that parse-time overflow requires
        // thousands of nesting levels — far beyond any legitimate predicate.
        self.check_depth(0, span)?;
        self.validate_inner(span)
    }

    fn check_depth(&self, depth: usize, span: Span) -> syn::Result<()> {
        if depth > crate::predicate::MAX_PREDICATE_DEPTH {
            return Err(syn::Error::new(
                span,
                format!(
                    "requires: predicate nesting depth exceeds maximum of {}; \
                     deeply-nested predicates are rejected (mirrors antigen_attestation \
                     MAX_PREDICATE_DEPTH guard)",
                    crate::predicate::MAX_PREDICATE_DEPTH
                ),
            ));
        }
        match self {
            Self::AllOf(children) | Self::AnyOf(children) => {
                for c in children {
                    c.check_depth(depth + 1, span)?;
                }
                Ok(())
            }
            Self::Not(child) => child.check_depth(depth + 1, span),
            Self::Leaf(_) => Ok(()),
        }
    }

    fn validate_inner(&self, span: Span) -> syn::Result<()> {
        match self {
            Self::AllOf(children) if children.is_empty() => Err(syn::Error::new(
                span,
                "requires: `all_of([])` is a semantic no-op (R-A6); add at least one child",
            )),
            Self::AnyOf(children) if children.is_empty() => Err(syn::Error::new(
                span,
                "requires: `any_of([])` is a semantic no-op (R-A6); add at least one child",
            )),
            Self::AllOf(children) | Self::AnyOf(children) => {
                for child in children {
                    child.validate_inner(span)?;
                }
                Ok(())
            }
            Self::Not(child) => child.validate_inner(span),
            Self::Leaf(leaf) => leaf.validate(span),
        }
    }
}

impl LeafExpr {
    /// Convert to the runtime [`crate::Leaf`]. Mirror of
    /// [`RequiresExpr::to_predicate`] at the leaf level.
    fn to_leaf(&self) -> crate::Leaf {
        match self {
            Self::RatifiedDoc {
                path,
                min_version,
                anchor,
                sibling_json,
            } => crate::Leaf::RatifiedDoc {
                path: path.as_ref().map(std::path::PathBuf::from),
                min_version: min_version.clone(),
                anchor: anchor.clone(),
                sibling_json: *sibling_json,
            },
            Self::Signers {
                required,
                roles,
                against,
                signature_allow,
                signature_prefer,
            } => crate::Leaf::Signers {
                required: required.clone(),
                roles: roles.clone(),
                against: match against {
                    SignerCurrencyExpr::Current => crate::predicate::SignerCurrency::Current,
                    SignerCurrencyExpr::Any => crate::predicate::SignerCurrency::Any,
                },
                signature_allow: signature_allow.clone(),
                signature_prefer: *signature_prefer,
            },
            Self::SignedTrailer { key, role, count } => crate::Leaf::SignedTrailer {
                key: key.clone(),
                role: role.clone(),
                count: *count,
            },
            Self::OraclesComplete { files } => crate::Leaf::OraclesComplete {
                files: files.iter().map(std::path::PathBuf::from).collect(),
            },
            Self::FreshWithinDays { days } => crate::Leaf::FreshWithinDays { days: *days },
            Self::DepPinned { crate_name } => crate::Leaf::DepPinned {
                crate_name: crate_name.clone(),
            },
            Self::DepAttested {
                crate_name,
                version,
                exact_version,
                reviewable_artifact,
            } => crate::Leaf::DepAttested {
                crate_name: crate_name.clone(),
                version: version.clone(),
                exact_version: *exact_version,
                reviewable_artifact: reviewable_artifact.clone(),
            },
            Self::MaintainerUnchanged {
                crate_name,
                since_version,
            } => crate::Leaf::MaintainerUnchanged {
                crate_name: crate_name.clone(),
                since_version: since_version.clone(),
            },
            Self::ContentHashMatches {
                crate_name,
                version,
            } => crate::Leaf::ContentHashMatches {
                crate_name: crate_name.clone(),
                version: version.clone(),
            },
            Self::SandboxClean {
                crate_name,
                sandbox_kind,
            } => crate::Leaf::SandboxClean {
                crate_name: crate_name.clone(),
                sandbox_kind: sandbox_kind.clone(),
            },
        }
    }

    fn validate(&self, span: Span) -> syn::Result<()> {
        match self {
            Self::Signers { required, .. } if required.is_empty() => Err(syn::Error::new(
                span,
                "requires: `signers(required = [])` is a semantic no-op (NFA-7); \
                 add at least one required signer name"
            )),
            Self::OraclesComplete { files } if files.is_empty() => Err(syn::Error::new(
                span,
                "requires: `oracles_complete(files = [])` is a semantic no-op (NFA-8); \
                 add at least one oracle file path",
            )),
            Self::SignedTrailer { count: 0, .. } => Err(syn::Error::new(
                span,
                "requires: `signed_trailer(count = 0)` is vacuously true (NFA-9); \
                 use count >= 1 (default is 1)",
            )),
            Self::RatifiedDoc {
                anchor: Some(a), ..
            } if a.is_empty() => Err(syn::Error::new(
                span,
                "requires: `ratified_doc(anchor = \"\")` is vacuously true (NFA-14); \
                 str::contains(\"\") always succeeds — specify a non-empty anchor string",
            )),
            Self::RatifiedDoc {
                min_version: Some(v),
                ..
            } if v.is_empty() => Err(syn::Error::new(
                span,
                "requires: `ratified_doc(min_version = \"\")` is vacuously true (NFA-15); \
                 any versioned doc passes an empty floor — specify a non-empty version string",
            )),
            // ADR-025 supply-chain leaves — empty-string semantic-no-op guards.
            Self::DepAttested {
                crate_name,
                version,
                ..
            } if crate_name.is_empty() || version.is_empty() => Err(syn::Error::new(
                span,
                "requires: `dep_attested` requires non-empty crate name and version; \
                 empty fields are semantically meaningless (ADR-025).",
            )),
            Self::MaintainerUnchanged {
                crate_name,
                since_version,
            } if crate_name.is_empty() || since_version.is_empty() => Err(syn::Error::new(
                span,
                "requires: `maintainer_unchanged` requires non-empty crate name and since_version; \
                 empty fields are semantically meaningless (ADR-025).",
            )),
            Self::ContentHashMatches {
                crate_name,
                version,
            } if crate_name.is_empty() || version.is_empty() => Err(syn::Error::new(
                span,
                "requires: `content_hash_matches` requires non-empty crate name and version; \
                 empty fields are semantically meaningless (ADR-025 NON-NEGOTIABLE).",
            )),
            Self::SandboxClean {
                crate_name,
                sandbox_kind,
            } if crate_name.is_empty()
                || (sandbox_kind != "build" && sandbox_kind != "proc-macro") =>
            {
                Err(syn::Error::new(
                    span,
                    "requires: `sandbox_clean` requires non-empty crate name AND \
                     sandbox_kind ∈ {\"build\", \"proc-macro\"} (ADR-025 §Substrate-witness-leaves).",
                ))
            }
            _ => Ok(()),
        }
    }
}

// ============================================================================
// RequiresExpr parsing
// ============================================================================

impl Parse for RequiresExpr {
    fn parse(input: ParseStream) -> syn::Result<Self> {
        let name: Ident = input.parse()?;
        let name_str = name.to_string();
        let span = name.span();

        match name_str.as_str() {
            "all_of" => {
                let content;
                syn::parenthesized!(content in input);
                let inner;
                syn::bracketed!(inner in content);
                let children = parse_predicate_list(&inner)?;
                if children.is_empty() {
                    return Err(syn::Error::new(
                        span,
                        "all_of([]) is a semantic no-op (R-A6); add at least one child",
                    ));
                }
                Ok(Self::AllOf(children))
            }
            "any_of" => {
                let content;
                syn::parenthesized!(content in input);
                let inner;
                syn::bracketed!(inner in content);
                let children = parse_predicate_list(&inner)?;
                if children.is_empty() {
                    return Err(syn::Error::new(
                        span,
                        "any_of([]) is a semantic no-op (R-A6); add at least one child",
                    ));
                }
                Ok(Self::AnyOf(children))
            }
            "not" => {
                let content;
                syn::parenthesized!(content in input);
                let child: Self = content.parse()?;
                Ok(Self::Not(Box::new(child)))
            }
            leaf_name => {
                let leaf = parse_leaf(leaf_name, span, input)?;
                Ok(Self::Leaf(leaf))
            }
        }
    }
}

fn parse_predicate_list(input: ParseStream) -> syn::Result<Vec<RequiresExpr>> {
    let mut children = Vec::new();
    while !input.is_empty() {
        children.push(input.parse::<RequiresExpr>()?);
        if input.is_empty() {
            break;
        }
        input.parse::<Token![,]>()?;
    }
    Ok(children)
}

fn parse_leaf(name: &str, span: Span, input: ParseStream) -> syn::Result<LeafExpr> {
    match name {
        "ratified_doc" => parse_ratified_doc(span, input),
        "signers" => parse_signers(span, input),
        "signed_trailer" => parse_signed_trailer(span, input),
        "oracles_complete" => parse_oracles_complete(span, input),
        "fresh_within_days" => parse_fresh_within_days(span, input),
        // ADR-025 supply-chain leaves
        "dep_pinned" => parse_dep_pinned(span, input),
        "dep_attested" => parse_dep_attested(span, input),
        "maintainer_unchanged" => parse_maintainer_unchanged(span, input),
        "content_hash_matches" => parse_content_hash_matches(span, input),
        "sandbox_clean" => parse_sandbox_clean(span, input),
        other => Err(syn::Error::new(
            span,
            format!(
                "unknown substrate-witness predicate leaf `{other}`; \
                 v0.2 set: ratified_doc, signers, signed_trailer, \
                 oracles_complete, fresh_within_days, \
                 dep_pinned, dep_attested, maintainer_unchanged, \
                 content_hash_matches, sandbox_clean"
            ),
        )),
    }
}

// ============================================================================
// Supply-Chain leaf parsers (ADR-025)
//
// Calling convention: positional string args, then optional key=value.
// Examples:
//   dep_pinned                          // all deps must be exact-pinned
//   dep_pinned("serde")                 // only serde must be exact-pinned
//   dep_attested("serde", "1.0.197")    // version exact
//   dep_attested("serde", "1.0.197", exact_version = false)
//   maintainer_unchanged("serde", "1.0.197")
//   content_hash_matches("serde", "1.0.197")
//   sandbox_clean("serde", sandbox_kind = "proc-macro")
// ============================================================================

fn parse_dep_pinned(_span: Span, input: ParseStream) -> syn::Result<LeafExpr> {
    if !input.peek(syn::token::Paren) {
        return Ok(LeafExpr::DepPinned { crate_name: None });
    }
    let content;
    syn::parenthesized!(content in input);

    let mut crate_name: Option<String> = None;
    // Positional first arg (string literal)?
    if content.peek(LitStr) {
        let s: LitStr = content.parse()?;
        crate_name = Some(s.value());
        if !content.is_empty() {
            content.parse::<Token![,]>()?;
        }
    }
    // Optional `crate = "..."` keyed form.
    while !content.is_empty() {
        let key = parse_kv_key(&content)?;
        match key.to_string().as_str() {
            "crate" | "crate_name" => {
                let s: LitStr = content.parse()?;
                crate_name = Some(s.value());
            }
            other => {
                return Err(syn::Error::new(
                    key.span(),
                    format!(
                        "unknown dep_pinned field `{other}`; expected: crate (positional or `crate = \"...\"`)"
                    ),
                ));
            }
        }
        if content.is_empty() {
            break;
        }
        content.parse::<Token![,]>()?;
    }

    Ok(LeafExpr::DepPinned { crate_name })
}

fn parse_dep_attested(span: Span, input: ParseStream) -> syn::Result<LeafExpr> {
    let content;
    syn::parenthesized!(content in input);

    let mut crate_name: Option<String> = None;
    let mut version: Option<String> = None;
    let mut exact_version = true;
    let mut reviewable_artifact: Option<String> = None;

    // Up to two positional string args (crate, version)
    let mut positional_taken = 0;
    while positional_taken < 2 && content.peek(LitStr) {
        let s: LitStr = content.parse()?;
        match positional_taken {
            0 => crate_name = Some(s.value()),
            1 => version = Some(s.value()),
            _ => unreachable!(),
        }
        positional_taken += 1;
        if !content.is_empty() {
            content.parse::<Token![,]>()?;
        }
    }
    while !content.is_empty() {
        let key = parse_kv_key(&content)?;
        match key.to_string().as_str() {
            "crate" | "crate_name" => {
                let s: LitStr = content.parse()?;
                crate_name = Some(s.value());
            }
            "version" => {
                let s: LitStr = content.parse()?;
                version = Some(s.value());
            }
            "exact_version" => {
                let b: LitBool = content.parse()?;
                exact_version = b.value();
            }
            "reviewable_artifact" => {
                let s: LitStr = content.parse()?;
                reviewable_artifact = Some(s.value());
            }
            other => {
                return Err(syn::Error::new(
                    key.span(),
                    format!(
                        "unknown dep_attested field `{other}`; \
                         expected: crate, version, exact_version, reviewable_artifact"
                    ),
                ));
            }
        }
        if content.is_empty() {
            break;
        }
        content.parse::<Token![,]>()?;
    }

    let crate_name = crate_name.ok_or_else(|| {
        syn::Error::new(
            span,
            "dep_attested requires `crate` (positional string or `crate = \"...\"`)",
        )
    })?;
    let version = version.ok_or_else(|| {
        syn::Error::new(
            span,
            "dep_attested requires `version` (positional string or `version = \"...\"`)",
        )
    })?;

    Ok(LeafExpr::DepAttested {
        crate_name,
        version,
        exact_version,
        reviewable_artifact,
    })
}

fn parse_maintainer_unchanged(span: Span, input: ParseStream) -> syn::Result<LeafExpr> {
    let content;
    syn::parenthesized!(content in input);

    let mut crate_name: Option<String> = None;
    let mut since_version: Option<String> = None;

    let mut positional_taken = 0;
    while positional_taken < 2 && content.peek(LitStr) {
        let s: LitStr = content.parse()?;
        match positional_taken {
            0 => crate_name = Some(s.value()),
            1 => since_version = Some(s.value()),
            _ => unreachable!(),
        }
        positional_taken += 1;
        if !content.is_empty() {
            content.parse::<Token![,]>()?;
        }
    }
    while !content.is_empty() {
        let key = parse_kv_key(&content)?;
        match key.to_string().as_str() {
            "crate" | "crate_name" => {
                let s: LitStr = content.parse()?;
                crate_name = Some(s.value());
            }
            "since_version" => {
                let s: LitStr = content.parse()?;
                since_version = Some(s.value());
            }
            other => {
                return Err(syn::Error::new(
                    key.span(),
                    format!(
                        "unknown maintainer_unchanged field `{other}`; \
                         expected: crate, since_version"
                    ),
                ));
            }
        }
        if content.is_empty() {
            break;
        }
        content.parse::<Token![,]>()?;
    }

    let crate_name = crate_name
        .ok_or_else(|| syn::Error::new(span, "maintainer_unchanged requires `crate` argument"))?;
    let since_version = since_version.ok_or_else(|| {
        syn::Error::new(
            span,
            "maintainer_unchanged requires `since_version` argument",
        )
    })?;

    Ok(LeafExpr::MaintainerUnchanged {
        crate_name,
        since_version,
    })
}

fn parse_content_hash_matches(span: Span, input: ParseStream) -> syn::Result<LeafExpr> {
    let content;
    syn::parenthesized!(content in input);

    let mut crate_name: Option<String> = None;
    let mut version: Option<String> = None;

    let mut positional_taken = 0;
    while positional_taken < 2 && content.peek(LitStr) {
        let s: LitStr = content.parse()?;
        match positional_taken {
            0 => crate_name = Some(s.value()),
            1 => version = Some(s.value()),
            _ => unreachable!(),
        }
        positional_taken += 1;
        if !content.is_empty() {
            content.parse::<Token![,]>()?;
        }
    }
    while !content.is_empty() {
        let key = parse_kv_key(&content)?;
        match key.to_string().as_str() {
            "crate" | "crate_name" => {
                let s: LitStr = content.parse()?;
                crate_name = Some(s.value());
            }
            "version" => {
                let s: LitStr = content.parse()?;
                version = Some(s.value());
            }
            other => {
                return Err(syn::Error::new(
                    key.span(),
                    format!(
                        "unknown content_hash_matches field `{other}`; \
                         expected: crate, version"
                    ),
                ));
            }
        }
        if content.is_empty() {
            break;
        }
        content.parse::<Token![,]>()?;
    }

    let crate_name = crate_name.ok_or_else(|| {
        syn::Error::new(
            span,
            "content_hash_matches requires `crate` argument (NON-NEGOTIABLE per ADR-025 B1-R)",
        )
    })?;
    let version = version.ok_or_else(|| {
        syn::Error::new(
            span,
            "content_hash_matches requires `version` argument (NON-NEGOTIABLE per ADR-025 B1-R)",
        )
    })?;

    Ok(LeafExpr::ContentHashMatches {
        crate_name,
        version,
    })
}

fn parse_sandbox_clean(span: Span, input: ParseStream) -> syn::Result<LeafExpr> {
    let content;
    syn::parenthesized!(content in input);

    let mut crate_name: Option<String> = None;
    let mut sandbox_kind: Option<String> = None;

    // First positional is crate name; sandbox_kind is keyword.
    if content.peek(LitStr) {
        let s: LitStr = content.parse()?;
        crate_name = Some(s.value());
        if !content.is_empty() {
            content.parse::<Token![,]>()?;
        }
    }
    while !content.is_empty() {
        let key = parse_kv_key(&content)?;
        match key.to_string().as_str() {
            "crate" | "crate_name" => {
                let s: LitStr = content.parse()?;
                crate_name = Some(s.value());
            }
            "sandbox_kind" => {
                let s: LitStr = content.parse()?;
                sandbox_kind = Some(s.value());
            }
            other => {
                return Err(syn::Error::new(
                    key.span(),
                    format!(
                        "unknown sandbox_clean field `{other}`; \
                         expected: crate, sandbox_kind"
                    ),
                ));
            }
        }
        if content.is_empty() {
            break;
        }
        content.parse::<Token![,]>()?;
    }

    let crate_name = crate_name
        .ok_or_else(|| syn::Error::new(span, "sandbox_clean requires `crate` argument"))?;
    let sandbox_kind = sandbox_kind.ok_or_else(|| {
        syn::Error::new(
            span,
            "sandbox_clean requires `sandbox_kind = \"build\"` or `sandbox_kind = \"proc-macro\"`",
        )
    })?;

    Ok(LeafExpr::SandboxClean {
        crate_name,
        sandbox_kind,
    })
}

/// Parse a `key = value` pair inside a leaf's parentheses, returning the key
/// ident and advancing past the `=`.
fn parse_kv_key(input: ParseStream) -> syn::Result<Ident> {
    let key: Ident = input.parse()?;
    input.parse::<Token![=]>()?;
    Ok(key)
}

/// Parse a `[str, str, ...]` bracketed string array (for `required`, `files`).
fn parse_string_array(input: ParseStream) -> syn::Result<Vec<String>> {
    let inner;
    syn::bracketed!(inner in input);
    let mut out = Vec::new();
    while !inner.is_empty() {
        let s: LitStr = inner.parse()?;
        out.push(s.value());
        if inner.is_empty() {
            break;
        }
        inner.parse::<Token![,]>()?;
    }
    Ok(out)
}

/// Map a DSL signature-strength string to a [`crate::tier::SignatureStrength`].
///
/// Accepts the canonical kebab-case spellings the CLI's `--strength` flag uses
/// (`text-stamp`, `git-trust`, `crypto-signed`). Returns a parse error naming
/// the valid set on an unknown spelling — the trust-boundary loud-failure
/// (ADR-005 sub-clause F) rather than a silent default.
fn signature_strength_from_str(s: &LitStr) -> syn::Result<crate::tier::SignatureStrength> {
    match s.value().as_str() {
        "text-stamp" => Ok(crate::tier::SignatureStrength::TextStamp),
        "git-trust" => Ok(crate::tier::SignatureStrength::GitTrust),
        "crypto-signed" => Ok(crate::tier::SignatureStrength::CryptoSigned),
        // The same value is spelled three ways across surfaces: PascalCase
        // (the Rust type), snake_case (the serde sidecar-JSON wire form), and
        // kebab-case (the `--strength` CLI flag AND this DSL). An adopter who
        // reaches for the wrong-surface spelling — most likely the snake_case
        // wire form seen in a sidecar — should get a redirect, not an opaque
        // miss (aristotle's F1/F5 affordance-trap ruling; the per-leaf-
        // diagnostic discipline applied to this cross-surface seam).
        other => {
            let snake_case_hint = if other.contains('_') {
                " (note: kebab-case, matching the `cargo antigen --strength` flag \
                 — not the snake_case sidecar-JSON wire form)"
            } else {
                ""
            };
            Err(syn::Error::new(
                s.span(),
                format!(
                    "unknown signature strength `{other}`; expected one of: \
                     \"text-stamp\", \"git-trust\", \"crypto-signed\"{snake_case_hint}"
                ),
            ))
        }
    }
}

/// Parse a `[str, str, ...]` bracketed array of signature-strength strings
/// (for `signature_allow`).
fn parse_signature_strength_array(
    input: ParseStream,
) -> syn::Result<Vec<crate::tier::SignatureStrength>> {
    let inner;
    syn::bracketed!(inner in input);
    let mut out = Vec::new();
    while !inner.is_empty() {
        let s: LitStr = inner.parse()?;
        out.push(signature_strength_from_str(&s)?);
        if inner.is_empty() {
            break;
        }
        inner.parse::<Token![,]>()?;
    }
    Ok(out)
}

/// Parse a `{key = "val", ...}` brace-delimited string-to-string map (for `roles`).
fn parse_string_string_map(
    input: ParseStream,
) -> syn::Result<std::collections::BTreeMap<String, String>> {
    let inner;
    syn::braced!(inner in input);
    let mut out = std::collections::BTreeMap::new();
    while !inner.is_empty() {
        let key: Ident = inner.parse()?;
        inner.parse::<Token![=]>()?;
        let val: LitStr = inner.parse()?;
        out.insert(key.to_string(), val.value());
        if inner.is_empty() {
            break;
        }
        inner.parse::<Token![,]>()?;
    }
    Ok(out)
}

fn parse_ratified_doc(_span: Span, input: ParseStream) -> syn::Result<LeafExpr> {
    let mut path: Option<String> = None;
    let mut min_version: Option<String> = None;
    let mut anchor: Option<String> = None;
    let mut sibling_json = false;

    // ratified_doc may be bare (no parens) or have named args
    if input.peek(syn::token::Paren) {
        let content;
        syn::parenthesized!(content in input);
        while !content.is_empty() {
            let key = parse_kv_key(&content)?;
            match key.to_string().as_str() {
                "path" => {
                    let s: LitStr = content.parse()?;
                    path = Some(s.value());
                }
                "min_version" => {
                    let s: LitStr = content.parse()?;
                    min_version = Some(s.value());
                }
                "anchor" => {
                    let s: LitStr = content.parse()?;
                    anchor = Some(s.value());
                }
                "sibling_json" => {
                    let b: LitBool = content.parse()?;
                    sibling_json = b.value();
                }
                other => {
                    return Err(syn::Error::new(
                        key.span(),
                        format!(
                            "unknown ratified_doc field `{other}`; \
                             expected: path, min_version, anchor, sibling_json"
                        ),
                    ));
                }
            }
            if content.is_empty() {
                break;
            }
            content.parse::<Token![,]>()?;
        }
    }

    Ok(LeafExpr::RatifiedDoc {
        path,
        min_version,
        anchor,
        sibling_json,
    })
}

fn parse_signers(span: Span, input: ParseStream) -> syn::Result<LeafExpr> {
    let mut required: Vec<String> = Vec::new();
    let mut roles: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new();
    let mut against = SignerCurrencyExpr::Current;
    let mut signature_allow: Vec<crate::tier::SignatureStrength> = Vec::new();
    let mut signature_prefer: Option<crate::tier::SignatureStrength> = None;

    if input.peek(syn::token::Paren) {
        let content;
        syn::parenthesized!(content in input);
        while !content.is_empty() {
            let key = parse_kv_key(&content)?;
            match key.to_string().as_str() {
                "required" => {
                    required = parse_string_array(&content)?;
                }
                "roles" => {
                    roles = parse_string_string_map(&content)?;
                }
                "against" => {
                    let s: LitStr = content.parse()?;
                    against = match s.value().as_str() {
                        "current" => SignerCurrencyExpr::Current,
                        "any" => SignerCurrencyExpr::Any,
                        other => {
                            return Err(syn::Error::new(
                                s.span(),
                                format!(
                                    "unknown signers `against` value `{other}`; \
                                     expected \"current\" or \"any\""
                                ),
                            ))
                        }
                    };
                }
                "signature_allow" => {
                    signature_allow = parse_signature_strength_array(&content)?;
                }
                "signature_prefer" => {
                    let s: LitStr = content.parse()?;
                    signature_prefer = Some(signature_strength_from_str(&s)?);
                }
                other => {
                    return Err(syn::Error::new(
                        key.span(),
                        format!(
                            "unknown signers field `{other}`; expected: required, roles, \
                             against, signature_allow, signature_prefer"
                        ),
                    ));
                }
            }
            if content.is_empty() {
                break;
            }
            content.parse::<Token![,]>()?;
        }
    }

    if required.is_empty() {
        return Err(syn::Error::new(
            span,
            "signers(required = [...]) must include at least one name (NFA-7)",
        ));
    }

    // Sub-clause F: every key in `roles` must name a signer in `required`.
    // A role assertion for an absent signer is a dead constraint — it can
    // never fire — and almost always indicates a typo.
    for role_name in roles.keys() {
        if !required.contains(role_name) {
            return Err(syn::Error::new(
                span,
                format!(
                    "signers: roles key `{role_name}` is not in `required` \
                     (sub-clause F: every role assertion must name a required signer)"
                ),
            ));
        }
    }

    Ok(LeafExpr::Signers {
        required,
        roles,
        against,
        signature_allow,
        signature_prefer,
    })
}

fn parse_signed_trailer(span: Span, input: ParseStream) -> syn::Result<LeafExpr> {
    let mut key: Option<String> = None;
    let mut role: Option<String> = None;
    let mut count: u32 = 1;

    if input.peek(syn::token::Paren) {
        let content;
        syn::parenthesized!(content in input);
        while !content.is_empty() {
            let k = parse_kv_key(&content)?;
            match k.to_string().as_str() {
                "key" => {
                    let s: LitStr = content.parse()?;
                    key = Some(s.value());
                }
                "role" => {
                    let s: LitStr = content.parse()?;
                    role = Some(s.value());
                }
                "count" => {
                    let n: LitInt = content.parse()?;
                    count = n.base10_parse::<u32>()?;
                }
                other => {
                    return Err(syn::Error::new(
                        k.span(),
                        format!(
                            "unknown signed_trailer field `{other}`; \
                             expected: key, role, count"
                        ),
                    ));
                }
            }
            if content.is_empty() {
                break;
            }
            content.parse::<Token![,]>()?;
        }
    }

    let key = key.ok_or_else(|| {
        syn::Error::new(span, "signed_trailer requires `key = \"...\"` (the trailer key, e.g., \"Discipline-Verified-By\")")
    })?;

    if count == 0 {
        return Err(syn::Error::new(
            span,
            "signed_trailer `count = 0` is vacuously true (NFA-9); use count >= 1",
        ));
    }

    Ok(LeafExpr::SignedTrailer { key, role, count })
}

fn parse_oracles_complete(span: Span, input: ParseStream) -> syn::Result<LeafExpr> {
    let mut files: Vec<String> = Vec::new();

    if input.peek(syn::token::Paren) {
        let content;
        syn::parenthesized!(content in input);
        while !content.is_empty() {
            let key = parse_kv_key(&content)?;
            match key.to_string().as_str() {
                "files" => {
                    files = parse_string_array(&content)?;
                }
                other => {
                    return Err(syn::Error::new(
                        key.span(),
                        format!("unknown oracles_complete field `{other}`; expected: files"),
                    ));
                }
            }
            if content.is_empty() {
                break;
            }
            content.parse::<Token![,]>()?;
        }
    }

    if files.is_empty() {
        return Err(syn::Error::new(
            span,
            "oracles_complete(files = [...]) must include at least one file path (NFA-8)",
        ));
    }

    Ok(LeafExpr::OraclesComplete { files })
}

fn parse_fresh_within_days(span: Span, input: ParseStream) -> syn::Result<LeafExpr> {
    let days = if input.peek(syn::token::Paren) {
        let content;
        syn::parenthesized!(content in input);
        if content.peek(Ident) && content.peek2(Token![=]) {
            // named: fresh_within_days(days = 90)
            let key: Ident = content.parse()?;
            if key != "days" {
                return Err(syn::Error::new(
                    key.span(),
                    format!("unknown fresh_within_days field `{key}`; expected: days"),
                ));
            }
            content.parse::<Token![=]>()?;
        }
        // positional or named — both end with a u32 literal
        let n: LitInt = content.parse()?;
        n.base10_parse::<u32>()?
    } else {
        return Err(syn::Error::new(
            span,
            "fresh_within_days requires a day count: `fresh_within_days(90)` or \
             `fresh_within_days(days = 90)`",
        ));
    };

    Ok(LeafExpr::FreshWithinDays { days })
}

// ============================================================================
// RequiresExpr round-trip tests
//
// rc.2 contract: `RequiresExpr::to_json()` always produces JSON that
// deserializes as `crate::Predicate`. The earlier rc.1 contract — hand-rolled
// JSON literals matching a hypothesized shape — produced wrong output and
// silently broke the substrate-witness pipeline.
//
// We assert two invariants:
// 1. Round-trip equality: `parse → to_predicate` and `parse → to_json →
//    from_str` produce structurally-equivalent Predicates.
// 2. Structural-shape spot checks: per-leaf checks that the JSON contains
//    the `"kind":"leaf"` discriminator and the `"name":"<leaf>"` tag at the
//    top level (not nested in a wrapper field — the rc.1 bug).
// ============================================================================

#[cfg(test)]
mod requires_json_tests {
    use super::*;
    use proc_macro2::TokenStream;

    fn parse_requires(input: &str) -> RequiresExpr {
        let tokens: TokenStream = input.parse().expect("tokenize");
        syn::parse2::<RequiresExpr>(tokens).expect("parse RequiresExpr")
    }

    fn round_trip(input: &str) -> crate::Predicate {
        let expr = parse_requires(input);
        let predicate_direct = expr.to_predicate();
        let json = expr.to_json();
        let predicate_via_json: crate::Predicate = serde_json::from_str(&json)
            .unwrap_or_else(|e| panic!("JSON `{json}` from `{input}` did not deserialize: {e}"));
        assert_eq!(
            predicate_direct, predicate_via_json,
            "round-trip mismatch for `{input}`:\n  direct = {predicate_direct:?}\n  via_json = {predicate_via_json:?}"
        );
        predicate_via_json
    }

    #[test]
    fn leaf_fresh_within_days_positional_round_trips() {
        let p = round_trip("fresh_within_days(90)");
        assert!(matches!(
            p,
            crate::Predicate::Leaf(crate::Leaf::FreshWithinDays { days: 90 })
        ));
    }

    #[test]
    fn leaf_fresh_within_days_named_round_trips() {
        let p = round_trip("fresh_within_days(days = 180)");
        assert!(matches!(
            p,
            crate::Predicate::Leaf(crate::Leaf::FreshWithinDays { days: 180 })
        ));
    }

    #[test]
    fn leaf_signers_current_round_trips() {
        round_trip(r#"signers(required = ["alice", "bob"])"#);
    }

    #[test]
    fn leaf_signers_any_against_round_trips() {
        round_trip(r#"signers(required = ["alice"], against = "any")"#);
    }

    #[test]
    fn leaf_ratified_doc_bare_round_trips() {
        round_trip("ratified_doc");
    }

    #[test]
    fn leaf_ratified_doc_with_path_round_trips() {
        round_trip(r#"ratified_doc(path = "docs/discipline.md")"#);
    }

    #[test]
    fn leaf_ratified_doc_sibling_json_round_trips() {
        round_trip("ratified_doc(sibling_json = true)");
    }

    #[test]
    fn leaf_oracles_complete_round_trips() {
        round_trip(r#"oracles_complete(files = ["a.md", "b.md"])"#);
    }

    #[test]
    fn leaf_signed_trailer_default_count_round_trips() {
        round_trip(r#"signed_trailer(key = "Discipline-Verified-By")"#);
    }

    #[test]
    fn leaf_signed_trailer_non_default_count_round_trips() {
        round_trip(r#"signed_trailer(key = "Verified-By", count = 2)"#);
    }

    #[test]
    fn combinator_all_of_round_trips() {
        round_trip(r#"all_of([fresh_within_days(90), signers(required = ["alice"])])"#);
    }

    #[test]
    fn combinator_any_of_round_trips() {
        round_trip(r"any_of([fresh_within_days(30), fresh_within_days(90)])");
    }

    #[test]
    fn combinator_not_round_trips() {
        round_trip(r"not(fresh_within_days(90))");
    }

    #[test]
    fn json_shape_is_flat_not_nested() {
        // rc.1 bug regression: the macro emitted `{"kind":"leaf","leaf":{...}}`
        // but the real Predicate serde produces `{"kind":"leaf","name":"signers",...}`.
        // The deserializer rejected the wrapped form, so the audit always reported
        // sidecar-schema-invalid. This test pins the flat shape.
        let json = parse_requires(r#"signers(required = ["alice"])"#).to_json();
        assert!(
            json.contains(r#""kind":"leaf""#),
            "JSON must carry the predicate discriminator: {json}"
        );
        assert!(
            json.contains(r#""name":"signers""#),
            "leaf discriminator must be at the same level as kind, not nested: {json}"
        );
        assert!(
            !json.contains(r#""leaf":{"#),
            "rc.1 bug: leaf fields must NOT be wrapped in a `leaf` field: {json}"
        );
    }

    #[test]
    fn json_string_escapes_quotes_and_backslash() {
        // Signer names with special chars must be safely JSON-escaped via the
        // real serde path. This used to be a custom escape function; now we
        // rely on serde_json to handle it correctly.
        let expr = parse_requires(r#"signers(required = ["alice\"bob"])"#);
        let json = expr.to_json();
        let p: crate::Predicate = serde_json::from_str(&json).expect("round-trip ok");
        if let crate::Predicate::Leaf(crate::Leaf::Signers { required, .. }) = p {
            assert_eq!(required, vec!["alice\"bob".to_string()]);
        } else {
            panic!("expected Signers leaf");
        }
    }

    #[test]
    fn requires_expr_validate_rejects_empty_all_of() {
        // all_of([]) is caught at parse time (not validate time) but the
        // validate() method must also reject it if somehow constructed.
        let span = proc_macro2::Span::call_site();
        let expr = RequiresExpr::AllOf(vec![]);
        assert!(expr.validate(span).is_err());
    }

    #[test]
    fn requires_expr_validate_rejects_empty_signers() {
        let span = proc_macro2::Span::call_site();
        let expr = RequiresExpr::Leaf(LeafExpr::Signers {
            required: vec![],
            roles: std::collections::BTreeMap::new(),
            against: SignerCurrencyExpr::Current,
            signature_allow: vec![],
            signature_prefer: None,
        });
        assert!(expr.validate(span).is_err());
    }

    #[test]
    fn requires_expr_validate_rejects_empty_anchor_nfa14() {
        let span = proc_macro2::Span::call_site();
        let expr = RequiresExpr::Leaf(LeafExpr::RatifiedDoc {
            path: Some("docs/d.md".to_string()),
            min_version: None,
            anchor: Some(String::new()), // empty anchor — NFA-14
            sibling_json: false,
        });
        assert!(expr.validate(span).is_err());
    }

    #[test]
    fn requires_expr_validate_rejects_empty_min_version_nfa15() {
        let span = proc_macro2::Span::call_site();
        let expr = RequiresExpr::Leaf(LeafExpr::RatifiedDoc {
            path: Some("docs/d.md".to_string()),
            min_version: Some(String::new()), // empty min_version — NFA-15
            anchor: None,
            sibling_json: false,
        });
        assert!(expr.validate(span).is_err());
    }

    #[test]
    fn requires_expr_validate_passes_well_formed() {
        let span = proc_macro2::Span::call_site();
        let expr = RequiresExpr::AllOf(vec![
            RequiresExpr::Leaf(LeafExpr::Signers {
                required: vec!["alice".to_string()],
                roles: std::collections::BTreeMap::new(),
                against: SignerCurrencyExpr::Current,
                signature_allow: vec![],
                signature_prefer: None,
            }),
            RequiresExpr::Leaf(LeafExpr::FreshWithinDays { days: 90 }),
        ]);
        assert!(expr.validate(span).is_ok());
    }

    #[test]
    fn json_round_trips_through_predicate_serde() {
        // The contract: every JSON we produce must deserialize as the runtime
        // Predicate type. This locks the two ASTs together — any divergence
        // (renamed variant, new field, changed serde tag) breaks the test.
        let cases = [
            r#"signers(required = ["alice"], roles = {alice = "math-researcher"})"#,
            r#"all_of([signers(required = ["alice"]), fresh_within_days(180)])"#,
            r"any_of([fresh_within_days(30), fresh_within_days(90)])",
            r"not(fresh_within_days(90))",
            r#"ratified_doc(path = "docs/discipline.md", min_version = "1.0")"#,
            r#"oracles_complete(files = ["a.md"])"#,
            r#"signed_trailer(key = "Verified-By", count = 2)"#,
        ];
        for input in cases {
            let json = parse_requires(input).to_json();
            let _: crate::Predicate = serde_json::from_str(&json).unwrap_or_else(|e| {
                panic!("JSON `{json}` from `{input}` failed Predicate round-trip: {e}")
            });
        }
    }

    #[test]
    fn leaf_signers_roles_round_trips() {
        // Ensure roles field parses and survives the JSON round-trip.
        let input = r#"signers(required = ["alice", "bob"], roles = {alice = "math-researcher", bob = "reviewer"})"#;
        let json = parse_requires(input).to_json();
        let p: crate::Predicate =
            serde_json::from_str(&json).expect("roles round-trip must succeed");
        if let crate::Predicate::Leaf(crate::Leaf::Signers {
            required, roles, ..
        }) = p
        {
            assert_eq!(required, vec!["alice".to_string(), "bob".to_string()]);
            assert_eq!(
                roles.get("alice").map(String::as_str),
                Some("math-researcher")
            );
            assert_eq!(roles.get("bob").map(String::as_str), Some("reviewer"));
        } else {
            panic!("expected Signers leaf, got: {json}");
        }
    }

    #[test]
    fn leaf_signers_roles_subset_f_rejects_unknown_name() {
        // Sub-clause F: role key not in required must be rejected at parse time.
        let result = std::panic::catch_unwind(|| {
            parse_requires(r#"signers(required = ["alice"], roles = {bob = "reviewer"})"#)
        });
        assert!(
            result.is_err(),
            "roles key not in required must panic/reject at parse time (sub-clause F)"
        );
    }

    // ATK: DslSurfaceOmitsRuntimeField — `signature_allow` and `signature_prefer` are
    // evaluated by eval_signers (evaluate.rs:410-411) but are inexpressible via the DSL.
    // The DSL `signers(...)` hardcodes `signature_allow: Vec::new()` in to_leaf()
    // (parser.rs:317), silently allowing any strength. An adopter who needs to restrict
    // attestation to `CryptoSigned` entries has no way to express it and gets no warning.
    //
    // This test FAILS until parser.rs exposes `signature_allow = [...]` in the DSL
    // and to_leaf() propagates the list.
    //
    // Root: aristotle finding F2 from comprehensive-antigen-coverage campsite:
    //   Leaf::Signers has 5 fields; LeafExpr::Signers exposes 3 (after roles= fix).
    //   signature_allow and signature_prefer are still dead surface (parser.rs:317-318).
    #[test]
    fn atk_dsl_signers_signature_allow_expressible_and_round_trips() {
        // ATK: verify signature_allow survives DSL → to_leaf() → JSON round-trip.
        // Was broken: to_leaf() hardcoded Vec::new() for signature_allow (parser.rs:317).
        // Now fixed: parse_signers() parses the DSL field and to_leaf() propagates it.
        let p = round_trip(r#"signers(required = ["alice"], signature_allow = ["crypto-signed"])"#);
        if let crate::Predicate::Leaf(crate::Leaf::Signers {
            signature_allow, ..
        }) = p
        {
            assert_eq!(
                signature_allow,
                vec![crate::tier::SignatureStrength::CryptoSigned],
                "signature_allow must survive DSL → to_leaf() round-trip; \
                 currently to_leaf() hardcodes Vec::new() regardless of DSL input"
            );
        } else {
            panic!("expected Signers leaf");
        }
    }

    // ATK: verify that the DSL `signers(required=["alice"])` produces
    // `signature_allow: []` in the runtime Leaf — confirming that the CURRENT
    // behavior (Vec::new() default) is preserved and not accidentally changed
    // to a non-empty restriction. An empty `signature_allow` means any strength
    // is accepted; this is the correct CONTROL for the `atk_dsl_signers_signature_allow_expressible`
    // test above.
    #[test]
    fn atk_dsl_signers_no_signature_allow_preserves_empty_list() {
        let p = round_trip(r#"signers(required = ["alice"])"#);
        if let crate::Predicate::Leaf(crate::Leaf::Signers {
            signature_allow, ..
        }) = p
        {
            assert!(
                signature_allow.is_empty(),
                "signers() without signature_allow must produce an empty allow-list \
                 (any strength accepted); got: {signature_allow:?}"
            );
        } else {
            panic!("expected Signers leaf");
        }
    }

    // Witness for the `CapabilityOmissionAtLowering` dogfood antigen (F5 family,
    // a child of SilentIntentNullification): the parity guard that every runtime
    // `Leaf::Signers` field is REACHABLE from the DSL and survives lowering. A
    // DSL string exercising ALL FIVE fields with non-default values must lower to
    // a Leaf whose every field carries that value — never a hardcoded default.
    //
    // This is the regression shield against the exact drift fixed in c237101:
    // before the fix, to_leaf() hardcoded signature_allow=Vec::new() and
    // signature_prefer=None regardless of DSL input (parser.rs:317-318), so two
    // ratified grammar fields (decisions.md:5085) were silently inexpressible.
    // If any future field is added to Leaf::Signers without a DSL arm + lowering,
    // this test fails — the capability-omission is caught at the parser, not by
    // an adopter whose value vanished.
    #[test]
    fn atk_dsl_signers_every_field_reachable_and_lowered_no_omission() {
        use crate::tier::SignatureStrength;
        // Every field non-default: required (2 names), roles (a role assertion),
        // against = "any" (non-default; default is Current), signature_allow
        // (a non-empty allow-set), signature_prefer (a single strength).
        let p = round_trip(
            r#"signers(
                required = ["alice", "bob"],
                roles = {alice = "math-researcher"},
                against = "any",
                signature_allow = ["git-trust", "crypto-signed"],
                signature_prefer = "crypto-signed"
            )"#,
        );
        let crate::Predicate::Leaf(crate::Leaf::Signers {
            required,
            roles,
            against,
            signature_allow,
            signature_prefer,
        }) = p
        else {
            panic!("expected Signers leaf");
        };
        assert_eq!(required, vec!["alice".to_string(), "bob".to_string()]);
        assert_eq!(
            roles.get("alice").map(String::as_str),
            Some("math-researcher"),
            "roles field must survive lowering"
        );
        assert_eq!(
            against,
            crate::predicate::SignerCurrency::Any,
            "against=\"any\" must survive lowering (not reset to the Current default)"
        );
        assert_eq!(
            signature_allow,
            vec![SignatureStrength::GitTrust, SignatureStrength::CryptoSigned],
            "signature_allow must survive lowering — the field the c237101 fix \
             added; before, to_leaf() hardcoded Vec::new() (CapabilityOmissionAtLowering)"
        );
        assert_eq!(
            signature_prefer,
            Some(SignatureStrength::CryptoSigned),
            "signature_prefer must survive lowering — before, to_leaf() hardcoded None"
        );
    }
}
