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        // An `operator_ref` that is present but empty names no Operator, so
311        // it is dropped here rather than carried forward as a role nothing
312        // can hold. The agent then reads as "declares no binding target",
313        // which `OperatorSessionBindingProvider` already fails closed on for
314        // a WS-backed runner — the same tier of declaration error.
315        binding_target: bound
316            .agent
317            .spec
318            .get("operator_ref")
319            .and_then(|value| value.as_str())
320            .and_then(|value| crate::types::OperatorRef::new(value).ok()),
321        requested_model: bound
322            .agent
323            .profile
324            .as_ref()
325            .and_then(|profile| profile.model.clone()),
326        requested_tools,
327        launch_variant,
328    })
329}
330
331#[derive(Serialize)]
332struct LegacyEvidenceAttestation<'a> {
333    request_digest: &'a crate::blueprint::BindingDigest,
334    provider_id: &'a str,
335    #[serde(skip_serializing_if = "Option::is_none")]
336    provider_revision: &'a Option<String>,
337    #[serde(skip_serializing_if = "Option::is_none")]
338    resolved_model: &'a Option<String>,
339    #[serde(skip_serializing_if = "Vec::is_empty")]
340    effective_tools: &'a Vec<String>,
341    #[serde(skip_serializing_if = "Option::is_none")]
342    launch_variant: &'a Option<String>,
343    #[serde(skip_serializing_if = "Option::is_none")]
344    evidence_digest: &'a Option<crate::blueprint::BindingDigest>,
345}
346
347#[derive(Serialize)]
348struct LegacyEvidenceBoundAgentDigestInput<'a> {
349    agent: &'a crate::blueprint::AgentDef,
350    runner: &'a Option<Runner>,
351    context_policy: &'a Option<crate::core::agent_context::ContextPolicy>,
352    runner_source: crate::blueprint::RunnerResolutionSource,
353    attestation: Option<LegacyEvidenceAttestation<'a>>,
354}
355
356fn legacy_evidence_binding_digest(
357    bound: &BoundAgent,
358) -> Result<crate::blueprint::BindingDigest, BindingProviderError> {
359    let attestation = bound
360        .attestation
361        .as_ref()
362        .map(|attestation| LegacyEvidenceAttestation {
363            request_digest: &attestation.request_digest,
364            provider_id: &attestation.provider_id,
365            provider_revision: &attestation.provider_revision,
366            resolved_model: &attestation.resolved_model,
367            effective_tools: &attestation.effective_tools,
368            launch_variant: &attestation.launch_variant,
369            evidence_digest: &attestation.capability_snapshot_digest,
370        });
371    let bytes = serde_json::to_vec(&LegacyEvidenceBoundAgentDigestInput {
372        agent: &bound.agent,
373        runner: &bound.runner,
374        context_policy: &bound.context_policy,
375        runner_source: bound.runner_source,
376        attestation,
377    })
378    .map_err(|error| BindingProviderError::Digest(error.to_string()))?;
379    Ok(crate::blueprint::BindingDigest::sha256(bytes))
380}
381
382/// Validate one persisted [`BoundAgent`] before it is reused or explained.
383///
384/// The digest is recomputed from the snapshot body, then an attested snapshot
385/// is checked again against its declaration-only request. This detects store
386/// corruption and schema-inconsistent mutations without consulting a Provider,
387/// the current Blueprint, or any mutable execution-environment registry.
388pub fn validate_bound_agent_snapshot(bound: &BoundAgent) -> Result<(), BindingProviderError> {
389    let mut expected = bound.clone();
390    expected
391        .recompute_binding_digest()
392        .map_err(|error| BindingProviderError::Digest(error.to_string()))?;
393    let legacy_digest = legacy_evidence_binding_digest(bound)?;
394    if expected.binding_digest != bound.binding_digest && legacy_digest != bound.binding_digest {
395        return Err(BindingProviderError::Digest(format!(
396            "stored BoundAgent '{}' has binding digest '{}', recomputed '{}'",
397            bound.agent.name, bound.binding_digest, expected.binding_digest
398        )));
399    }
400
401    let Some(attestation) = bound.attestation.as_ref() else {
402        return Ok(());
403    };
404
405    let mut declaration = bound.clone();
406    declaration.attestation = None;
407    declaration
408        .recompute_binding_digest()
409        .map_err(|error| BindingProviderError::Digest(error.to_string()))?;
410    let request = binding_request_for_snapshot(&declaration).ok_or_else(|| {
411        BindingProviderError::Provider(format!(
412            "stored BoundAgent '{}' has an attestation but no Runner declaration",
413            bound.agent.name
414        ))
415    })?;
416    let validated = validate_receipt(
417        &request,
418        BindReceipt {
419            agent: bound.agent.name.clone(),
420            request_digest: attestation.request_digest.clone(),
421            provider_id: attestation.provider_id.clone(),
422            provider_revision: attestation.provider_revision.clone(),
423            resolved_model: attestation.resolved_model.clone(),
424            effective_tools: attestation.effective_tools.clone(),
425            launch_variant: attestation.launch_variant.clone(),
426            capability_snapshot_digest: attestation.capability_snapshot_digest.clone(),
427        },
428    )?;
429    if &validated != attestation {
430        return Err(BindingProviderError::Provider(format!(
431            "stored BoundAgent '{}' contains a non-canonical attestation",
432            bound.agent.name
433        )));
434    }
435    Ok(())
436}
437
438/// Validate a complete persisted binding snapshot without partially accepting
439/// any entry.
440pub fn validate_bound_agent_snapshots(
441    bound_agents: &[BoundAgent],
442) -> Result<(), BindingProviderError> {
443    for bound in bound_agents {
444        validate_bound_agent_snapshot(bound)?;
445    }
446    Ok(())
447}
448
449/// Ask `provider` to bind all Runner-backed agents, validate every returned
450/// receipt, and pin accepted attestations into the snapshots.
451///
452/// `strict` decides how an `Unbound` outcome is treated (never how a `Bound`
453/// one is validated — "attestation is optional, but never wrong"):
454///
455/// - A `Bound` outcome is always validated through [`validate_receipt`]; a
456///   receipt that is present but contradicts the request (missing tools,
457///   variant mismatch, stale digest, empty resolved model) is an error in
458///   BOTH modes.
459/// - An `Unbound` outcome fails the call with
460///   [`BindingProviderError::AttestationRequired`] when `strict`, or is
461///   collected into the returned [`UnboundAgent`] list when not — the agent
462///   stays `DeclarationOnly`.
463///
464/// Per-agent outcome completeness (exactly one outcome per requested agent,
465/// no missing / duplicate / unexpected entries) stays fail-closed in both
466/// modes.
467pub async fn attest_bound_agents(
468    provider: &dyn AgentBindingProvider,
469    bound_agents: &mut [BoundAgent],
470    strict: bool,
471) -> Result<Vec<UnboundAgent>, BindingProviderError> {
472    let requests = binding_requests(bound_agents);
473    if requests.is_empty() {
474        return Ok(Vec::new());
475    }
476
477    let outcomes = provider.bind(&requests).await?;
478    let requested_names: HashSet<&str> = requests.iter().map(|r| r.agent.as_str()).collect();
479    let mut by_agent = HashMap::with_capacity(outcomes.len());
480    for outcome in outcomes {
481        let agent = match &outcome {
482            BindOutcome::Bound { receipt } => receipt.agent.clone(),
483            BindOutcome::Unbound { agent, .. } => agent.clone(),
484        };
485        if !requested_names.contains(agent.as_str()) {
486            return Err(BindingProviderError::UnexpectedReceipt { agent });
487        }
488        if by_agent.insert(agent.clone(), outcome).is_some() {
489            return Err(BindingProviderError::DuplicateReceipt { agent });
490        }
491    }
492
493    let mut accepted = Vec::with_capacity(requests.len());
494    let mut unbound = Vec::new();
495    for request in requests {
496        let outcome = by_agent.remove(&request.agent).ok_or_else(|| {
497            BindingProviderError::MissingReceipt {
498                agent: request.agent.clone(),
499            }
500        })?;
501        match outcome {
502            BindOutcome::Bound { receipt } => {
503                let attestation = validate_receipt(&request, receipt)?;
504                accepted.push((request.agent, attestation));
505            }
506            BindOutcome::Unbound { reason, .. } => {
507                if strict {
508                    return Err(BindingProviderError::AttestationRequired {
509                        agent: request.agent,
510                        reason,
511                        variant: request.launch_variant,
512                        tools: request.requested_tools,
513                        model: request.requested_model,
514                    });
515                }
516                unbound.push(UnboundAgent {
517                    agent: request.agent,
518                    reason,
519                });
520            }
521        }
522    }
523
524    for (agent, attestation) in accepted {
525        let bound = bound_agents
526            .iter_mut()
527            .find(|bound| bound.agent.name == agent)
528            .expect("BindRequest is constructed from BoundAgent");
529        bound
530            .set_attestation(attestation)
531            .map_err(|error| BindingProviderError::Digest(error.to_string()))?;
532    }
533    Ok(unbound)
534}
535
536fn validate_receipt(
537    request: &BindRequest,
538    receipt: BindReceipt,
539) -> Result<BindingAttestation, BindingProviderError> {
540    if receipt.request_digest != request.request_digest {
541        return Err(BindingProviderError::RequestDigestMismatch {
542            agent: request.agent.clone(),
543            requested: request.request_digest.clone(),
544            effective: receipt.request_digest,
545        });
546    }
547    if receipt.provider_id.trim().is_empty() {
548        return Err(BindingProviderError::EmptyProviderId {
549            agent: request.agent.clone(),
550        });
551    }
552    if let Some(requested) = &request.requested_model {
553        if receipt
554            .resolved_model
555            .as_deref()
556            .map_or(true, str::is_empty)
557        {
558            return Err(BindingProviderError::MissingResolvedModel {
559                agent: request.agent.clone(),
560                requested: requested.clone(),
561            });
562        }
563    }
564
565    let effective_tools = canonical_tools(&receipt.effective_tools);
566    let effective_set: BTreeSet<&str> = effective_tools.iter().map(String::as_str).collect();
567    let missing: Vec<String> = request
568        .requested_tools
569        .iter()
570        .filter(|tool| !effective_set.contains(tool.as_str()))
571        .cloned()
572        .collect();
573    if !missing.is_empty() {
574        return Err(BindingProviderError::MissingTools {
575            agent: request.agent.clone(),
576            missing,
577        });
578    }
579    if let Some(requested) = &request.launch_variant {
580        if receipt.launch_variant.as_ref() != Some(requested) {
581            return Err(BindingProviderError::VariantMismatch {
582                agent: request.agent.clone(),
583                requested: requested.clone(),
584                effective: receipt.launch_variant,
585            });
586        }
587    }
588
589    Ok(BindingAttestation {
590        request_digest: request.request_digest.clone(),
591        provider_id: receipt.provider_id,
592        provider_revision: receipt.provider_revision,
593        resolved_model: receipt.resolved_model,
594        effective_tools,
595        launch_variant: receipt.launch_variant,
596        capability_snapshot_digest: receipt.capability_snapshot_digest,
597    })
598}
599
600fn canonical_tools(tools: &[String]) -> Vec<String> {
601    tools
602        .iter()
603        .filter(|tool| !tool.is_empty())
604        .cloned()
605        .collect::<BTreeSet<_>>()
606        .into_iter()
607        .collect()
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613    use crate::blueprint::{
614        current_schema_version, resolve_bound_agents, AgentDef, AgentKind, AgentProfile, Blueprint,
615        BlueprintMetadata, CompilerHints, CompilerStrategy,
616    };
617    use mlua_flow_ir::Node as FlowNode;
618    use serde_json::json;
619
620    struct ReceiptProvider(Vec<BindReceipt>);
621
622    #[async_trait]
623    impl AgentBindingProvider for ReceiptProvider {
624        async fn bind(
625            &self,
626            _requests: &[BindRequest],
627        ) -> Result<Vec<BindOutcome>, BindingProviderError> {
628            Ok(self
629                .0
630                .iter()
631                .cloned()
632                .map(|receipt| BindOutcome::Bound { receipt })
633                .collect())
634        }
635    }
636
637    /// Provider that reports every request as `Unbound` with a fixed reason —
638    /// exercises the `strict` gate in [`attest_bound_agents`].
639    struct UnboundProvider(&'static str);
640
641    #[async_trait]
642    impl AgentBindingProvider for UnboundProvider {
643        async fn bind(
644            &self,
645            requests: &[BindRequest],
646        ) -> Result<Vec<BindOutcome>, BindingProviderError> {
647            Ok(requests
648                .iter()
649                .map(|request| BindOutcome::Unbound {
650                    agent: request.agent.clone(),
651                    reason: self.0.to_string(),
652                })
653                .collect())
654        }
655    }
656
657    fn bound() -> Vec<BoundAgent> {
658        let mut bp = Blueprint {
659            schema_version: current_schema_version(),
660            id: "binding-test".into(),
661            flow: FlowNode::Seq { children: vec![] },
662            agents: vec![],
663            operators: vec![],
664            metas: vec![],
665            hints: CompilerHints::default(),
666            strategy: CompilerStrategy::default(),
667            metadata: BlueprintMetadata::default(),
668            spawner_hints: Default::default(),
669            default_agent_kind: AgentKind::Operator,
670            default_operator_kind: None,
671            default_init_ctx: None,
672            default_agent_ctx: None,
673            default_context_policy: None,
674            projection_placement: None,
675            audits: vec![],
676            degradation_policy: None,
677            runners: vec![],
678            default_runner: None,
679            subprocesses: vec![],
680            check_policy: None,
681            blueprint_ref_includes: vec![],
682        };
683        bp.agents.push(AgentDef {
684            name: "coder".to_string(),
685            kind: AgentKind::Operator,
686            spec: json!({ "operator_ref": "main-ai" }),
687            profile: Some(AgentProfile {
688                model: Some("sonnet".to_string()),
689                ..Default::default()
690            }),
691            meta: None,
692            runner: Some(Runner::WsClaudeCode {
693                variant: "mse-coder".to_string(),
694                tools: vec!["Write".to_string(), "Read".to_string()],
695            }),
696            runner_ref: None,
697            verdict: None,
698            lints: None,
699        });
700        resolve_bound_agents(&bp).unwrap()
701    }
702
703    fn receipt() -> BindReceipt {
704        BindReceipt {
705            agent: "coder".to_string(),
706            request_digest: bound()[0].binding_digest.clone(),
707            provider_id: "operator-main-ai".to_string(),
708            provider_revision: Some("1".to_string()),
709            resolved_model: Some("claude-sonnet-4".to_string()),
710            effective_tools: vec!["Write".to_string(), "Read".to_string()],
711            launch_variant: Some("mse-coder".to_string()),
712            capability_snapshot_digest: None,
713        }
714    }
715
716    #[test]
717    fn requests_are_canonical_and_include_declaration_digest() {
718        let bound = bound();
719        let requests = binding_requests(&bound);
720        assert_eq!(requests[0].agent, "coder");
721        assert_eq!(requests[0].request_digest, bound[0].binding_digest);
722        assert_eq!(requests[0].backend, BindingBackend::WsClaudeCode);
723        assert_eq!(
724            requests[0].binding_target.as_ref().map(|t| t.as_str()),
725            Some("main-ai")
726        );
727        assert_eq!(requests[0].requested_model.as_deref(), Some("sonnet"));
728        assert_eq!(requests[0].requested_tools, ["Read", "Write"]);
729        assert_eq!(requests[0].launch_variant.as_deref(), Some("mse-coder"));
730    }
731
732    #[tokio::test]
733    async fn accepted_receipt_is_attested_and_changes_digest() {
734        let mut bound = bound();
735        let declaration_digest = bound[0].binding_digest.clone();
736        let unbound = attest_bound_agents(&ReceiptProvider(vec![receipt()]), &mut bound, false)
737            .await
738            .unwrap();
739        assert!(unbound.is_empty());
740        assert_ne!(bound[0].binding_digest, declaration_digest);
741        validate_bound_agent_snapshot(&bound[0]).unwrap();
742        assert_eq!(
743            bound[0].attestation.as_ref().unwrap().effective_tools,
744            ["Read", "Write"]
745        );
746    }
747
748    #[test]
749    fn persisted_snapshot_rejects_binding_digest_drift() {
750        let mut bound = bound().remove(0);
751        bound.agent.profile.as_mut().unwrap().system_prompt = "mutated after persistence".into();
752
753        let error = validate_bound_agent_snapshot(&bound).unwrap_err();
754        assert!(matches!(error, BindingProviderError::Digest(_)));
755    }
756
757    #[tokio::test]
758    async fn persisted_snapshot_rejects_attestation_for_a_different_declaration() {
759        let mut bound = bound();
760        attest_bound_agents(&ReceiptProvider(vec![receipt()]), &mut bound, false)
761            .await
762            .unwrap();
763        bound[0].attestation.as_mut().unwrap().request_digest =
764            crate::blueprint::BindingDigest::sha256("other-declaration");
765        bound[0].recompute_binding_digest().unwrap();
766
767        let error = validate_bound_agent_snapshot(&bound[0]).unwrap_err();
768        assert!(matches!(
769            error,
770            BindingProviderError::RequestDigestMismatch { .. }
771        ));
772    }
773
774    #[tokio::test]
775    async fn persisted_snapshot_accepts_the_legacy_evidence_digest_identity() {
776        let mut receipt = receipt();
777        receipt.capability_snapshot_digest = Some(crate::blueprint::BindingDigest::sha256(
778            "legacy-capabilities",
779        ));
780        let mut bound = bound();
781        attest_bound_agents(&ReceiptProvider(vec![receipt]), &mut bound, false)
782            .await
783            .unwrap();
784        let new_digest = bound[0].binding_digest.clone();
785        let legacy_digest = legacy_evidence_binding_digest(&bound[0]).unwrap();
786        assert_ne!(legacy_digest, new_digest);
787
788        bound[0].binding_digest = legacy_digest;
789        validate_bound_agent_snapshot(&bound[0]).unwrap();
790    }
791
792    #[tokio::test]
793    async fn missing_tool_fails_closed() {
794        let mut bound = bound();
795        let mut receipt = receipt();
796        receipt.effective_tools = vec!["Read".to_string()];
797        // A receipt that IS present but contradicts the request fails in
798        // non-strict mode too — "attestation is optional, but never wrong".
799        let error = attest_bound_agents(&ReceiptProvider(vec![receipt]), &mut bound, false)
800            .await
801            .unwrap_err();
802        assert_eq!(
803            error,
804            BindingProviderError::MissingTools {
805                agent: "coder".to_string(),
806                missing: vec!["Write".to_string()],
807            }
808        );
809    }
810
811    #[tokio::test]
812    async fn missing_receipt_fails_closed() {
813        let mut bound = bound();
814        let error = attest_bound_agents(&ReceiptProvider(vec![]), &mut bound, false)
815            .await
816            .unwrap_err();
817        assert_eq!(
818            error,
819            BindingProviderError::MissingReceipt {
820                agent: "coder".to_string(),
821            }
822        );
823        assert!(bound[0].attestation.is_none());
824    }
825
826    #[tokio::test]
827    async fn stale_request_digest_fails_closed() {
828        let mut bound = bound();
829        let mut receipt = receipt();
830        receipt.request_digest = crate::blueprint::BindingDigest::sha256("stale");
831        let error = attest_bound_agents(&ReceiptProvider(vec![receipt]), &mut bound, false)
832            .await
833            .unwrap_err();
834        assert!(matches!(
835            error,
836            BindingProviderError::RequestDigestMismatch { .. }
837        ));
838        assert!(bound[0].attestation.is_none());
839    }
840
841    #[tokio::test]
842    async fn variant_mismatch_fails_closed() {
843        let mut bound = bound();
844        let mut receipt = receipt();
845        receipt.launch_variant = Some("other".to_string());
846        let error = attest_bound_agents(&ReceiptProvider(vec![receipt]), &mut bound, false)
847            .await
848            .unwrap_err();
849        assert!(matches!(
850            error,
851            BindingProviderError::VariantMismatch { .. }
852        ));
853    }
854
855    #[tokio::test]
856    async fn unbound_outcome_is_collected_in_non_strict_mode() {
857        let mut bound = bound();
858        let unbound = attest_bound_agents(
859            &UnboundProvider("no capability for launch variant"),
860            &mut bound,
861            false,
862        )
863        .await
864        .expect("non-strict must not fail on Unbound");
865        assert_eq!(unbound.len(), 1);
866        assert_eq!(unbound[0].agent, "coder");
867        assert_eq!(unbound[0].reason, "no capability for launch variant");
868        // The agent stays DeclarationOnly — no attestation is pinned.
869        assert!(bound[0].attestation.is_none());
870    }
871
872    #[tokio::test]
873    async fn unbound_outcome_fails_closed_in_strict_mode_with_requirements() {
874        let mut bound = bound();
875        let error = attest_bound_agents(
876            &UnboundProvider("role main-ai has not joined"),
877            &mut bound,
878            true,
879        )
880        .await
881        .unwrap_err();
882        match &error {
883            BindingProviderError::AttestationRequired {
884                agent,
885                variant,
886                tools,
887                ..
888            } => {
889                assert_eq!(agent, "coder");
890                assert_eq!(variant.as_deref(), Some("mse-coder"));
891                assert_eq!(tools, &["Read".to_string(), "Write".to_string()]);
892            }
893            other => panic!("expected AttestationRequired, got {other:?}"),
894        }
895        // The message must name the agent and the requested variant/tools so
896        // an Operator can generate a satisfying manifest.
897        let message = error.to_string();
898        assert!(message.contains("coder"), "message: {message}");
899        assert!(message.contains("mse-coder"), "message: {message}");
900        assert!(message.contains("Read"), "message: {message}");
901        assert!(bound[0].attestation.is_none());
902    }
903}