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