Skip to main content

licoup_workflow_runtime/
assistant.rs

1//! Assistant-temporary Graph admission.
2//!
3//! This Facade coordinates Conversation/Profile facts, Graph compilation and
4//! exact Membership binding without owning any of those facts. Every
5//! rejection here is pure and precedes durable admission or an effect permit.
6
7use serde::{Deserialize, Serialize};
8use serde_json::{Value, json};
9use std::collections::{BTreeMap, BTreeSet};
10use std::fmt::{Display, Formatter};
11
12use licoup_conversation::{
13    LICOUP_GUIDE_SKILL_ID, MembershipProfileSnapshot, ProfileResponsibility,
14};
15
16use crate::ports::{CandidateFilters, ModelFactsPort, rank_candidates, route_receipt};
17use licoup_workflow::{
18    PreflightDiagnostic, WorkflowDiagnosticCode, WorkflowDiagnosticRecovery,
19    WorkflowDiagnosticStage,
20};
21
22use licoup_workflow::{BindingKind, GraphStateKind, WorkflowDefinition, validate_workflow_value};
23
24use licoup_workflow_store::BindingValue;
25
26pub const ASSISTANT_TEMPORARY_DEFINITION_PREFIX: &str = "assistant-temporary";
27
28#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
29#[serde(rename_all = "camelCase", deny_unknown_fields)]
30pub struct PreflightReceipt {
31    pub conversation_id: String,
32    pub assistant_membership_id: String,
33    pub workflow_digest: String,
34    pub membership_ids: Vec<String>,
35    pub route_receipt: Value,
36}
37
38#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
39#[serde(rename_all = "camelCase", deny_unknown_fields)]
40pub struct PreflightFailure {
41    pub code: String,
42    pub stage: String,
43    pub retryable: bool,
44    pub recovery: String,
45    pub diagnostics: Vec<PreflightDiagnostic>,
46}
47
48impl Display for PreflightFailure {
49    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
50        formatter.write_str(&self.code)
51    }
52}
53
54impl std::error::Error for PreflightFailure {}
55
56impl PreflightFailure {
57    pub(crate) fn new(code: &'static str, mut diagnostics: Vec<PreflightDiagnostic>) -> Self {
58        diagnostics.sort_by(|left, right| {
59            left.stage
60                .cmp(&right.stage)
61                .then_with(|| left.path.cmp(&right.path))
62                .then_with(|| left.code.cmp(&right.code))
63                .then_with(|| left.related_paths.cmp(&right.related_paths))
64                .then_with(|| left.membership_id.cmp(&right.membership_id))
65        });
66        diagnostics.dedup();
67        Self {
68            code: code.to_owned(),
69            stage: "assistant-workflow/preflight".to_owned(),
70            retryable: true,
71            recovery: "correct_graph_or_bindings_and_retry".to_owned(),
72            diagnostics,
73        }
74    }
75}
76
77#[derive(Clone, Debug)]
78pub struct AssistantPreflight {
79    pub definition: WorkflowDefinition,
80    pub bindings: Vec<BindingValue>,
81    pub receipt: PreflightReceipt,
82    /// Store-owned revisions revalidated immediately before durable admission.
83    /// This is internal admission state, not a second public Profile projection.
84    pub profile_revisions: BTreeMap<String, i64>,
85}
86
87pub fn preflight_assistant_graph(
88    conversation_id: &str,
89    assistant_membership_id: &str,
90    workflow: &Value,
91    bindings: &[BindingValue],
92    snapshots: &[MembershipProfileSnapshot],
93    filters: &CandidateFilters,
94    model_facts: &dyn ModelFactsPort,
95    selection_policy_revision: &str,
96) -> Result<AssistantPreflight, PreflightFailure> {
97    let validation = validate_workflow_value(workflow);
98    let mut checks = validation.diagnostics;
99
100    let by_membership = snapshots
101        .iter()
102        .map(|snapshot| (snapshot.membership_id.as_str(), snapshot))
103        .collect::<BTreeMap<_, _>>();
104    let assistant = by_membership.get(assistant_membership_id).copied();
105    if assistant.is_none() {
106        checks.push(check_at(
107            "graph_assistant_membership_rejected",
108            Some("/assistantMembershipId"),
109            Some(assistant_membership_id),
110        ));
111    }
112    if assistant
113        .is_some_and(|assistant| assistant.responsibility != ProfileResponsibility::Assistant)
114    {
115        checks.push(check_at(
116            "graph_assistant_designation_rejected",
117            Some("/assistantMembershipId"),
118            Some(assistant_membership_id),
119        ));
120    }
121    if assistant.is_some_and(|assistant| {
122        !assistant
123            .skills
124            .iter()
125            .any(|skill| skill == LICOUP_GUIDE_SKILL_ID)
126            || !conversation_driver_available(assistant)
127    }) {
128        checks.push(check_at(
129            "graph_assistant_skill_unavailable",
130            Some("/assistantMembershipId"),
131            Some(assistant_membership_id),
132        ));
133    }
134    if assistant.is_some_and(|assistant| {
135        filters
136            .required_authority
137            .iter()
138            .any(|required| !assistant.authority.contains(required))
139    }) {
140        checks.push(check_at(
141            "graph_authority_rejected",
142            Some("/filters/requiredAuthority"),
143            Some(assistant_membership_id),
144        ));
145    }
146    if assistant.is_some_and(|assistant| assistant.model.is_none()) {
147        checks.push(check_at(
148            "graph_model_unavailable",
149            Some("/assistantMembershipId"),
150            Some(assistant_membership_id),
151        ));
152    }
153    if assistant.is_some_and(|assistant| assistant.readiness.as_deref() != Some("ready")) {
154        checks.push(check_at(
155            "graph_readiness_rejected",
156            Some("/assistantMembershipId"),
157            Some(assistant_membership_id),
158        ));
159    }
160    if assistant.is_some_and(|assistant| assistant.environment.is_none()) {
161        checks.push(check_at(
162            "graph_environment_unavailable",
163            Some("/assistantMembershipId"),
164            Some(assistant_membership_id),
165        ));
166    }
167
168    let Some(definition) = validation.definition else {
169        return Err(PreflightFailure::new(failure_code(&checks), checks));
170    };
171    if !definition
172        .metadata
173        .id
174        .starts_with(ASSISTANT_TEMPORARY_DEFINITION_PREFIX)
175    {
176        checks.push(diagnostic(
177            "graph_identity_not_assistant_temporary",
178            "assistant-workflow/preflight",
179            Some("/metadata/id"),
180            None,
181            None,
182            None,
183        ));
184    }
185    if !definition.runtimes.is_empty()
186        || definition
187            .states
188            .iter()
189            .any(|state| state.kind == GraphStateKind::Script)
190    {
191        checks.push(check_at(
192            "graph_runtime_asset_unavailable",
193            Some("/runtimes"),
194            None,
195        ));
196    }
197
198    let actor_slots = definition
199        .actor_slots
200        .iter()
201        .map(|slot| (slot.id.as_str(), slot))
202        .collect::<BTreeMap<_, _>>();
203    let required_slots = definition
204        .actor_slots
205        .iter()
206        .filter(|slot| slot.required && slot.kind == BindingKind::Actor)
207        .map(|slot| slot.id.as_str())
208        .collect::<BTreeSet<_>>();
209    let mut seen_bindings = BTreeSet::new();
210    let mut bound_slots = BTreeSet::new();
211    let mut bound_membership_ids = BTreeSet::new();
212    let mut canonical_bindings = Vec::new();
213    for (binding_index, binding) in bindings.iter().enumerate() {
214        let binding_pointer = format!("/bindings/{binding_index}");
215        if !seen_bindings.insert((binding.slot_id.as_str(), binding.ordinal)) {
216            checks.push(check_at(
217                "graph_binding_duplicate",
218                Some(binding_pointer.as_str()),
219                None,
220            ));
221            continue;
222        }
223        let Some(slot) = actor_slots.get(binding.slot_id.as_str()) else {
224            checks.push(check_at(
225                "graph_binding_unknown",
226                Some(format!("{binding_pointer}/slotId").as_str()),
227                None,
228            ));
229            continue;
230        };
231        if slot.kind != BindingKind::Actor || binding.ordinal >= 16 {
232            checks.push(check_at(
233                "graph_binding_kind_rejected",
234                Some(binding_pointer.as_str()),
235                None,
236            ));
237            continue;
238        }
239        if binding.ordinal == 0 {
240            bound_slots.insert(binding.slot_id.as_str());
241        }
242        let Some(member) = by_membership.get(binding.value_id.as_str()).copied() else {
243            checks.push(check_at(
244                "graph_membership_rejected",
245                Some(format!("{binding_pointer}/valueId").as_str()),
246                Some(binding.value_id.as_str()),
247            ));
248            continue;
249        };
250        bound_membership_ids.insert(member.membership_id.clone());
251        if member.membership_id != assistant_membership_id && member.model.is_none() {
252            checks.push(check_at(
253                "graph_model_unavailable",
254                Some(format!("{binding_pointer}/model").as_str()),
255                Some(member.membership_id.as_str()),
256            ));
257        } else if !binding.model.is_empty()
258            && member.model.as_deref() != Some(binding.model.as_str())
259        {
260            checks.push(check_at(
261                "graph_model_rejected",
262                Some(format!("{binding_pointer}/model").as_str()),
263                Some(member.membership_id.as_str()),
264            ));
265        }
266        if member.membership_id != assistant_membership_id
267            && member.readiness.as_deref() != Some("ready")
268        {
269            checks.push(check_at(
270                "graph_readiness_rejected",
271                Some(format!("{binding_pointer}/valueId").as_str()),
272                Some(member.membership_id.as_str()),
273            ));
274        }
275        if member.membership_id != assistant_membership_id && member.environment.is_none() {
276            checks.push(check_at(
277                "graph_environment_unavailable",
278                Some(format!("{binding_pointer}/valueId").as_str()),
279                Some(member.membership_id.as_str()),
280            ));
281        }
282        canonical_bindings.push(BindingValue {
283            slot_id: binding.slot_id.clone(),
284            ordinal: binding.ordinal,
285            value_id: member.membership_id.clone(),
286            model: member.model.clone().unwrap_or_default(),
287            reasoning_effort: binding.reasoning_effort.clone(),
288            revision: 0,
289        });
290    }
291    for slot in required_slots.difference(&bound_slots) {
292        let slot_index = definition
293            .actor_slots
294            .iter()
295            .position(|candidate| candidate.id == *slot)
296            .unwrap_or_default();
297        checks.push(check_at(
298            "graph_binding_incomplete",
299            Some(format!("/actorSlots/{slot_index}").as_str()),
300            None,
301        ));
302    }
303
304    let requested_memberships = filters
305        .membership_ids
306        .iter()
307        .map(String::as_str)
308        .collect::<BTreeSet<_>>();
309    if !requested_memberships.is_empty()
310        && bound_membership_ids
311            .iter()
312            .any(|membership_id| !requested_memberships.contains(membership_id.as_str()))
313    {
314        checks.push(check_at(
315            "graph_membership_rejected",
316            Some("/filters/membershipIds"),
317            None,
318        ));
319    }
320    let mut exact_filters = filters.clone();
321    exact_filters.membership_ids = bound_membership_ids.iter().cloned().collect();
322    let ranked = match rank_candidates(snapshots.to_vec(), &exact_filters) {
323        Ok(ranked) => ranked,
324        Err(_) => {
325            checks.push(check_at("graph_profile_rejected", Some("/filters"), None));
326            Vec::new()
327        }
328    };
329
330    if !checks.is_empty() {
331        return Err(PreflightFailure::new(failure_code(&checks), checks));
332    }
333    canonical_bindings.sort_by(|left, right| {
334        left.slot_id
335            .cmp(&right.slot_id)
336            .then_with(|| left.ordinal.cmp(&right.ordinal))
337            .then_with(|| left.value_id.cmp(&right.value_id))
338    });
339    // The receipt states the selection-policy revision the admitting caller
340    // captured for this decision. Nothing is read and nothing is synthesized
341    // here: a durable admission keeps the revision it was admitted under, so a
342    // later adoption governs only the next task.
343    let route_receipt = route_receipt(
344        conversation_id,
345        &ranked,
346        model_facts,
347        selection_policy_revision,
348    );
349    let digest_payload = json!({
350        "workflow": definition,
351        "bindings": canonical_bindings,
352        "routeReceipt": route_receipt,
353    });
354    let workflow_digest = sha256_hex(&serde_json::to_vec(&digest_payload).unwrap_or_default());
355    let membership_ids = bound_membership_ids.into_iter().collect::<Vec<_>>();
356    let profile_revisions = snapshots
357        .iter()
358        .filter(|snapshot| {
359            snapshot.membership_id == assistant_membership_id
360                || membership_ids.contains(&snapshot.membership_id)
361        })
362        .map(|snapshot| (snapshot.membership_id.clone(), snapshot.intent_revision))
363        .collect();
364    Ok(AssistantPreflight {
365        definition,
366        bindings: canonical_bindings,
367        receipt: PreflightReceipt {
368            conversation_id: conversation_id.to_owned(),
369            assistant_membership_id: assistant_membership_id.to_owned(),
370            workflow_digest,
371            membership_ids,
372            route_receipt,
373        },
374        profile_revisions,
375    })
376}
377
378fn failure_code(diagnostics: &[PreflightDiagnostic]) -> &'static str {
379    if diagnostics.len() == 1
380        && diagnostics[0].code == WorkflowDiagnosticCode::GraphIdentityNotAssistantTemporary
381    {
382        "graph_identity_rejected"
383    } else if diagnostics.iter().any(|diagnostic| {
384        matches!(
385            diagnostic.stage,
386            WorkflowDiagnosticStage::AssistantWorkflowPreflight
387                | WorkflowDiagnosticStage::AssistantWorkflowRevalidate
388        )
389    }) {
390        "graph_preflight_rejected"
391    } else {
392        "graph_invalid"
393    }
394}
395
396fn conversation_driver_available(snapshot: &MembershipProfileSnapshot) -> bool {
397    snapshot.capabilities.iter().any(|capability| {
398        matches!(
399            capability.as_str(),
400            "conversationDriver:supported" | "conversationDriver:ready"
401        )
402    })
403}
404
405fn check_at(
406    code: &str,
407    json_pointer: Option<&str>,
408    membership_id: Option<&str>,
409) -> PreflightDiagnostic {
410    diagnostic(
411        code,
412        "assistant-workflow/preflight",
413        json_pointer,
414        membership_id,
415        None,
416        None,
417    )
418}
419
420fn diagnostic(
421    code: &str,
422    stage: &str,
423    json_pointer: Option<&str>,
424    membership_id: Option<&str>,
425    actual: Option<u64>,
426    limit: Option<u64>,
427) -> PreflightDiagnostic {
428    let code =
429        WorkflowDiagnosticCode::from_wire(code).unwrap_or(WorkflowDiagnosticCode::WorkflowInvalid);
430    let recovery = diagnostic_recovery(code);
431    PreflightDiagnostic {
432        code,
433        stage: WorkflowDiagnosticStage::from_wire(stage)
434            .unwrap_or(WorkflowDiagnosticStage::WorkflowCompile),
435        path: json_pointer.map(str::to_owned),
436        related_paths: Vec::new(),
437        membership_id: membership_id
438            .filter(|value| valid_diagnostic_identity(value))
439            .map(str::to_owned),
440        actual,
441        limit,
442        expected: None,
443        actual_kind: None,
444        recovery: Some(recovery),
445        line: None,
446        column: None,
447    }
448}
449
450fn diagnostic_recovery(code: WorkflowDiagnosticCode) -> WorkflowDiagnosticRecovery {
451    use WorkflowDiagnosticCode as Code;
452    match code {
453        Code::WorkflowRequiredFieldMissing => WorkflowDiagnosticRecovery::AddRequiredField,
454        Code::WorkflowUnknownField => WorkflowDiagnosticRecovery::RemoveUnknownField,
455        Code::WorkflowBindingDuplicate
456        | Code::WorkflowStateDuplicate
457        | Code::WorkflowTransitionDuplicate => WorkflowDiagnosticRecovery::RemoveDuplicate,
458        Code::WorkflowRuntimeBindingInvalid
459        | Code::WorkflowActorBindingInvalid
460        | Code::WorkflowScriptRuntimeInvalid
461        | Code::WorkflowWorksetReferenceInvalid
462        | Code::WorkflowWorksetBindingInvalid
463        | Code::WorkflowInitialUnknown
464        | Code::WorkflowTransitionStateUnknown => WorkflowDiagnosticRecovery::CorrectReference,
465        Code::WorkflowStateLimit
466        | Code::WorkflowTransitionLimit
467        | Code::WorkflowBindingLimit
468        | Code::WorkflowRuntimeLimit
469        | Code::WorkflowParallelismInvalid
470        | Code::WorkflowWorksetLimitInvalid
471        | Code::WorkflowRetryLimitInvalid => WorkflowDiagnosticRecovery::ReduceResource,
472        Code::WorkflowGuardAmbiguous | Code::WorkflowRoutingInvalid => {
473            WorkflowDiagnosticRecovery::CorrectRouting
474        }
475        Code::WorkflowTopologyInvalid
476        | Code::WorkflowStateUnreachable
477        | Code::WorkflowTerminalUnreachable
478        | Code::WorkflowEffectCycle => WorkflowDiagnosticRecovery::CorrectTopology,
479        Code::GraphAssistantMembershipRejected
480        | Code::GraphAssistantDesignationRejected
481        | Code::GraphAssistantSkillUnavailable
482        | Code::GraphAuthorityRejected
483        | Code::GraphModelUnavailable
484        | Code::GraphModelRejected
485        | Code::GraphReadinessRejected
486        | Code::GraphEnvironmentUnavailable
487        | Code::GraphProfileRejected => WorkflowDiagnosticRecovery::UpdateAssistantProfile,
488        Code::GraphBindingDuplicate
489        | Code::GraphBindingUnknown
490        | Code::GraphBindingKindRejected
491        | Code::GraphBindingIncomplete
492        | Code::GraphMembershipRejected => WorkflowDiagnosticRecovery::UpdateBinding,
493        Code::ConversationStateUnavailable | Code::ConversationNotFound | Code::GraphRouteStale => {
494            WorkflowDiagnosticRecovery::RefreshConversationState
495        }
496        _ => WorkflowDiagnosticRecovery::CorrectField,
497    }
498}
499
500fn valid_diagnostic_identity(value: &str) -> bool {
501    !value.is_empty()
502        && value.len() <= 256
503        && value.chars().all(|character| {
504            character.is_ascii_alphanumeric() || matches!(character, ':' | '.' | '_' | '-')
505        })
506}
507
508pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
509    use sha2::{Digest, Sha256};
510    let digest = Sha256::digest(bytes);
511    digest.iter().map(|byte| format!("{byte:02x}")).collect()
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517
518    /// The selection-policy revision a test admits its fixture graph under.
519    /// It is stated explicitly: the receipt must never carry a synthesized
520    /// or current-reading revision.
521    const FIXTURE_SELECTION_POLICY_REVISION: &str = "selection-policy-fixture-1";
522    use licoup_workflow::{MAX_ACTIVE_EFFECTS, MAX_WORKSET_ITEMS};
523
524    fn workflow() -> Value {
525        json!({
526            "schema": "licoup.adaptive-flywheel.workflow.v1",
527            "metadata": {"id": "assistant-temporary", "name": "Temporary", "version": "1"},
528            "limits": {"maxParallelism": 2, "maxWorksetItems": 16, "maxAttempts": 2},
529            "actorSlots": [{"id": "actor", "kind": "actor", "label": "Actor", "required": true, "entry": true}],
530            "runtimes": [],
531            "worksets": [],
532            "initial": "run",
533            "states": [
534                {"id": "run", "kind": "actor", "label": "Run", "binding": "actor"},
535                {"id": "done", "kind": "succeed", "label": "Done"},
536                {"id": "failed", "kind": "fail", "label": "Failed"}
537            ],
538            "transitions": [
539                {"id": "done", "from": "run", "to": "done", "event": "success"},
540                {"id": "failed", "from": "run", "to": "failed", "event": "failure"}
541            ],
542        })
543    }
544
545    fn snapshot(id: &str, responsibility: ProfileResponsibility) -> MembershipProfileSnapshot {
546        MembershipProfileSnapshot {
547            conversation_id: "conversation:g".to_owned(),
548            membership_id: id.to_owned(),
549            agent_id: "agent:a".to_owned(),
550            intent_revision: 1,
551            responsibility,
552            required_capabilities: vec!["conversationDriver:supported".to_owned()],
553            preferred_capabilities: Vec::new(),
554            skill_references: if responsibility == ProfileResponsibility::Assistant {
555                vec![LICOUP_GUIDE_SKILL_ID.to_owned()]
556            } else {
557                Vec::new()
558            },
559            preferred_model: None,
560            preferred_reasoning_effort: None,
561            preferred_environment: None,
562            preferred_permission_mode: None,
563            model: Some("model-a".to_owned()),
564            capabilities: vec!["conversationDriver:supported".to_owned()],
565            skills: if responsibility == ProfileResponsibility::Assistant {
566                vec![LICOUP_GUIDE_SKILL_ID.to_owned()]
567            } else {
568                Vec::new()
569            },
570            environment: Some("local".to_owned()),
571            readiness: Some("ready".to_owned()),
572            price_input_usd_per_million_tokens: Some(1.0),
573            price_output_usd_per_million_tokens: Some(2.0),
574            intelligence_score: Some(8),
575            task_tags: Vec::new(),
576            reliability_class: Some("verified".to_owned()),
577            latency_class: Some(1),
578            authority: vec![
579                "conversation.act".to_owned(),
580                "conversation.read".to_owned(),
581            ],
582        }
583    }
584
585    #[test]
586    fn exact_memberships_and_owner_derived_model_are_frozen() {
587        let snapshots = vec![
588            snapshot("membership:assistant", ProfileResponsibility::Assistant),
589            snapshot("membership:actor", ProfileResponsibility::Member),
590        ];
591        let admitted = preflight_assistant_graph(
592            "conversation:g",
593            "membership:assistant",
594            &workflow(),
595            &[BindingValue {
596                slot_id: "actor".to_owned(),
597                ordinal: 0,
598                value_id: "membership:actor".to_owned(),
599                model: String::new(),
600                reasoning_effort: String::new(),
601                revision: 0,
602            }],
603            &snapshots,
604            &CandidateFilters::default(),
605            crate::ports::host_ports().model.as_ref(),
606            FIXTURE_SELECTION_POLICY_REVISION,
607        )
608        .unwrap();
609        assert_eq!(admitted.bindings[0].value_id, "membership:actor");
610        assert_eq!(admitted.bindings[0].model, "model-a");
611        assert_eq!(admitted.receipt.membership_ids, vec!["membership:actor"]);
612    }
613
614    #[test]
615    fn missing_skill_and_runtime_assets_fail_before_admission() {
616        let mut assistant = snapshot("membership:assistant", ProfileResponsibility::Assistant);
617        assistant.skills.clear();
618        let mut script = workflow();
619        script["actorSlots"].as_array_mut().unwrap().push(json!({
620            "id": "node",
621            "kind": "runtime",
622            "label": "Node",
623            "required": true
624        }));
625        script["runtimes"] = json!([{"id": "node", "kind": "node"}]);
626        let failure = preflight_assistant_graph(
627            "conversation:g",
628            "membership:assistant",
629            &script,
630            &[],
631            &[assistant],
632            &CandidateFilters::default(),
633            crate::ports::host_ports().model.as_ref(),
634            FIXTURE_SELECTION_POLICY_REVISION,
635        )
636        .unwrap_err();
637        assert!(
638            failure
639                .diagnostics
640                .iter()
641                .any(|check| check.code == "graph_assistant_skill_unavailable")
642        );
643        assert!(
644            failure
645                .diagnostics
646                .iter()
647                .any(|check| check.code == "graph_runtime_asset_unavailable")
648        );
649    }
650
651    #[test]
652    fn resource_and_identity_diagnostics_preserve_safe_repair_locations() {
653        let mut invalid = workflow();
654        invalid["metadata"]["id"] = json!("imported-workflow");
655        invalid["limits"]["maxParallelism"] = json!(0);
656        invalid["limits"]["maxWorksetItems"] = json!(MAX_WORKSET_ITEMS + 1);
657        let failure = preflight_assistant_graph(
658            "conversation:g",
659            "membership:assistant",
660            &invalid,
661            &[],
662            &[],
663            &CandidateFilters::default(),
664            crate::ports::host_ports().model.as_ref(),
665            FIXTURE_SELECTION_POLICY_REVISION,
666        )
667        .unwrap_err();
668        assert_eq!(failure.code, "graph_preflight_rejected");
669        assert!(failure.diagnostics.iter().any(|diagnostic| {
670            diagnostic.code == "graph_identity_not_assistant_temporary"
671                && diagnostic.path.as_deref() == Some("/metadata/id")
672        }));
673        assert!(failure.diagnostics.iter().any(|diagnostic| {
674            diagnostic.code == "workflow_parallelism_invalid"
675                && diagnostic.path.as_deref() == Some("/limits/maxParallelism")
676                && diagnostic.actual == Some(0)
677                && diagnostic.limit == Some(MAX_ACTIVE_EFFECTS as u64)
678        }));
679        assert!(failure.diagnostics.iter().any(|diagnostic| {
680            diagnostic.code == "workflow_workset_limit_invalid"
681                && diagnostic.path.as_deref() == Some("/limits/maxWorksetItems")
682                && diagnostic.actual == Some((MAX_WORKSET_ITEMS + 1) as u64)
683                && diagnostic.limit == Some(MAX_WORKSET_ITEMS as u64)
684        }));
685    }
686
687    #[test]
688    fn binding_diagnostics_identify_the_exact_request_item() {
689        let snapshots = vec![snapshot(
690            "membership:assistant",
691            ProfileResponsibility::Assistant,
692        )];
693        let failure = preflight_assistant_graph(
694            "conversation:g",
695            "membership:assistant",
696            &workflow(),
697            &[BindingValue {
698                slot_id: "actor".to_owned(),
699                ordinal: 0,
700                value_id: "membership:missing".to_owned(),
701                model: String::new(),
702                reasoning_effort: String::new(),
703                revision: 0,
704            }],
705            &snapshots,
706            &CandidateFilters::default(),
707            crate::ports::host_ports().model.as_ref(),
708            FIXTURE_SELECTION_POLICY_REVISION,
709        )
710        .unwrap_err();
711        assert!(failure.diagnostics.iter().any(|diagnostic| {
712            diagnostic.code == "graph_membership_rejected"
713                && diagnostic.path.as_deref() == Some("/bindings/0/valueId")
714                && diagnostic.membership_id.as_deref() == Some("membership:missing")
715        }));
716    }
717}