Skip to main content

mlua_swarm/
binding.rs

1//! Platform-neutral execution binding boundary.
2//!
3//! Swarm owns request construction, requested/effective validation, and
4//! immutable Run snapshots. Execution environments implement
5//! [`AgentBindingProvider`] and report what they can actually enforce; an
6//! official platform adapter is one implementation of this same interface.
7
8use crate::blueprint::{
9    AgentProviderManifest, BindOutcome, BindReceipt, BindRequest, BindingAttestation,
10    BindingBackend, BoundAgent, Runner,
11};
12use async_trait::async_trait;
13use serde::Serialize;
14use std::collections::{BTreeSet, HashMap, HashSet};
15use thiserror::Error;
16
17/// Migration policy for the deprecated `AgentProfile.worker_binding` Runner
18/// fallback. It applies only to fresh declaration resolution; persisted
19/// snapshots keep their pinned Runner and remain readable.
20#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum LegacyWorkerBindingPolicy {
23    /// Preserve pre-Runner Blueprint compatibility and mark the snapshot with
24    /// `runner_source = legacy_worker_binding`.
25    #[default]
26    Allow,
27    /// Reject the fallback and require an explicit `runner` or `runner_ref`.
28    Reject,
29}
30
31/// Execution-environment provider for effective agent capabilities.
32#[async_trait]
33pub trait AgentBindingProvider: Send + Sync {
34    /// Resolve all requested bindings as one launch-time transaction.
35    ///
36    /// The provider returns exactly one [`BindOutcome`] per requested agent
37    /// — `Bound` with an (untrusted) receipt Core still validates, or
38    /// `Unbound` when the execution environment currently offers no matching
39    /// capability. Returning fewer, extra, or duplicate outcomes is rejected
40    /// by Core in [`attest_bound_agents`]. Whether an `Unbound` outcome fails
41    /// the launch is the caller's `strict` decision, not the provider's.
42    async fn bind(
43        &self,
44        requests: &[BindRequest],
45    ) -> Result<Vec<BindOutcome>, BindingProviderError>;
46
47    /// Launch-scoped variant of this provider, resolved against one pinned
48    /// execution session id instead of whatever session currently holds the
49    /// request's logical `binding_target`.
50    ///
51    /// A launch may pin the execution session it routes to (the server's
52    /// `operator_sid` launch field). Two drivers sharing one process claim
53    /// the same logical role over time, so "the role's current holder" and
54    /// "the session this launch is bound to" are different facts — this hook
55    /// lets a provider resolve manifests through the second one.
56    ///
57    /// The default returns `None`: a provider with no session concept (the
58    /// manifest reference provider, an embed-mode adapter) is unaffected and
59    /// the caller keeps using the unpinned provider, byte-for-byte.
60    fn pinned_to_session(
61        &self,
62        _session_id: &str,
63    ) -> Option<std::sync::Arc<dyn AgentBindingProvider>> {
64        None
65    }
66}
67
68/// Reference provider backed by an execution-environment capability manifest.
69///
70/// Claude Code, Codex, and other official plugins can inspect their own
71/// platform state, construct [`AgentProviderManifest`], and delegate the common
72/// request-to-receipt mapping here. Core still validates every returned receipt
73/// through [`attest_bound_agents`].
74#[derive(Debug, Clone)]
75pub struct ManifestBindingProvider {
76    manifest: AgentProviderManifest,
77}
78
79impl ManifestBindingProvider {
80    /// Wrap one provider-owned capability manifest.
81    pub fn new(manifest: AgentProviderManifest) -> Self {
82        Self { manifest }
83    }
84
85    /// Borrow the provider-owned manifest used for resolution.
86    pub fn manifest(&self) -> &AgentProviderManifest {
87        &self.manifest
88    }
89
90    fn outcome_for(&self, request: &BindRequest) -> Result<BindOutcome, BindingProviderError> {
91        if request.backend == BindingBackend::AgentBlockInProcess {
92            return Err(BindingProviderError::Provider(format!(
93                "manifest provider '{}' cannot bind in-process agent '{}'",
94                self.manifest.provider_id, request.agent
95            )));
96        }
97        let mut matches = self
98            .manifest
99            .capabilities
100            .iter()
101            .filter(|capability| capability.launch_variant == request.launch_variant);
102        let Some(capability) = matches.next() else {
103            // No capability for the requested variant is an attestation gap,
104            // not a provider fault: report `Unbound` and let the caller's
105            // `strict` decision (in `attest_bound_agents`) choose whether the
106            // launch fails.
107            return Ok(BindOutcome::Unbound {
108                agent: request.agent.clone(),
109                reason: format!(
110                    "manifest provider '{}' has no capability for launch variant {:?}",
111                    self.manifest.provider_id, request.launch_variant
112                ),
113            });
114        };
115        if matches.next().is_some() {
116            // A manifest that declares the same variant twice is ambiguous:
117            // this is a provider configuration bug, so it stays fail-closed
118            // in every mode.
119            return Err(BindingProviderError::Provider(format!(
120                "manifest provider '{}' declares duplicate capabilities for launch variant {:?}",
121                self.manifest.provider_id, request.launch_variant
122            )));
123        }
124        Ok(BindOutcome::Bound {
125            receipt: BindReceipt {
126                agent: request.agent.clone(),
127                request_digest: request.request_digest.clone(),
128                provider_id: self.manifest.provider_id.clone(),
129                provider_revision: self.manifest.provider_revision.clone(),
130                resolved_model: capability.resolved_model.clone(),
131                effective_tools: capability.effective_tools.clone(),
132                launch_variant: capability.launch_variant.clone(),
133                capability_snapshot_digest: capability.capability_snapshot_digest.clone(),
134            },
135        })
136    }
137}
138
139#[async_trait]
140impl AgentBindingProvider for ManifestBindingProvider {
141    async fn bind(
142        &self,
143        requests: &[BindRequest],
144    ) -> Result<Vec<BindOutcome>, BindingProviderError> {
145        requests
146            .iter()
147            .map(|request| self.outcome_for(request))
148            .collect()
149    }
150}
151
152/// Failure reported by a provider or by Core's receipt validation.
153#[derive(Debug, Error, Clone, PartialEq, Eq)]
154pub enum BindingProviderError {
155    /// The provider could not inspect or resolve its execution environment.
156    #[error("binding provider failed: {0}")]
157    Provider(String),
158    /// More than one receipt used the same logical agent correlation key.
159    #[error("binding provider returned duplicate receipt for agent '{agent}'")]
160    DuplicateReceipt {
161        /// Duplicate logical agent name.
162        agent: String,
163    },
164    /// A requested agent had no receipt.
165    #[error("binding provider returned no receipt for agent '{agent}'")]
166    MissingReceipt {
167        /// Missing logical agent name.
168        agent: String,
169    },
170    /// A receipt did not correspond to any request.
171    #[error("binding provider returned unexpected receipt for agent '{agent}'")]
172    UnexpectedReceipt {
173        /// Unexpected logical agent name.
174        agent: String,
175    },
176    /// A receipt was produced for an older or different declaration.
177    #[error(
178        "binding receipt for agent '{agent}' attests request digest '{effective}', expected '{requested}'"
179    )]
180    RequestDigestMismatch {
181        /// Logical agent name.
182        agent: String,
183        /// Digest sent in the current request.
184        requested: crate::blueprint::BindingDigest,
185        /// Digest echoed by the provider.
186        effective: crate::blueprint::BindingDigest,
187    },
188    /// The provider identifier is required for provenance.
189    #[error("binding receipt for agent '{agent}' has an empty provider_id")]
190    EmptyProviderId {
191        /// Logical agent name.
192        agent: String,
193    },
194    /// Model resolution was requested but the provider did not identify the
195    /// effective model.
196    #[error(
197        "binding receipt for agent '{agent}' omitted resolved_model for requested model '{requested}'"
198    )]
199    MissingResolvedModel {
200        /// Logical agent name.
201        agent: String,
202        /// Requested model alias or tier.
203        requested: String,
204    },
205    /// The execution environment cannot enforce every requested tool.
206    #[error("binding receipt for agent '{agent}' is missing requested tools: {missing:?}")]
207    MissingTools {
208        /// Logical agent name.
209        agent: String,
210        /// Requested tools absent from the effective grant.
211        missing: Vec<String>,
212    },
213    /// The effective launch variant differs from the requested variant.
214    #[error(
215        "binding receipt for agent '{agent}' resolved launch variant {effective:?}, expected '{requested}'"
216    )]
217    VariantMismatch {
218        /// Logical agent name.
219        agent: String,
220        /// Requested launch variant.
221        requested: String,
222        /// Provider-reported effective launch variant.
223        effective: Option<String>,
224    },
225    /// The accepted attestation could not be incorporated into replay
226    /// identity.
227    #[error("binding digest recompute failed: {0}")]
228    Digest(String),
229    /// `strict_binding` is set but the provider left a Runner-backed agent
230    /// `Unbound`. The message lists what an execution environment would have
231    /// to attest (launch variant, requested tools, requested model) so an
232    /// Operator can generate a satisfying capability manifest.
233    #[error(
234        "strict_binding requires an attestation for agent '{agent}' \
235         (requested launch variant {variant:?}, tools {tools:?}, model {model:?}) \
236         but the provider returned Unbound: {reason}"
237    )]
238    AttestationRequired {
239        /// Logical agent name.
240        agent: String,
241        /// Provider-reported reason the agent could not be bound.
242        reason: String,
243        /// Requested launch variant from the resolved `Runner`.
244        variant: Option<String>,
245        /// Requested minimum tool grant from the resolved `Runner`.
246        tools: Vec<String>,
247        /// Requested model alias or tier from `AgentProfile.model`.
248        model: Option<String>,
249    },
250}
251
252/// One Runner-backed agent the provider could not attest, returned by
253/// [`attest_bound_agents`] in non-strict mode. Purely observational: the
254/// `reason` never enters the [`BoundAgent`] snapshot or its digest lineage —
255/// the agent stays `DeclarationOnly` and callers record the gap out of band
256/// (tracing warn + `RunRecord.degradations`).
257#[derive(Debug, Clone, PartialEq, Eq)]
258pub struct UnboundAgent {
259    /// Logical agent name that was left unattested.
260    pub agent: String,
261    /// Provider-reported reason the agent could not be bound.
262    pub reason: String,
263}
264
265/// Build platform-neutral requests for every Runner-bound agent.
266pub fn binding_requests(bound_agents: &[BoundAgent]) -> Vec<BindRequest> {
267    bound_agents
268        .iter()
269        .filter_map(binding_request_for_snapshot)
270        .collect()
271}
272
273/// Reconstruct the platform-neutral request pinned by one immutable snapshot.
274///
275/// Before attestation, the snapshot's `binding_digest` is the declaration
276/// request digest. After attestation changes the final replay identity, the
277/// original request digest remains pinned inside the accepted attestation.
278/// This makes the helper safe for both launch-time provider calls and
279/// after-the-fact Run explain surfaces.
280pub fn binding_request_for_snapshot(bound: &BoundAgent) -> Option<BindRequest> {
281    let runner = bound.runner.as_ref()?;
282    let (backend, requested_tools, launch_variant) = match runner {
283        Runner::WsOperator { variant, tools } => (
284            BindingBackend::WsOperator,
285            canonical_tools(tools),
286            Some(variant.clone()),
287        ),
288        Runner::WsClaudeCode { variant, tools } => (
289            BindingBackend::WsClaudeCode,
290            canonical_tools(tools),
291            Some(variant.clone()),
292        ),
293        Runner::AgentBlockInProcess { tools } => (
294            BindingBackend::AgentBlockInProcess,
295            canonical_tools(tools),
296            None,
297        ),
298        // GH #83: a Subprocess-backed agent runs as a headless local child
299        // process — there is no execution-environment provider to bind, so
300        // no platform-neutral BindRequest is emitted for it.
301        Runner::Subprocess { .. } => return None,
302    };
303    Some(BindRequest {
304        agent: bound.agent.name.clone(),
305        request_digest: bound.attestation.as_ref().map_or_else(
306            || bound.binding_digest.clone(),
307            |attestation| attestation.request_digest.clone(),
308        ),
309        backend,
310        binding_target: bound
311            .agent
312            .spec
313            .get("operator_ref")
314            .and_then(|value| value.as_str())
315            .map(str::to_string),
316        requested_model: bound
317            .agent
318            .profile
319            .as_ref()
320            .and_then(|profile| profile.model.clone()),
321        requested_tools,
322        launch_variant,
323    })
324}
325
326#[derive(Serialize)]
327struct LegacyEvidenceAttestation<'a> {
328    request_digest: &'a crate::blueprint::BindingDigest,
329    provider_id: &'a str,
330    #[serde(skip_serializing_if = "Option::is_none")]
331    provider_revision: &'a Option<String>,
332    #[serde(skip_serializing_if = "Option::is_none")]
333    resolved_model: &'a Option<String>,
334    #[serde(skip_serializing_if = "Vec::is_empty")]
335    effective_tools: &'a Vec<String>,
336    #[serde(skip_serializing_if = "Option::is_none")]
337    launch_variant: &'a Option<String>,
338    #[serde(skip_serializing_if = "Option::is_none")]
339    evidence_digest: &'a Option<crate::blueprint::BindingDigest>,
340}
341
342#[derive(Serialize)]
343struct LegacyEvidenceBoundAgentDigestInput<'a> {
344    agent: &'a crate::blueprint::AgentDef,
345    runner: &'a Option<Runner>,
346    context_policy: &'a Option<crate::core::agent_context::ContextPolicy>,
347    runner_source: crate::blueprint::RunnerResolutionSource,
348    attestation: Option<LegacyEvidenceAttestation<'a>>,
349}
350
351fn legacy_evidence_binding_digest(
352    bound: &BoundAgent,
353) -> Result<crate::blueprint::BindingDigest, BindingProviderError> {
354    let attestation = bound
355        .attestation
356        .as_ref()
357        .map(|attestation| LegacyEvidenceAttestation {
358            request_digest: &attestation.request_digest,
359            provider_id: &attestation.provider_id,
360            provider_revision: &attestation.provider_revision,
361            resolved_model: &attestation.resolved_model,
362            effective_tools: &attestation.effective_tools,
363            launch_variant: &attestation.launch_variant,
364            evidence_digest: &attestation.capability_snapshot_digest,
365        });
366    let bytes = serde_json::to_vec(&LegacyEvidenceBoundAgentDigestInput {
367        agent: &bound.agent,
368        runner: &bound.runner,
369        context_policy: &bound.context_policy,
370        runner_source: bound.runner_source,
371        attestation,
372    })
373    .map_err(|error| BindingProviderError::Digest(error.to_string()))?;
374    Ok(crate::blueprint::BindingDigest::sha256(bytes))
375}
376
377/// Validate one persisted [`BoundAgent`] before it is reused or explained.
378///
379/// The digest is recomputed from the snapshot body, then an attested snapshot
380/// is checked again against its declaration-only request. This detects store
381/// corruption and schema-inconsistent mutations without consulting a Provider,
382/// the current Blueprint, or any mutable execution-environment registry.
383pub fn validate_bound_agent_snapshot(bound: &BoundAgent) -> Result<(), BindingProviderError> {
384    let mut expected = bound.clone();
385    expected
386        .recompute_binding_digest()
387        .map_err(|error| BindingProviderError::Digest(error.to_string()))?;
388    let legacy_digest = legacy_evidence_binding_digest(bound)?;
389    if expected.binding_digest != bound.binding_digest && legacy_digest != bound.binding_digest {
390        return Err(BindingProviderError::Digest(format!(
391            "stored BoundAgent '{}' has binding digest '{}', recomputed '{}'",
392            bound.agent.name, bound.binding_digest, expected.binding_digest
393        )));
394    }
395
396    let Some(attestation) = bound.attestation.as_ref() else {
397        return Ok(());
398    };
399
400    let mut declaration = bound.clone();
401    declaration.attestation = None;
402    declaration
403        .recompute_binding_digest()
404        .map_err(|error| BindingProviderError::Digest(error.to_string()))?;
405    let request = binding_request_for_snapshot(&declaration).ok_or_else(|| {
406        BindingProviderError::Provider(format!(
407            "stored BoundAgent '{}' has an attestation but no Runner declaration",
408            bound.agent.name
409        ))
410    })?;
411    let validated = validate_receipt(
412        &request,
413        BindReceipt {
414            agent: bound.agent.name.clone(),
415            request_digest: attestation.request_digest.clone(),
416            provider_id: attestation.provider_id.clone(),
417            provider_revision: attestation.provider_revision.clone(),
418            resolved_model: attestation.resolved_model.clone(),
419            effective_tools: attestation.effective_tools.clone(),
420            launch_variant: attestation.launch_variant.clone(),
421            capability_snapshot_digest: attestation.capability_snapshot_digest.clone(),
422        },
423    )?;
424    if &validated != attestation {
425        return Err(BindingProviderError::Provider(format!(
426            "stored BoundAgent '{}' contains a non-canonical attestation",
427            bound.agent.name
428        )));
429    }
430    Ok(())
431}
432
433/// Validate a complete persisted binding snapshot without partially accepting
434/// any entry.
435pub fn validate_bound_agent_snapshots(
436    bound_agents: &[BoundAgent],
437) -> Result<(), BindingProviderError> {
438    for bound in bound_agents {
439        validate_bound_agent_snapshot(bound)?;
440    }
441    Ok(())
442}
443
444/// Ask `provider` to bind all Runner-backed agents, validate every returned
445/// receipt, and pin accepted attestations into the snapshots.
446///
447/// `strict` decides how an `Unbound` outcome is treated (never how a `Bound`
448/// one is validated — "attestation is optional, but never wrong"):
449///
450/// - A `Bound` outcome is always validated through [`validate_receipt`]; a
451///   receipt that is present but contradicts the request (missing tools,
452///   variant mismatch, stale digest, empty resolved model) is an error in
453///   BOTH modes.
454/// - An `Unbound` outcome fails the call with
455///   [`BindingProviderError::AttestationRequired`] when `strict`, or is
456///   collected into the returned [`UnboundAgent`] list when not — the agent
457///   stays `DeclarationOnly`.
458///
459/// Per-agent outcome completeness (exactly one outcome per requested agent,
460/// no missing / duplicate / unexpected entries) stays fail-closed in both
461/// modes.
462pub async fn attest_bound_agents(
463    provider: &dyn AgentBindingProvider,
464    bound_agents: &mut [BoundAgent],
465    strict: bool,
466) -> Result<Vec<UnboundAgent>, BindingProviderError> {
467    let requests = binding_requests(bound_agents);
468    if requests.is_empty() {
469        return Ok(Vec::new());
470    }
471
472    let outcomes = provider.bind(&requests).await?;
473    let requested_names: HashSet<&str> = requests.iter().map(|r| r.agent.as_str()).collect();
474    let mut by_agent = HashMap::with_capacity(outcomes.len());
475    for outcome in outcomes {
476        let agent = match &outcome {
477            BindOutcome::Bound { receipt } => receipt.agent.clone(),
478            BindOutcome::Unbound { agent, .. } => agent.clone(),
479        };
480        if !requested_names.contains(agent.as_str()) {
481            return Err(BindingProviderError::UnexpectedReceipt { agent });
482        }
483        if by_agent.insert(agent.clone(), outcome).is_some() {
484            return Err(BindingProviderError::DuplicateReceipt { agent });
485        }
486    }
487
488    let mut accepted = Vec::with_capacity(requests.len());
489    let mut unbound = Vec::new();
490    for request in requests {
491        let outcome = by_agent.remove(&request.agent).ok_or_else(|| {
492            BindingProviderError::MissingReceipt {
493                agent: request.agent.clone(),
494            }
495        })?;
496        match outcome {
497            BindOutcome::Bound { receipt } => {
498                let attestation = validate_receipt(&request, receipt)?;
499                accepted.push((request.agent, attestation));
500            }
501            BindOutcome::Unbound { reason, .. } => {
502                if strict {
503                    return Err(BindingProviderError::AttestationRequired {
504                        agent: request.agent,
505                        reason,
506                        variant: request.launch_variant,
507                        tools: request.requested_tools,
508                        model: request.requested_model,
509                    });
510                }
511                unbound.push(UnboundAgent {
512                    agent: request.agent,
513                    reason,
514                });
515            }
516        }
517    }
518
519    for (agent, attestation) in accepted {
520        let bound = bound_agents
521            .iter_mut()
522            .find(|bound| bound.agent.name == agent)
523            .expect("BindRequest is constructed from BoundAgent");
524        bound
525            .set_attestation(attestation)
526            .map_err(|error| BindingProviderError::Digest(error.to_string()))?;
527    }
528    Ok(unbound)
529}
530
531fn validate_receipt(
532    request: &BindRequest,
533    receipt: BindReceipt,
534) -> Result<BindingAttestation, BindingProviderError> {
535    if receipt.request_digest != request.request_digest {
536        return Err(BindingProviderError::RequestDigestMismatch {
537            agent: request.agent.clone(),
538            requested: request.request_digest.clone(),
539            effective: receipt.request_digest,
540        });
541    }
542    if receipt.provider_id.trim().is_empty() {
543        return Err(BindingProviderError::EmptyProviderId {
544            agent: request.agent.clone(),
545        });
546    }
547    if let Some(requested) = &request.requested_model {
548        if receipt
549            .resolved_model
550            .as_deref()
551            .map_or(true, str::is_empty)
552        {
553            return Err(BindingProviderError::MissingResolvedModel {
554                agent: request.agent.clone(),
555                requested: requested.clone(),
556            });
557        }
558    }
559
560    let effective_tools = canonical_tools(&receipt.effective_tools);
561    let effective_set: BTreeSet<&str> = effective_tools.iter().map(String::as_str).collect();
562    let missing: Vec<String> = request
563        .requested_tools
564        .iter()
565        .filter(|tool| !effective_set.contains(tool.as_str()))
566        .cloned()
567        .collect();
568    if !missing.is_empty() {
569        return Err(BindingProviderError::MissingTools {
570            agent: request.agent.clone(),
571            missing,
572        });
573    }
574    if let Some(requested) = &request.launch_variant {
575        if receipt.launch_variant.as_ref() != Some(requested) {
576            return Err(BindingProviderError::VariantMismatch {
577                agent: request.agent.clone(),
578                requested: requested.clone(),
579                effective: receipt.launch_variant,
580            });
581        }
582    }
583
584    Ok(BindingAttestation {
585        request_digest: request.request_digest.clone(),
586        provider_id: receipt.provider_id,
587        provider_revision: receipt.provider_revision,
588        resolved_model: receipt.resolved_model,
589        effective_tools,
590        launch_variant: receipt.launch_variant,
591        capability_snapshot_digest: receipt.capability_snapshot_digest,
592    })
593}
594
595fn canonical_tools(tools: &[String]) -> Vec<String> {
596    tools
597        .iter()
598        .filter(|tool| !tool.is_empty())
599        .cloned()
600        .collect::<BTreeSet<_>>()
601        .into_iter()
602        .collect()
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608    use crate::blueprint::{
609        current_schema_version, resolve_bound_agents, AgentDef, AgentKind, AgentProfile, Blueprint,
610        BlueprintMetadata, CompilerHints, CompilerStrategy,
611    };
612    use mlua_flow_ir::Node as FlowNode;
613    use serde_json::json;
614
615    struct ReceiptProvider(Vec<BindReceipt>);
616
617    #[async_trait]
618    impl AgentBindingProvider for ReceiptProvider {
619        async fn bind(
620            &self,
621            _requests: &[BindRequest],
622        ) -> Result<Vec<BindOutcome>, BindingProviderError> {
623            Ok(self
624                .0
625                .iter()
626                .cloned()
627                .map(|receipt| BindOutcome::Bound { receipt })
628                .collect())
629        }
630    }
631
632    /// Provider that reports every request as `Unbound` with a fixed reason —
633    /// exercises the `strict` gate in [`attest_bound_agents`].
634    struct UnboundProvider(&'static str);
635
636    #[async_trait]
637    impl AgentBindingProvider for UnboundProvider {
638        async fn bind(
639            &self,
640            requests: &[BindRequest],
641        ) -> Result<Vec<BindOutcome>, BindingProviderError> {
642            Ok(requests
643                .iter()
644                .map(|request| BindOutcome::Unbound {
645                    agent: request.agent.clone(),
646                    reason: self.0.to_string(),
647                })
648                .collect())
649        }
650    }
651
652    fn bound() -> Vec<BoundAgent> {
653        let mut bp = Blueprint {
654            schema_version: current_schema_version(),
655            id: "binding-test".into(),
656            flow: FlowNode::Seq { children: vec![] },
657            agents: vec![],
658            operators: vec![],
659            metas: vec![],
660            hints: CompilerHints::default(),
661            strategy: CompilerStrategy::default(),
662            metadata: BlueprintMetadata::default(),
663            spawner_hints: Default::default(),
664            default_agent_kind: AgentKind::Operator,
665            default_operator_kind: None,
666            default_init_ctx: None,
667            default_agent_ctx: None,
668            default_context_policy: None,
669            projection_placement: None,
670            audits: vec![],
671            degradation_policy: None,
672            runners: vec![],
673            default_runner: None,
674            subprocesses: vec![],
675            check_policy: None,
676            blueprint_ref_includes: vec![],
677        };
678        bp.agents.push(AgentDef {
679            name: "coder".to_string(),
680            kind: AgentKind::Operator,
681            spec: json!({ "operator_ref": "main-ai" }),
682            profile: Some(AgentProfile {
683                model: Some("sonnet".to_string()),
684                ..Default::default()
685            }),
686            meta: None,
687            runner: Some(Runner::WsClaudeCode {
688                variant: "mse-coder".to_string(),
689                tools: vec!["Write".to_string(), "Read".to_string()],
690            }),
691            runner_ref: None,
692            verdict: None,
693        });
694        resolve_bound_agents(&bp).unwrap()
695    }
696
697    fn receipt() -> BindReceipt {
698        BindReceipt {
699            agent: "coder".to_string(),
700            request_digest: bound()[0].binding_digest.clone(),
701            provider_id: "operator-main-ai".to_string(),
702            provider_revision: Some("1".to_string()),
703            resolved_model: Some("claude-sonnet-4".to_string()),
704            effective_tools: vec!["Write".to_string(), "Read".to_string()],
705            launch_variant: Some("mse-coder".to_string()),
706            capability_snapshot_digest: None,
707        }
708    }
709
710    #[test]
711    fn requests_are_canonical_and_include_declaration_digest() {
712        let bound = bound();
713        let requests = binding_requests(&bound);
714        assert_eq!(requests[0].agent, "coder");
715        assert_eq!(requests[0].request_digest, bound[0].binding_digest);
716        assert_eq!(requests[0].backend, BindingBackend::WsClaudeCode);
717        assert_eq!(requests[0].binding_target.as_deref(), Some("main-ai"));
718        assert_eq!(requests[0].requested_model.as_deref(), Some("sonnet"));
719        assert_eq!(requests[0].requested_tools, ["Read", "Write"]);
720        assert_eq!(requests[0].launch_variant.as_deref(), Some("mse-coder"));
721    }
722
723    #[tokio::test]
724    async fn accepted_receipt_is_attested_and_changes_digest() {
725        let mut bound = bound();
726        let declaration_digest = bound[0].binding_digest.clone();
727        let unbound = attest_bound_agents(&ReceiptProvider(vec![receipt()]), &mut bound, false)
728            .await
729            .unwrap();
730        assert!(unbound.is_empty());
731        assert_ne!(bound[0].binding_digest, declaration_digest);
732        validate_bound_agent_snapshot(&bound[0]).unwrap();
733        assert_eq!(
734            bound[0].attestation.as_ref().unwrap().effective_tools,
735            ["Read", "Write"]
736        );
737    }
738
739    #[test]
740    fn persisted_snapshot_rejects_binding_digest_drift() {
741        let mut bound = bound().remove(0);
742        bound.agent.profile.as_mut().unwrap().system_prompt = "mutated after persistence".into();
743
744        let error = validate_bound_agent_snapshot(&bound).unwrap_err();
745        assert!(matches!(error, BindingProviderError::Digest(_)));
746    }
747
748    #[tokio::test]
749    async fn persisted_snapshot_rejects_attestation_for_a_different_declaration() {
750        let mut bound = bound();
751        attest_bound_agents(&ReceiptProvider(vec![receipt()]), &mut bound, false)
752            .await
753            .unwrap();
754        bound[0].attestation.as_mut().unwrap().request_digest =
755            crate::blueprint::BindingDigest::sha256("other-declaration");
756        bound[0].recompute_binding_digest().unwrap();
757
758        let error = validate_bound_agent_snapshot(&bound[0]).unwrap_err();
759        assert!(matches!(
760            error,
761            BindingProviderError::RequestDigestMismatch { .. }
762        ));
763    }
764
765    #[tokio::test]
766    async fn persisted_snapshot_accepts_the_legacy_evidence_digest_identity() {
767        let mut receipt = receipt();
768        receipt.capability_snapshot_digest = Some(crate::blueprint::BindingDigest::sha256(
769            "legacy-capabilities",
770        ));
771        let mut bound = bound();
772        attest_bound_agents(&ReceiptProvider(vec![receipt]), &mut bound, false)
773            .await
774            .unwrap();
775        let new_digest = bound[0].binding_digest.clone();
776        let legacy_digest = legacy_evidence_binding_digest(&bound[0]).unwrap();
777        assert_ne!(legacy_digest, new_digest);
778
779        bound[0].binding_digest = legacy_digest;
780        validate_bound_agent_snapshot(&bound[0]).unwrap();
781    }
782
783    #[tokio::test]
784    async fn missing_tool_fails_closed() {
785        let mut bound = bound();
786        let mut receipt = receipt();
787        receipt.effective_tools = vec!["Read".to_string()];
788        // A receipt that IS present but contradicts the request fails in
789        // non-strict mode too — "attestation is optional, but never wrong".
790        let error = attest_bound_agents(&ReceiptProvider(vec![receipt]), &mut bound, false)
791            .await
792            .unwrap_err();
793        assert_eq!(
794            error,
795            BindingProviderError::MissingTools {
796                agent: "coder".to_string(),
797                missing: vec!["Write".to_string()],
798            }
799        );
800    }
801
802    #[tokio::test]
803    async fn missing_receipt_fails_closed() {
804        let mut bound = bound();
805        let error = attest_bound_agents(&ReceiptProvider(vec![]), &mut bound, false)
806            .await
807            .unwrap_err();
808        assert_eq!(
809            error,
810            BindingProviderError::MissingReceipt {
811                agent: "coder".to_string(),
812            }
813        );
814        assert!(bound[0].attestation.is_none());
815    }
816
817    #[tokio::test]
818    async fn stale_request_digest_fails_closed() {
819        let mut bound = bound();
820        let mut receipt = receipt();
821        receipt.request_digest = crate::blueprint::BindingDigest::sha256("stale");
822        let error = attest_bound_agents(&ReceiptProvider(vec![receipt]), &mut bound, false)
823            .await
824            .unwrap_err();
825        assert!(matches!(
826            error,
827            BindingProviderError::RequestDigestMismatch { .. }
828        ));
829        assert!(bound[0].attestation.is_none());
830    }
831
832    #[tokio::test]
833    async fn variant_mismatch_fails_closed() {
834        let mut bound = bound();
835        let mut receipt = receipt();
836        receipt.launch_variant = Some("other".to_string());
837        let error = attest_bound_agents(&ReceiptProvider(vec![receipt]), &mut bound, false)
838            .await
839            .unwrap_err();
840        assert!(matches!(
841            error,
842            BindingProviderError::VariantMismatch { .. }
843        ));
844    }
845
846    #[tokio::test]
847    async fn unbound_outcome_is_collected_in_non_strict_mode() {
848        let mut bound = bound();
849        let unbound = attest_bound_agents(
850            &UnboundProvider("no capability for launch variant"),
851            &mut bound,
852            false,
853        )
854        .await
855        .expect("non-strict must not fail on Unbound");
856        assert_eq!(unbound.len(), 1);
857        assert_eq!(unbound[0].agent, "coder");
858        assert_eq!(unbound[0].reason, "no capability for launch variant");
859        // The agent stays DeclarationOnly — no attestation is pinned.
860        assert!(bound[0].attestation.is_none());
861    }
862
863    #[tokio::test]
864    async fn unbound_outcome_fails_closed_in_strict_mode_with_requirements() {
865        let mut bound = bound();
866        let error = attest_bound_agents(
867            &UnboundProvider("role main-ai has not joined"),
868            &mut bound,
869            true,
870        )
871        .await
872        .unwrap_err();
873        match &error {
874            BindingProviderError::AttestationRequired {
875                agent,
876                variant,
877                tools,
878                ..
879            } => {
880                assert_eq!(agent, "coder");
881                assert_eq!(variant.as_deref(), Some("mse-coder"));
882                assert_eq!(tools, &["Read".to_string(), "Write".to_string()]);
883            }
884            other => panic!("expected AttestationRequired, got {other:?}"),
885        }
886        // The message must name the agent and the requested variant/tools so
887        // an Operator can generate a satisfying manifest.
888        let message = error.to_string();
889        assert!(message.contains("coder"), "message: {message}");
890        assert!(message.contains("mse-coder"), "message: {message}");
891        assert!(message.contains("Read"), "message: {message}");
892        assert!(bound[0].attestation.is_none());
893    }
894}