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