Skip to main content

licoup_workflow_runtime/
evolution.rs

1//! Evolution wiring connecting workflow runtime to observation, cost, context,
2//! and strategy ports.
3//!
4//! # Architecture & Governance
5//!
6//! In accordance with `docs/plans/Plan.md` T07.2, D09, and D21:
7//! - Connects observation, cost, context, and strategy into existing workflow callbacks.
8//! - The Assistant receives usable facts and actionable suggestions via callbacks.
9//! - Strategy versions (from T06.1) feed selection without granting execution permission.
10//!   Every [`StrategySuggestion`] explicitly marks `has_execution_permission: false`.
11//!   The configured choice and the adopted default decide first; with neither, the
12//!   selection is routed by the candidate policy
13//!   ([`licoup_model_catalog::candidate_routing`]) over the same candidate catalog and the
14//!   effective scope admission, so an alternative the policy does not allow is never
15//!   suggested — including the first entry of the list, which is not a decision.
16//! - Permission, version, and resource recheck is strictly evaluated before any effect execution
17//!   through [`EffectRecheckContext::for_run_command`], the single construction path used by
18//!   the executor.
19//! - Group B (economics loop, T03–T06) is a separate Draft: integration seams are
20//!   strictly typed and wired against existing host-side seams, with gaps explicitly recorded
21//!   in [`GroupBIntegrationGaps`].
22
23use std::collections::{BTreeMap, BTreeSet};
24use std::path::{Path, PathBuf};
25use std::sync::{Arc, RwLock};
26
27use licoup_workflow::{PendingCallback, RunCommand, RunSnapshot, StrategyRunStatus};
28use serde::{Deserialize, Serialize};
29use serde_json::{Value, json};
30
31use crate::adapter::SingleWriterSessionRegistry;
32use crate::ports::host_ports;
33use licoup_model_catalog::candidate_routing::{
34    CandidateId, CandidateOffer, CandidateRoutingPort, CandidateRoutingRequest,
35    PolicyCandidateRouting, SelectionScope, routing_rationale,
36};
37use licoup_workflow_store::{StrategyAuthorization, StrategyDefinition};
38
39// The vocabulary that carries no host naming is the engine's, re-exported
40// under this module so the established `evolution::*` surface keeps
41// resolving. What stays declared below is what the LicoUp composition owns:
42// the callback facts that name conversations and memberships, the context
43// and strategy ports that read them, the ledger cost port bound to this
44// crate's own host-ports registry, the recheck context typed on this
45// crate's snapshot and command, and the Group B governance record.
46pub use acativa::runtime::evolution::{
47    AdoptedPlanningDefaultSeam, AgentModelOptionSeam, BudgetPoolReservationSeam,
48    ContextManifestSummarySeam, DefaultEvolutionObservationPort, EffectRecheckFailure,
49    EffectRecheckReceipt, EvolutionCostPort, EvolutionObservationPort, EvolutionStrategyPort,
50    NodeObservationSummary, ObservationFactKindSeam, ObservationFactRecordSeam,
51    ObservationScopeSeam, PlanningScopeSeam, SourceRefSeam, StrategySourceSeam, StrategySuggestion,
52    StrategyVersionSeam,
53};
54
55/// The callback's cost reading. The fields are the engine's; the name this
56/// host's callback envelope knows them by is its own.
57pub type CallbackCostFacts = acativa::runtime::evolution::EvolutionCostFacts;
58
59// ============================================================================
60// Group B Typed Integration Seams & Gap Tracking
61// ============================================================================
62
63/// Recorded status and gaps for the separate Group B (economics loop, T03–T06) Draft.
64#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase")]
66pub struct GroupBGapReport {
67    pub t03_observation_gap: &'static str,
68    pub t04_cost_budget_pool_gap: &'static str,
69    pub t05_context_composition_gap: &'static str,
70    pub t06_replaceable_strategy_gap: &'static str,
71}
72
73pub struct GroupBIntegrationGaps;
74
75impl GroupBIntegrationGaps {
76    pub fn report() -> GroupBGapReport {
77        GroupBGapReport {
78            t03_observation_gap: "assistant_continuity::observation with continuity_source_cursors is in group B branch (fix/t03-1-observation); wired here against run snapshots and NodeObservationSummary, whose producer is the T07.4 Node Facade observe() seam rather than native NodeObservation",
79            t04_cost_budget_pool_gap: "workflow_ledger graph_usage_budget_pools and graph_usage_reservations are in group B branch (fix/t04-1-usage-admission); wired here against workflow_ledger v2 numeric token accounting, so the configured budget pool and its reservations stay unavailable and the pre-effect budget recheck has no remaining-token fact to compare",
80            t05_context_composition_gap: "assistant_continuity::context multi-source refinement with parent grants is in group B branch (fix/t05-1-context); wired here against conversation run/causation metadata",
81            t06_replaceable_strategy_gap: "the replaceable strategy seam is wired to the non-learning candidate policy: an explicit user choice and an adopted default decide first, and every other selection is routed by domain::candidate_routing over the configured candidate catalog and the effective scope admission. Durable learned defaults and the qualification policy of domain::model_planning are not composed into this seam yet, so no learned default reaches a callback; the catalogue's ranking is not used to choose a model either, because the seam carries no price or intelligence facts",
82        }
83    }
84}
85
86#[derive(Default)]
87pub struct LedgerEvolutionCostPort;
88
89impl LedgerEvolutionCostPort {
90    pub fn new() -> Self {
91        Self
92    }
93}
94
95impl EvolutionCostPort for LedgerEvolutionCostPort {
96    fn query_cost_facts(&self, portable_root: &Path, run_id: &str) -> Option<CallbackCostFacts> {
97        let report_result = host_ports().usage.workflow_report(&json!({
98            "stateRoot": portable_root,
99            "runId": run_id,
100        }));
101        match report_result {
102            Ok(report) => {
103                let summary = report.get("summary");
104                Some(CallbackCostFacts {
105                    prompt_tokens: summary
106                        .and_then(|s| s.get("promptTokens"))
107                        .and_then(Value::as_u64)
108                        .unwrap_or(0),
109                    cached_input_tokens: summary
110                        .and_then(|s| s.get("cachedInputTokens"))
111                        .and_then(Value::as_u64)
112                        .unwrap_or(0),
113                    completion_tokens: summary
114                        .and_then(|s| s.get("completionTokens"))
115                        .and_then(Value::as_u64)
116                        .unwrap_or(0),
117                    total_tokens: summary
118                        .and_then(|s| s.get("totalTokens"))
119                        .and_then(Value::as_u64)
120                        .unwrap_or(0),
121                    exact_count: summary
122                        .and_then(|s| s.get("exactCount"))
123                        .and_then(Value::as_u64)
124                        .unwrap_or(0),
125                    estimated_count: summary
126                        .and_then(|s| s.get("estimatedCount"))
127                        .and_then(Value::as_u64)
128                        .unwrap_or(0),
129                    budget_pool_seam_active: false,
130                })
131            }
132            Err(_) => None,
133        }
134    }
135}
136
137pub trait EvolutionContextPort: Send + Sync {
138    fn assemble_context_facts(
139        &self,
140        snapshot: &RunSnapshot,
141        state_id: &str,
142        state_visit: u64,
143        transition_id: &str,
144        answer_channel: &str,
145    ) -> CallbackContextFacts;
146}
147
148#[derive(Default)]
149pub struct DefaultEvolutionContextPort;
150
151impl DefaultEvolutionContextPort {
152    pub fn new() -> Self {
153        Self
154    }
155}
156
157impl EvolutionContextPort for DefaultEvolutionContextPort {
158    fn assemble_context_facts(
159        &self,
160        snapshot: &RunSnapshot,
161        state_id: &str,
162        state_visit: u64,
163        transition_id: &str,
164        answer_channel: &str,
165    ) -> CallbackContextFacts {
166        CallbackContextFacts {
167            conversation_id: snapshot.conversation_id.clone(),
168            assistant_membership_id: snapshot.assistant_membership_id.clone(),
169            state_id: state_id.to_owned(),
170            state_visit,
171            transition_id: transition_id.to_owned(),
172            causation_id: Some(snapshot.run_id.clone()),
173            answer_channel: answer_channel.to_owned(),
174            context_composition_seam_active: true,
175        }
176    }
177}
178
179pub struct DefaultEvolutionStrategyPort {
180    defaults: RwLock<BTreeMap<PlanningScopeSeam, AdoptedPlanningDefaultSeam>>,
181    revocations: RwLock<BTreeSet<String>>,
182    routing: Arc<dyn CandidateRoutingPort>,
183}
184
185impl DefaultEvolutionStrategyPort {
186    pub fn new() -> Self {
187        Self {
188            defaults: RwLock::new(BTreeMap::new()),
189            revocations: RwLock::new(BTreeSet::new()),
190            routing: Arc::new(PolicyCandidateRouting),
191        }
192    }
193
194    /// Replace the routing owner this port asks for a non-default selection.
195    ///
196    /// A caller that composes its own policy facts supplies its owner here; a
197    /// caller that supplies none keeps [`PolicyCandidateRouting`], which reads
198    /// the facts the host installed and selects nothing when none were.
199    pub fn with_routing(mut self, routing: Arc<dyn CandidateRoutingPort>) -> Self {
200        self.routing = routing;
201        self
202    }
203
204    /// Adopt a revocable default strategy per D21.
205    pub fn adopt_default(&self, default: AdoptedPlanningDefaultSeam) {
206        let mut guard = self.defaults.write().unwrap_or_else(|p| p.into_inner());
207        guard.insert(default.scope.clone(), default);
208    }
209
210    /// Revoke an adopted strategy default per D21.
211    pub fn revoke_strategy(&self, source_id: &str) {
212        let mut guard = self.revocations.write().unwrap_or_else(|p| p.into_inner());
213        guard.insert(source_id.to_owned());
214    }
215}
216
217impl Default for DefaultEvolutionStrategyPort {
218    fn default() -> Self {
219        Self::new()
220    }
221}
222
223impl EvolutionStrategyPort for DefaultEvolutionStrategyPort {
224    fn suggest_strategy(
225        &self,
226        scope: &PlanningScopeSeam,
227        candidates: &[AgentModelOptionSeam],
228        explicit_user_choice: Option<&AgentModelOptionSeam>,
229    ) -> Option<StrategySuggestion> {
230        // D09 / D21: Explicit user configuration strictly takes precedence.
231        if let Some(user_choice) = explicit_user_choice
232            && candidates.contains(user_choice)
233        {
234            return Some(StrategySuggestion {
235                candidate: user_choice.clone(),
236                ranking_basis: "user-configured".to_owned(),
237                source: None,
238                scope: Some(scope.clone()),
239                is_default: false,
240                has_execution_permission: false, // Invariant: no execution permission granted
241                rationale: "Explicit user selection preserved without implicit upgrade".to_owned(),
242            });
243        }
244
245        let defaults_guard = self.defaults.read().unwrap_or_else(|p| p.into_inner());
246        let revocations_guard = self.revocations.read().unwrap_or_else(|p| p.into_inner());
247
248        if let Some(adopted) = defaults_guard.get(scope) {
249            // Check revocation
250            if !revocations_guard.contains(&adopted.source.source_id) {
251                // Must select only from caller's candidate set
252                if candidates.contains(&adopted.selected_option) {
253                    return Some(StrategySuggestion {
254                        candidate: adopted.selected_option.clone(),
255                        ranking_basis: "adopted-default-comparable-outcome".to_owned(),
256                        source: Some(adopted.source.clone()),
257                        scope: Some(scope.clone()),
258                        is_default: true,
259                        has_execution_permission: false, // Invariant: no execution permission granted
260                        rationale: format!(
261                            "Adopted default from source '{}' rev '{}'",
262                            adopted.source.source_id, adopted.source.revision
263                        ),
264                    });
265                }
266            }
267        }
268
269        // Fallback: ask the routing policy which of the caller's alternatives
270        // may run. The configured order is the preference, not the answer, and
271        // an alternative the policy does not allow is never suggested — a
272        // process that composes no admission owner therefore gets no
273        // suggestion at all instead of the first entry of the list.
274        let outcome = self.routing.route(&candidate_routing_request(candidates));
275        let suggestion = outcome
276            .selected()
277            .and_then(|selected| configured_option(candidates, selected))?;
278        Some(StrategySuggestion {
279            candidate: suggestion.clone(),
280            ranking_basis: "candidate-policy".to_owned(),
281            source: None,
282            scope: Some(scope.clone()),
283            is_default: false,
284            has_execution_permission: false, // Invariant: no execution permission granted
285            rationale: routing_rationale(&outcome),
286        })
287    }
288}
289
290// ============================================================================
291// Usable Callback Facts & Suggestions
292// ============================================================================
293
294/// The policy question one strategy suggestion is answered by.
295///
296/// It is a workflow turn: the alternatives are the configured candidate list
297/// the enricher was given, in its configured order, and the scope is the
298/// catalogue's own [`SelectionScope::Workflow`]. A direct request asks the same
299/// question under `SelectionScope::Direct`; the policy answers each scope on its
300/// own, so one Agent can be suggested for one and not the other.
301fn candidate_routing_request(candidates: &[AgentModelOptionSeam]) -> CandidateRoutingRequest {
302    CandidateRoutingRequest::new(
303        SelectionScope::Workflow,
304        candidates
305            .iter()
306            .enumerate()
307            .map(|(index, candidate)| CandidateOffer {
308                candidate: CandidateId::new(
309                    candidate.agent_id.clone(),
310                    candidate.model_id.clone(),
311                    None,
312                ),
313                configured_position: index + 1,
314            })
315            .collect(),
316    )
317}
318
319/// The configured option one routed candidate names.
320///
321/// The policy ranks Agent/provider/model identities; the suggestion carries the
322/// configured option with its thinking setting. A recommended identity the
323/// configured list does not carry is reported as no suggestion rather than as an
324/// invented option, so the suggestion can never leave the caller's candidate set.
325fn configured_option<'a>(
326    candidates: &'a [AgentModelOptionSeam],
327    candidate: &CandidateId,
328) -> Option<&'a AgentModelOptionSeam> {
329    candidates.iter().find(|option| {
330        option.agent_id == candidate.agent_id && option.model_id == candidate.model_id
331    })
332}
333
334#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
335#[serde(rename_all = "camelCase")]
336pub struct CallbackObservationFacts {
337    pub completed_states: Vec<String>,
338    pub state_visits: BTreeMap<String, u64>,
339    pub node_observations: Vec<NodeObservationSummary>,
340    pub failure_diagnostic: Option<String>,
341    pub observation_seam_active: bool,
342}
343
344#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
345#[serde(rename_all = "camelCase")]
346pub struct CallbackContextFacts {
347    pub conversation_id: Option<String>,
348    pub assistant_membership_id: Option<String>,
349    pub state_id: String,
350    pub state_visit: u64,
351    pub transition_id: String,
352    pub causation_id: Option<String>,
353    pub answer_channel: String,
354    pub context_composition_seam_active: bool,
355}
356
357#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
358#[serde(rename_all = "camelCase")]
359pub struct CallbackFacts {
360    pub observation: CallbackObservationFacts,
361    /// `None` when the run's spend could not be read: unknown cost is reported
362    /// as unknown, never as zero.
363    pub cost: Option<CallbackCostFacts>,
364    pub context: CallbackContextFacts,
365}
366
367#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
368#[serde(rename_all = "camelCase")]
369pub struct CallbackSuggestions {
370    pub recommended_decision: String,
371    pub decision_reasons: Vec<String>,
372    pub strategy_suggestion: Option<StrategySuggestion>,
373    pub alternative_decisions: Vec<String>,
374}
375
376#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
377#[serde(rename_all = "camelCase")]
378pub struct CallbackEvolutionPayload {
379    pub facts: CallbackFacts,
380    pub suggestions: CallbackSuggestions,
381}
382
383pub struct CallbackEvolutionEnricher {
384    portable_root: PathBuf,
385    observation_port: Arc<dyn EvolutionObservationPort>,
386    cost_port: Arc<dyn EvolutionCostPort>,
387    context_port: Arc<dyn EvolutionContextPort>,
388    strategy_port: Arc<dyn EvolutionStrategyPort>,
389    strategy_candidates: Vec<AgentModelOptionSeam>,
390}
391
392impl CallbackEvolutionEnricher {
393    pub fn new(portable_root: &Path) -> Self {
394        Self {
395            portable_root: portable_root.to_path_buf(),
396            observation_port: Arc::new(DefaultEvolutionObservationPort::new()),
397            cost_port: Arc::new(LedgerEvolutionCostPort::new()),
398            context_port: Arc::new(DefaultEvolutionContextPort::new()),
399            strategy_port: Arc::new(DefaultEvolutionStrategyPort::new()),
400            strategy_candidates: Vec::new(),
401        }
402    }
403
404    /// Supply the model options this host can actually run for a scope.
405    ///
406    /// Only options present here can be suggested; an empty catalog (the state
407    /// until the T06 durable-defaults seam is wired) yields no strategy
408    /// suggestion at all, never a model the host has not been told about.
409    pub fn with_strategy_candidates(mut self, candidates: Vec<AgentModelOptionSeam>) -> Self {
410        self.strategy_candidates = candidates;
411        self
412    }
413
414    pub fn with_observation_port(mut self, port: Arc<dyn EvolutionObservationPort>) -> Self {
415        self.observation_port = port;
416        self
417    }
418
419    pub fn with_cost_port(mut self, port: Arc<dyn EvolutionCostPort>) -> Self {
420        self.cost_port = port;
421        self
422    }
423
424    pub fn with_context_port(mut self, port: Arc<dyn EvolutionContextPort>) -> Self {
425        self.context_port = port;
426        self
427    }
428
429    pub fn with_strategy_port(mut self, port: Arc<dyn EvolutionStrategyPort>) -> Self {
430        self.strategy_port = port;
431        self
432    }
433
434    pub fn enrich_callback_request(
435        &self,
436        snapshot: &RunSnapshot,
437        pending: &PendingCallback,
438        answer_channel: &str,
439    ) -> CallbackEvolutionPayload {
440        let node_obs = self
441            .observation_port
442            .collect_node_observations(&snapshot.run_id);
443        let obs_facts = CallbackObservationFacts {
444            completed_states: snapshot.completed_states.iter().cloned().collect(),
445            state_visits: snapshot.state_visits.clone(),
446            node_observations: node_obs,
447            failure_diagnostic: snapshot.diagnostic_code.clone(),
448            observation_seam_active: true,
449        };
450
451        let cost_facts = self
452            .cost_port
453            .query_cost_facts(&self.portable_root, &snapshot.run_id);
454        let ctx_facts = self.context_port.assemble_context_facts(
455            snapshot,
456            &pending.state_id,
457            pending.state_visit,
458            &pending.transition_id,
459            answer_channel,
460        );
461
462        let mut decision_reasons = Vec::new();
463        let recommended_decision = if snapshot.status == StrategyRunStatus::Failed {
464            decision_reasons
465                .push("Run has encountered a terminal failure; termination recommended".into());
466            "terminate".to_owned()
467        } else {
468            decision_reasons.push(format!(
469                "Callback parked at state '{}' (visit {}); previous states completed successfully",
470                pending.state_id, pending.state_visit
471            ));
472            match &cost_facts {
473                Some(cost) if cost.total_tokens > 0 => decision_reasons.push(format!(
474                    "Current run usage is {} tokens (exact: {}, estimated: {})",
475                    cost.total_tokens, cost.exact_count, cost.estimated_count
476                )),
477                Some(_) => {}
478                None => decision_reasons
479                    .push("Run usage could not be read from the ledger; spend is unknown".into()),
480            }
481            "advance".to_owned()
482        };
483
484        // Ask the strategy port to rank the options this host actually offers.
485        // The port only advises: the suggestion it returns never carries
486        // execution permission and never leaves the candidate catalog.
487        let scope = PlanningScopeSeam {
488            task_kind: "workflow-turn".to_owned(),
489            configuration: pending.state_id.clone(),
490        };
491        let strategy_suggestion =
492            self.strategy_port
493                .suggest_strategy(&scope, &self.strategy_candidates, None);
494
495        let alternative_decisions = match recommended_decision.as_str() {
496            "advance" => vec!["return".to_owned(), "terminate".to_owned()],
497            "return" => vec!["advance".to_owned(), "terminate".to_owned()],
498            _ => vec!["advance".to_owned(), "return".to_owned()],
499        };
500
501        CallbackEvolutionPayload {
502            facts: CallbackFacts {
503                observation: obs_facts,
504                cost: cost_facts,
505                context: ctx_facts,
506            },
507            suggestions: CallbackSuggestions {
508                recommended_decision,
509                decision_reasons,
510                strategy_suggestion,
511                alternative_decisions,
512            },
513        }
514    }
515
516    pub fn enrich_terminal_outcome(&self, snapshot: &RunSnapshot) -> CallbackEvolutionPayload {
517        let node_obs = self
518            .observation_port
519            .collect_node_observations(&snapshot.run_id);
520        let obs_facts = CallbackObservationFacts {
521            completed_states: snapshot.completed_states.iter().cloned().collect(),
522            state_visits: snapshot.state_visits.clone(),
523            node_observations: node_obs,
524            failure_diagnostic: snapshot.diagnostic_code.clone(),
525            observation_seam_active: true,
526        };
527
528        let cost_facts = self
529            .cost_port
530            .query_cost_facts(&self.portable_root, &snapshot.run_id);
531        let ctx_facts = self
532            .context_port
533            .assemble_context_facts(snapshot, "", 0, "", "terminal");
534
535        CallbackEvolutionPayload {
536            facts: CallbackFacts {
537                observation: obs_facts,
538                cost: cost_facts,
539                context: ctx_facts,
540            },
541            suggestions: CallbackSuggestions {
542                recommended_decision: "terminate".to_owned(),
543                decision_reasons: vec![format!(
544                    "Terminal run failure with diagnostic: {}",
545                    snapshot.diagnostic_code.as_deref().unwrap_or("unknown")
546                )],
547                strategy_suggestion: None,
548                alternative_decisions: Vec::new(),
549            },
550        }
551    }
552
553    pub fn enrich_flow_settled(
554        &self,
555        snapshot: &RunSnapshot,
556        state_id: &str,
557        state_visit: u64,
558    ) -> CallbackEvolutionPayload {
559        let node_obs = self
560            .observation_port
561            .collect_node_observations(&snapshot.run_id);
562        let obs_facts = CallbackObservationFacts {
563            completed_states: snapshot.completed_states.iter().cloned().collect(),
564            state_visits: snapshot.state_visits.clone(),
565            node_observations: node_obs,
566            failure_diagnostic: None,
567            observation_seam_active: true,
568        };
569
570        let cost_facts = self
571            .cost_port
572            .query_cost_facts(&self.portable_root, &snapshot.run_id);
573        let ctx_facts = self.context_port.assemble_context_facts(
574            snapshot,
575            state_id,
576            state_visit,
577            "flow",
578            "flow",
579        );
580
581        CallbackEvolutionPayload {
582            facts: CallbackFacts {
583                observation: obs_facts,
584                cost: cost_facts,
585                context: ctx_facts,
586            },
587            suggestions: CallbackSuggestions {
588                recommended_decision: "advance".to_owned(),
589                decision_reasons: vec![format!(
590                    "State '{}' (visit {}) completed successfully in flow mode",
591                    state_id, state_visit
592                )],
593                strategy_suggestion: None,
594                alternative_decisions: Vec::new(),
595            },
596        }
597    }
598}
599
600// ============================================================================
601// Pre-Effect Recheck: Permission, Version, Resource
602// ============================================================================
603
604/// Context evaluated immediately prior to executing any effect.
605pub struct EffectRecheckContext<'a> {
606    pub run_id: &'a str,
607    pub command_id: &'a str,
608    pub definition_revision: &'a str,
609    pub expected_revision: &'a str,
610    pub expected_generation: u64,
611    pub current_generation: u64,
612    pub authorization: Option<&'a StrategyAuthorization>,
613    pub claimant: &'a str,
614    /// Native session this effect will write through, when the executor holds a
615    /// single-writer registration. `None` means the executor has no session lock
616    /// to recheck, not that the lock check passed.
617    pub session_id: Option<&'a str>,
618    pub single_writer_registry: Option<&'a SingleWriterSessionRegistry>,
619    /// Tokens this effect is about to spend against a configured pool, and what
620    /// is left of it. `None` means no pool fact is available to compare.
621    pub required_tokens: Option<u64>,
622    pub token_budget_remaining: Option<u64>,
623}
624
625impl<'a> EffectRecheckContext<'a> {
626    /// Build the pre-effect recheck context for one command the executor claimed.
627    ///
628    /// The generation check compares two independently sourced facts: the visit
629    /// the command was minted for (`RunCommand::state_visit`) against the visit
630    /// the run is at now (`RunSnapshot::state_visits`). A command whose state was
631    /// re-entered, or that was claimed for a state the run has since advanced
632    /// past, must not reach the executor with stale semantics.
633    ///
634    /// The revision check asserts the run is still bound to the definition it
635    /// was loaded from. That binding is structural here (the definition is read
636    /// by `snapshot.definition_digest`), so the check is an invariant assertion
637    /// rather than a second read of mutable state.
638    ///
639    /// Session-lock and token-budget facts stay `None` until the T07.4 node
640    /// facade and the T04 pool ports are reachable from the executor; see
641    /// [`GroupBIntegrationGaps`]. Callers holding those facts set the fields
642    /// directly.
643    pub fn for_run_command(
644        run_id: &'a str,
645        snapshot: &'a RunSnapshot,
646        definition: &'a StrategyDefinition,
647        command: &'a RunCommand,
648        claimant: &'a str,
649    ) -> Self {
650        Self {
651            run_id,
652            command_id: &command.id,
653            definition_revision: &snapshot.definition_digest,
654            expected_revision: &definition.summary.revision_digest,
655            expected_generation: command.state_visit,
656            current_generation: snapshot
657                .state_visits
658                .get(&command.state_id)
659                .copied()
660                .unwrap_or(0),
661            authorization: definition.authorization.as_ref(),
662            claimant,
663            session_id: None,
664            single_writer_registry: None,
665            required_tokens: None,
666            token_budget_remaining: None,
667        }
668    }
669}
670
671/// Recheck permission, version, and resources before executing any effect.
672///
673/// Returns [`Ok(EffectRecheckReceipt)`] if all checks pass, or [`Err(EffectRecheckFailure)`]
674/// stopping execution before any real effect occurs.
675pub fn recheck_before_effect(
676    ctx: &EffectRecheckContext<'_>,
677) -> std::result::Result<EffectRecheckReceipt, EffectRecheckFailure> {
678    // 1. Permission Recheck: authorization must be present and active
679    let Some(auth) = ctx.authorization else {
680        return Err(EffectRecheckFailure::PermissionDenied {
681            code: "authorization_missing".into(),
682            reason: "No authorization configured on definition".into(),
683        });
684    };
685    if !auth.active {
686        return Err(EffectRecheckFailure::PermissionDenied {
687            code: "authorization_inactive".into(),
688            reason: "Strategy authorization is marked inactive or has been revoked".into(),
689        });
690    }
691
692    // 2. Version Recheck: expected definition revision and node generation must match
693    if ctx.definition_revision != ctx.expected_revision {
694        return Err(EffectRecheckFailure::VersionMismatch {
695            expected: ctx.expected_revision.to_owned(),
696            actual: ctx.definition_revision.to_owned(),
697        });
698    }
699    if ctx.expected_generation != ctx.current_generation {
700        return Err(EffectRecheckFailure::StaleGeneration {
701            expected: ctx.expected_generation,
702            current: ctx.current_generation,
703        });
704    }
705
706    // 3. Resource Recheck: check single-writer lock and token budget limits
707    if let (Some(registry), Some(session)) = (ctx.single_writer_registry, ctx.session_id)
708        && let Some(active_writer) = registry.current_writer(session)
709        && active_writer != ctx.claimant
710    {
711        return Err(EffectRecheckFailure::ResourceUnavailable {
712            code: "session_writer_conflict".into(),
713            reason: format!(
714                "Native session '{}' is currently locked by claimant '{}'",
715                session, active_writer
716            ),
717        });
718    }
719
720    if let (Some(req), Some(remaining)) = (ctx.required_tokens, ctx.token_budget_remaining)
721        && req > remaining
722    {
723        return Err(EffectRecheckFailure::ResourceUnavailable {
724            code: "token_budget_exhausted".into(),
725            reason: format!("Required tokens ({req}) exceed remaining budget ({remaining})"),
726        });
727    }
728
729    let now_ms = std::time::SystemTime::now()
730        .duration_since(std::time::UNIX_EPOCH)
731        .map(|d| d.as_millis() as i64)
732        .unwrap_or(0);
733
734    Ok(EffectRecheckReceipt {
735        run_id: ctx.run_id.to_owned(),
736        command_id: ctx.command_id.to_owned(),
737        rechecked_at_unix_ms: now_ms,
738        revision_digest: ctx.definition_revision.to_owned(),
739        generation: ctx.current_generation,
740        permission_verified: true,
741        version_verified: true,
742        resource_verified: true,
743    })
744}
745
746// ============================================================================
747// Tests
748// ============================================================================
749
750#[cfg(test)]
751mod tests {
752    use super::*;
753    use licoup_model_catalog::candidate_routing::{
754        CandidateFactTable, CandidateFacts, CandidateRoutingOutcome, CandidateRoutingRequest,
755        RoutingWithFacts,
756    };
757    use licoup_model_catalog::port::CredentialState;
758    use licoup_model_catalog::selection_matrix::{
759        ScopeAdmissionFacts, ScopeOutcomeState, SelectionMatrixPort, SelectionScope,
760    };
761
762    /// A routing owner that admits exactly the alternatives the test states
763    /// facts for, under the workflow scope.
764    fn routing_owner(rows: &[&AgentModelOptionSeam]) -> Arc<dyn CandidateRoutingPort> {
765        fn workflow_only(
766            _agent: &str,
767            scope: SelectionScope,
768            _params: &Value,
769        ) -> ScopeAdmissionFacts {
770            match scope {
771                SelectionScope::Workflow => {
772                    ScopeAdmissionFacts::new(ScopeOutcomeState::Allowed, "workflow_scope_admitted")
773                }
774                SelectionScope::Direct => {
775                    ScopeAdmissionFacts::new(ScopeOutcomeState::Blocked, "direct_scope_blocked")
776                }
777            }
778        }
779        let mut facts = CandidateFactTable::new();
780        for row in rows {
781            facts.record(
782                CandidateId::new(row.agent_id.clone(), row.model_id.clone(), None),
783                CandidateFacts {
784                    observed_at_unix_ms: Some(1_726_000_000_000),
785                    credential: CredentialState::Present,
786                    quota: licoup_model_catalog::candidate_policy::QuotaState::Available {
787                        window: "primary".to_owned(),
788                    },
789                    requirements: std::collections::BTreeMap::new(),
790                },
791            );
792        }
793        Arc::new(RoutingWithFacts::new(
794            Arc::new(facts),
795            SelectionMatrixPort {
796                agent_scope_admission: workflow_only,
797            },
798        ))
799    }
800
801    #[test]
802    fn test_group_b_gap_report_identifies_all_four_subsystems() {
803        let report = GroupBIntegrationGaps::report();
804        assert!(report.t03_observation_gap.contains("fix/t03-1-observation"));
805        assert!(
806            report
807                .t04_cost_budget_pool_gap
808                .contains("fix/t04-1-usage-admission")
809        );
810        assert!(
811            report
812                .t05_context_composition_gap
813                .contains("fix/t05-1-context")
814        );
815        assert!(
816            report
817                .t06_replaceable_strategy_gap
818                .contains("domain::candidate_routing")
819        );
820    }
821
822    #[test]
823    fn test_strategy_suggestion_has_no_execution_permission() {
824        let scope = PlanningScopeSeam {
825            task_kind: "coding".into(),
826            configuration: "default".into(),
827        };
828        let candidates = vec![
829            AgentModelOptionSeam {
830                agent_id: "agent-1".into(),
831                model_id: "model-a".into(),
832                thinking: "low".into(),
833            },
834            AgentModelOptionSeam {
835                agent_id: "agent-2".into(),
836                model_id: "model-b".into(),
837                thinking: "high".into(),
838            },
839        ];
840        // The fallback is the routing policy's answer, so this port is given
841        // the owner the composed host installs instead of one with no facts.
842        let port = DefaultEvolutionStrategyPort::new()
843            .with_routing(routing_owner(&candidates.iter().collect::<Vec<_>>()));
844
845        let suggestion = port.suggest_strategy(&scope, &candidates, None).unwrap();
846        assert_eq!(suggestion.candidate.agent_id, "agent-1");
847        // CRITICAL INVARIANT: Strategy selection NEVER grants execution permission!
848        assert!(!suggestion.has_execution_permission);
849    }
850
851    #[test]
852    fn test_strategy_user_explicit_choice_precedence_d09() {
853        let port = DefaultEvolutionStrategyPort::new();
854        let scope = PlanningScopeSeam {
855            task_kind: "coding".into(),
856            configuration: "default".into(),
857        };
858        let candidates = vec![
859            AgentModelOptionSeam {
860                agent_id: "agent-1".into(),
861                model_id: "model-a".into(),
862                thinking: "low".into(),
863            },
864            AgentModelOptionSeam {
865                agent_id: "agent-2".into(),
866                model_id: "model-b".into(),
867                thinking: "high".into(),
868            },
869        ];
870
871        // Adopt a default recommending agent-1
872        port.adopt_default(AdoptedPlanningDefaultSeam {
873            scope: scope.clone(),
874            source: StrategySourceSeam {
875                source_id: "test-src".into(),
876                revision: "r1".into(),
877            },
878            selected_option: candidates[0].clone(),
879            revocable: true,
880            supersedes_revision: None,
881        });
882
883        // User explicitly requests agent-2
884        let user_choice = candidates[1].clone();
885        let suggestion = port
886            .suggest_strategy(&scope, &candidates, Some(&user_choice))
887            .unwrap();
888        assert_eq!(suggestion.candidate.agent_id, "agent-2");
889        assert_eq!(suggestion.ranking_basis, "user-configured");
890        assert!(!suggestion.is_default);
891        assert!(!suggestion.has_execution_permission);
892    }
893
894    #[test]
895    fn test_strategy_adopted_default_selection_and_revocation_d21() {
896        let scope = PlanningScopeSeam {
897            task_kind: "review".into(),
898            configuration: "default".into(),
899        };
900        let candidates = vec![
901            AgentModelOptionSeam {
902                agent_id: "reviewer-1".into(),
903                model_id: "fast".into(),
904                thinking: "low".into(),
905            },
906            AgentModelOptionSeam {
907                agent_id: "reviewer-2".into(),
908                model_id: "deep".into(),
909                thinking: "high".into(),
910            },
911        ];
912        // After revocation the routing policy decides, so this port is given
913        // the owner the composed host installs instead of one with no facts.
914        let port = DefaultEvolutionStrategyPort::new()
915            .with_routing(routing_owner(&candidates.iter().collect::<Vec<_>>()));
916
917        // Adopt reviewer-2
918        port.adopt_default(AdoptedPlanningDefaultSeam {
919            scope: scope.clone(),
920            source: StrategySourceSeam {
921                source_id: "eval-42".into(),
922                revision: "rev-2".into(),
923            },
924            selected_option: candidates[1].clone(),
925            revocable: true,
926            supersedes_revision: None,
927        });
928
929        // Default takes effect when no explicit user choice
930        let suggestion = port.suggest_strategy(&scope, &candidates, None).unwrap();
931        assert_eq!(suggestion.candidate.agent_id, "reviewer-2");
932        assert!(suggestion.is_default);
933        assert_eq!(
934            suggestion.ranking_basis,
935            "adopted-default-comparable-outcome"
936        );
937        assert!(!suggestion.has_execution_permission);
938
939        // Revoking the strategy drops back to the routing policy, which selects
940        // the configured alternative it admits.
941        port.revoke_strategy("eval-42");
942        let after_revoke = port.suggest_strategy(&scope, &candidates, None).unwrap();
943        assert_eq!(after_revoke.candidate.agent_id, "reviewer-1");
944        assert!(!after_revoke.is_default);
945        assert_eq!(
946            after_revoke.ranking_basis, "candidate-policy",
947            "the basis the routing port reports for a policy-selected alternative"
948        );
949    }
950
951    #[test]
952    fn test_pre_effect_recheck_success() {
953        let auth = StrategyAuthorization {
954            definition_digest: "def-1".into(),
955            semantics_digest: "sem-1".into(),
956            binding_digest: "bind-1".into(),
957            authorization_digest: "auth-1".into(),
958            revision: 1,
959            active: true,
960        };
961        let ctx = EffectRecheckContext {
962            run_id: "run-1",
963            command_id: "cmd-1",
964            definition_revision: "rev-1",
965            expected_revision: "rev-1",
966            expected_generation: 2,
967            current_generation: 2,
968            authorization: Some(&auth),
969            claimant: "worker-1",
970            session_id: None,
971            single_writer_registry: None,
972            required_tokens: Some(500),
973            token_budget_remaining: Some(1000),
974        };
975
976        let receipt = recheck_before_effect(&ctx).unwrap();
977        assert_eq!(receipt.run_id, "run-1");
978        assert_eq!(receipt.command_id, "cmd-1");
979        assert!(receipt.permission_verified);
980        assert!(receipt.version_verified);
981        assert!(receipt.resource_verified);
982    }
983
984    #[test]
985    fn test_pre_effect_recheck_fails_on_inactive_authorization() {
986        let auth = StrategyAuthorization {
987            definition_digest: "def-1".into(),
988            semantics_digest: "sem-1".into(),
989            binding_digest: "bind-1".into(),
990            authorization_digest: "auth-1".into(),
991            revision: 1,
992            active: false, // Inactive / revoked!
993        };
994        let ctx = EffectRecheckContext {
995            run_id: "run-1",
996            command_id: "cmd-1",
997            definition_revision: "rev-1",
998            expected_revision: "rev-1",
999            expected_generation: 1,
1000            current_generation: 1,
1001            authorization: Some(&auth),
1002            claimant: "worker-1",
1003            session_id: None,
1004            single_writer_registry: None,
1005            required_tokens: None,
1006            token_budget_remaining: None,
1007        };
1008
1009        let err = recheck_before_effect(&ctx).unwrap_err();
1010        assert!(matches!(err, EffectRecheckFailure::PermissionDenied { .. }));
1011        assert_eq!(err.code(), "authorization_required");
1012    }
1013
1014    #[test]
1015    fn test_pre_effect_recheck_fails_on_version_mismatch() {
1016        let auth = StrategyAuthorization {
1017            definition_digest: "def-1".into(),
1018            semantics_digest: "sem-1".into(),
1019            binding_digest: "bind-1".into(),
1020            authorization_digest: "auth-1".into(),
1021            revision: 1,
1022            active: true,
1023        };
1024        let ctx = EffectRecheckContext {
1025            run_id: "run-1",
1026            command_id: "cmd-1",
1027            definition_revision: "rev-1",
1028            expected_revision: "rev-2", // Mismatch!
1029            expected_generation: 1,
1030            current_generation: 1,
1031            authorization: Some(&auth),
1032            claimant: "worker-1",
1033            session_id: None,
1034            single_writer_registry: None,
1035            required_tokens: None,
1036            token_budget_remaining: None,
1037        };
1038
1039        let err = recheck_before_effect(&ctx).unwrap_err();
1040        assert!(matches!(err, EffectRecheckFailure::VersionMismatch { .. }));
1041        assert_eq!(err.code(), "strategy_recheck_version_mismatch");
1042    }
1043
1044    #[test]
1045    fn test_pre_effect_recheck_fails_on_stale_generation() {
1046        let auth = StrategyAuthorization {
1047            definition_digest: "def-1".into(),
1048            semantics_digest: "sem-1".into(),
1049            binding_digest: "bind-1".into(),
1050            authorization_digest: "auth-1".into(),
1051            revision: 1,
1052            active: true,
1053        };
1054        let ctx = EffectRecheckContext {
1055            run_id: "run-1",
1056            command_id: "cmd-1",
1057            definition_revision: "rev-1",
1058            expected_revision: "rev-1",
1059            expected_generation: 1,
1060            current_generation: 2, // Node generation changed!
1061            authorization: Some(&auth),
1062            claimant: "worker-1",
1063            session_id: None,
1064            single_writer_registry: None,
1065            required_tokens: None,
1066            token_budget_remaining: None,
1067        };
1068
1069        let err = recheck_before_effect(&ctx).unwrap_err();
1070        assert!(matches!(err, EffectRecheckFailure::StaleGeneration { .. }));
1071        assert_eq!(err.code(), "strategy_recheck_stale_generation");
1072    }
1073
1074    #[test]
1075    fn test_pre_effect_recheck_fails_on_budget_exhaustion() {
1076        let auth = StrategyAuthorization {
1077            definition_digest: "def-1".into(),
1078            semantics_digest: "sem-1".into(),
1079            binding_digest: "bind-1".into(),
1080            authorization_digest: "auth-1".into(),
1081            revision: 1,
1082            active: true,
1083        };
1084        let ctx = EffectRecheckContext {
1085            run_id: "run-1",
1086            command_id: "cmd-1",
1087            definition_revision: "rev-1",
1088            expected_revision: "rev-1",
1089            expected_generation: 1,
1090            current_generation: 1,
1091            authorization: Some(&auth),
1092            claimant: "worker-1",
1093            session_id: None,
1094            single_writer_registry: None,
1095            required_tokens: Some(5000),
1096            token_budget_remaining: Some(2000), // Exceeded!
1097        };
1098
1099        let err = recheck_before_effect(&ctx).unwrap_err();
1100        assert!(matches!(
1101            err,
1102            EffectRecheckFailure::ResourceUnavailable { .. }
1103        ));
1104        assert_eq!(err.code(), "strategy_recheck_token_budget_exhausted");
1105    }
1106
1107    #[test]
1108    fn test_callback_evolution_enricher_populates_facts_and_suggestions() {
1109        let temp_dir =
1110            std::env::temp_dir().join(format!("licoup-evolution-test-{}", uuid::Uuid::new_v4()));
1111        let enricher = CallbackEvolutionEnricher::new(&temp_dir);
1112
1113        let mut snapshot = RunSnapshot::empty("test-run-1", "def-1", "sem-1");
1114        snapshot.status = StrategyRunStatus::Waiting;
1115        snapshot.conversation_id = Some("conv-1".into());
1116        snapshot.assistant_membership_id = Some("asst-1".into());
1117        snapshot.completed_states.insert("init".into());
1118        snapshot.state_visits.insert("init".into(), 1);
1119        snapshot.state_visits.insert("step1".into(), 1);
1120
1121        let pending = PendingCallback {
1122            state_id: "step1".into(),
1123            state_visit: 1,
1124            transition_id: "trans-1".into(),
1125            event: licoup_workflow::TransitionEvent::Complete,
1126            target: "step2".into(),
1127        };
1128
1129        let payload = enricher.enrich_callback_request(
1130            &snapshot,
1131            &pending,
1132            "lico_assistant_workflow_execute",
1133        );
1134
1135        // Facts assertions
1136        assert_eq!(payload.facts.observation.completed_states, vec!["init"]);
1137        assert_eq!(payload.facts.context.conversation_id, Some("conv-1".into()));
1138        assert_eq!(payload.facts.context.state_id, "step1");
1139        assert_eq!(
1140            payload.facts.context.answer_channel,
1141            "lico_assistant_workflow_execute"
1142        );
1143
1144        // Suggestions assertions
1145        assert_eq!(payload.suggestions.recommended_decision, "advance");
1146        assert!(!payload.suggestions.decision_reasons.is_empty());
1147        assert!(
1148            payload
1149                .suggestions
1150                .alternative_decisions
1151                .contains(&"terminate".to_owned())
1152        );
1153        // No candidate catalog is wired in this slice, so the payload carries no
1154        // strategy advice rather than a model option the host never offered.
1155        assert!(payload.suggestions.strategy_suggestion.is_none());
1156    }
1157
1158    #[test]
1159    fn callback_payload_omits_strategy_advice_when_the_candidate_catalog_is_empty() {
1160        let enricher = CallbackEvolutionEnricher::new(&temp_root("empty-catalog"));
1161        let snapshot = waiting_snapshot();
1162        let pending = callback_pending();
1163
1164        let payload = enricher.enrich_callback_request(&snapshot, &pending, "strategy.run.resume");
1165
1166        assert!(payload.suggestions.strategy_suggestion.is_none());
1167        assert!(payload.facts.observation.observation_seam_active);
1168        // The usage ledger is a host port. An uninstalled host is an unreadable
1169        // ledger, so spend stays unknown rather than a fabricated zero.
1170        assert!(payload.facts.cost.is_none());
1171    }
1172
1173    #[test]
1174    fn callback_payload_reports_unknown_spend_as_unknown_not_zero() {
1175        struct UnreadableCostPort;
1176        impl EvolutionCostPort for UnreadableCostPort {
1177            fn query_cost_facts(&self, _root: &Path, _run_id: &str) -> Option<CallbackCostFacts> {
1178                None
1179            }
1180        }
1181
1182        let enricher = CallbackEvolutionEnricher::new(&temp_root("unreadable-ledger"))
1183            .with_cost_port(Arc::new(UnreadableCostPort));
1184
1185        let payload = enricher.enrich_callback_request(
1186            &waiting_snapshot(),
1187            &callback_pending(),
1188            "strategy.run.resume",
1189        );
1190
1191        assert!(payload.facts.cost.is_none());
1192        assert!(
1193            payload
1194                .suggestions
1195                .decision_reasons
1196                .iter()
1197                .any(|reason| reason.contains("spend is unknown")),
1198            "{:?}",
1199            payload.suggestions.decision_reasons
1200        );
1201    }
1202
1203    #[test]
1204    fn callback_payload_carries_the_adopted_default_until_it_is_revoked() {
1205        let candidates = vec![
1206            option("reviewer-1", "fast", "low"),
1207            option("reviewer-2", "deep", "high"),
1208        ];
1209        let strategy = Arc::new(
1210            DefaultEvolutionStrategyPort::new()
1211                .with_routing(routing_owner(&candidates.iter().collect::<Vec<_>>())),
1212        );
1213        strategy.adopt_default(AdoptedPlanningDefaultSeam {
1214            // The enricher scopes suggestions by the parked state.
1215            scope: PlanningScopeSeam {
1216                task_kind: "workflow-turn".into(),
1217                configuration: "step1".into(),
1218            },
1219            source: StrategySourceSeam {
1220                source_id: "eval-7".into(),
1221                revision: "rev-3".into(),
1222            },
1223            selected_option: candidates[1].clone(),
1224            revocable: true,
1225            supersedes_revision: None,
1226        });
1227        let enricher = CallbackEvolutionEnricher::new(&temp_root("adopted-default"))
1228            .with_strategy_port(strategy.clone())
1229            .with_strategy_candidates(candidates);
1230
1231        let suggested = enricher
1232            .enrich_callback_request(
1233                &waiting_snapshot(),
1234                &callback_pending(),
1235                "strategy.run.resume",
1236            )
1237            .suggestions
1238            .strategy_suggestion
1239            .expect("the adopted default reaches the callback payload");
1240        assert_eq!(suggested.candidate.model_id, "deep");
1241        assert!(suggested.is_default);
1242        assert!(!suggested.has_execution_permission);
1243
1244        // Revocation restores the previous policy: the configured order decides
1245        // again, and the routing policy — not insertion order — names the
1246        // alternative that runs.
1247        strategy.revoke_strategy("eval-7");
1248        let revoked = enricher
1249            .enrich_callback_request(
1250                &waiting_snapshot(),
1251                &callback_pending(),
1252                "strategy.run.resume",
1253            )
1254            .suggestions
1255            .strategy_suggestion
1256            .expect("the routing policy selects after revocation");
1257        assert_eq!(revoked.candidate.model_id, "fast");
1258        assert_eq!(revoked.ranking_basis, "candidate-policy");
1259        assert!(!revoked.is_default);
1260        assert!(!revoked.has_execution_permission);
1261    }
1262
1263    /// With no routing owner composed there is no selection to make: the
1264    /// payload carries no strategy advice instead of the first entry of the
1265    /// caller's list.
1266    #[test]
1267    fn a_callback_without_a_routing_owner_carries_no_strategy_advice() {
1268        let enricher = CallbackEvolutionEnricher::new(&temp_root("no-routing-owner"))
1269            .with_strategy_port(Arc::new(DefaultEvolutionStrategyPort::new()))
1270            .with_strategy_candidates(vec![
1271                option("reviewer-1", "fast", "low"),
1272                option("reviewer-2", "deep", "high"),
1273            ]);
1274
1275        let payload = enricher.enrich_callback_request(
1276            &waiting_snapshot(),
1277            &callback_pending(),
1278            "strategy.run.resume",
1279        );
1280        assert!(
1281            payload.suggestions.strategy_suggestion.is_none(),
1282            "an unanswered policy must not fall back to the first candidate"
1283        );
1284    }
1285
1286    /// The decision keeps the scope it was asked for, so the same alternatives
1287    /// answer differently for a direct request than for a workflow turn.
1288    #[test]
1289    fn routing_keeps_direct_and_workflow_outcomes_apart() {
1290        let candidates = vec![option("reviewer-1", "fast", "low")];
1291        let owner = routing_owner(&candidates.iter().collect::<Vec<_>>());
1292        let workflow = owner.route(&candidate_routing_request(&candidates));
1293        assert_eq!(
1294            workflow
1295                .selected()
1296                .map(|selected| selected.model_id.as_str()),
1297            Some("fast")
1298        );
1299        let direct = owner.route(&CandidateRoutingRequest::new(
1300            SelectionScope::Direct,
1301            vec![CandidateOffer {
1302                candidate: CandidateId::new("reviewer-1", "fast", None),
1303                configured_position: 1,
1304            }],
1305        ));
1306        assert_eq!(direct.selected(), None);
1307        assert!(matches!(
1308            direct.unavailable(),
1309            Some(licoup_model_catalog::candidate_policy::CandidateUnavailable::NoAllowedCandidate)
1310        ));
1311        let _: CandidateRoutingOutcome = direct;
1312    }
1313
1314    #[test]
1315    fn effect_recheck_compares_the_claimed_visit_against_the_run_visit() {
1316        let definition = test_definition(Some(active_authorization()));
1317        let mut snapshot = RunSnapshot::empty("run-1", "rev-1", "sem-1");
1318        snapshot.state_visits.insert("step".into(), 2);
1319
1320        let receipt = recheck_before_effect(&EffectRecheckContext::for_run_command(
1321            "run-1",
1322            &snapshot,
1323            &definition,
1324            &test_command("step", 2),
1325            "worker-1",
1326        ))
1327        .expect("the command minted for the current visit executes");
1328        assert_eq!(receipt.generation, 2);
1329        assert_eq!(receipt.revision_digest, "rev-1");
1330
1331        let failure = recheck_before_effect(&EffectRecheckContext::for_run_command(
1332            "run-1",
1333            &snapshot,
1334            &definition,
1335            &test_command("step", 1),
1336            "worker-1",
1337        ))
1338        .expect_err("a command left over from a superseded visit must not execute");
1339        assert!(matches!(
1340            failure,
1341            EffectRecheckFailure::StaleGeneration { .. }
1342        ));
1343        assert_eq!(failure.code(), "strategy_recheck_stale_generation");
1344
1345        let never_entered = recheck_before_effect(&EffectRecheckContext::for_run_command(
1346            "run-1",
1347            &snapshot,
1348            &definition,
1349            &test_command("elsewhere", 1),
1350            "worker-1",
1351        ))
1352        .expect_err("a command for a state the run never entered must not execute");
1353        assert_eq!(never_entered.code(), "strategy_recheck_stale_generation");
1354    }
1355
1356    #[test]
1357    fn effect_recheck_is_the_admission_gate_for_missing_and_revoked_authorization() {
1358        let mut revoked = active_authorization();
1359        revoked.active = false;
1360        let mut snapshot = RunSnapshot::empty("run-1", "rev-1", "sem-1");
1361        snapshot.state_visits.insert("step".into(), 1);
1362        let command = test_command("step", 1);
1363
1364        for definition in [test_definition(Some(revoked)), test_definition(None)] {
1365            let failure = recheck_before_effect(&EffectRecheckContext::for_run_command(
1366                "run-1",
1367                &snapshot,
1368                &definition,
1369                &command,
1370                "worker-1",
1371            ))
1372            .expect_err("an unusable authorization stops the effect at the gate");
1373            assert!(matches!(
1374                failure,
1375                EffectRecheckFailure::PermissionDenied { .. }
1376            ));
1377            assert_eq!(failure.code(), "authorization_required");
1378        }
1379    }
1380
1381    fn temp_root(tag: &str) -> PathBuf {
1382        std::env::temp_dir().join(format!("licoup-evolution-{tag}-{}", uuid::Uuid::new_v4()))
1383    }
1384
1385    fn option(agent_id: &str, model_id: &str, thinking: &str) -> AgentModelOptionSeam {
1386        AgentModelOptionSeam {
1387            agent_id: agent_id.into(),
1388            model_id: model_id.into(),
1389            thinking: thinking.into(),
1390        }
1391    }
1392
1393    fn waiting_snapshot() -> RunSnapshot {
1394        let mut snapshot = RunSnapshot::empty("test-run-1", "rev-1", "sem-1");
1395        snapshot.status = StrategyRunStatus::Waiting;
1396        snapshot.conversation_id = Some("conv-1".into());
1397        snapshot.assistant_membership_id = Some("asst-1".into());
1398        snapshot.completed_states.insert("init".into());
1399        snapshot.state_visits.insert("init".into(), 1);
1400        snapshot.state_visits.insert("step1".into(), 1);
1401        snapshot
1402    }
1403
1404    fn callback_pending() -> PendingCallback {
1405        PendingCallback {
1406            state_id: "step1".into(),
1407            state_visit: 1,
1408            transition_id: "trans-1".into(),
1409            event: licoup_workflow::TransitionEvent::Complete,
1410            target: "step2".into(),
1411        }
1412    }
1413
1414    fn active_authorization() -> StrategyAuthorization {
1415        StrategyAuthorization {
1416            definition_digest: "rev-1".into(),
1417            semantics_digest: "sem-1".into(),
1418            binding_digest: "bind-1".into(),
1419            authorization_digest: "auth-1".into(),
1420            revision: 1,
1421            active: true,
1422        }
1423    }
1424
1425    fn test_command(state_id: &str, state_visit: u64) -> RunCommand {
1426        RunCommand {
1427            id: format!("command:{state_id}:{state_visit}"),
1428            state_id: state_id.to_owned(),
1429            state_visit,
1430            kind: licoup_workflow::CommandKind::Actor,
1431            status: licoup_workflow::CommandStatus::Claimed,
1432            attempt: 1,
1433            attempt_token: String::from("attempt:1"),
1434            binding_id: Some("entry".into()),
1435            runtime_id: None,
1436            entry: None,
1437            item_id: None,
1438            session_policy: licoup_workflow::SessionPolicy::default(),
1439            binding_ordinal: 0,
1440            resume_session_id: None,
1441            input_digest: "input-1".into(),
1442            input: json!({"message": "hi"}),
1443            output_digest: None,
1444            failure_class: None,
1445            failure_code: None,
1446        }
1447    }
1448
1449    fn test_definition(authorization: Option<StrategyAuthorization>) -> StrategyDefinition {
1450        use licoup_workflow::{
1451            GraphState, GraphStateKind, RetryPolicy, WorkflowDefinition, WorkflowLimits,
1452            WorkflowMetadata,
1453        };
1454        StrategyDefinition {
1455            summary: licoup_workflow_store::StrategyDefinitionSummary {
1456                definition_id: "evolution-test".into(),
1457                revision_digest: "rev-1".into(),
1458                semantics_digest: "sem-1".into(),
1459                name: "Evolution test".into(),
1460                version: "1".into(),
1461                imported_at_unix_ms: 0,
1462                authorized: authorization.is_some(),
1463            },
1464            workflow: WorkflowDefinition {
1465                schema: licoup_workflow::WORKFLOW_SCHEMA_VERSION.into(),
1466                metadata: WorkflowMetadata {
1467                    id: "evolution-test".into(),
1468                    name: "Evolution test".into(),
1469                    version: "1".into(),
1470                    description: String::new(),
1471                },
1472                limits: WorkflowLimits::default(),
1473                actor_slots: Vec::new(),
1474                runtimes: Vec::new(),
1475                worksets: Vec::new(),
1476                initial: "step".into(),
1477                states: vec![GraphState {
1478                    id: "step".into(),
1479                    kind: GraphStateKind::Succeed,
1480                    label: "Step".into(),
1481                    instruction: String::new(),
1482                    binding: None,
1483                    runtime: None,
1484                    entry: None,
1485                    workset: None,
1486                    retry: RetryPolicy::default(),
1487                }],
1488                transitions: Vec::new(),
1489            },
1490            asset_count: 0,
1491            bindings: Vec::new(),
1492            authorization,
1493        }
1494    }
1495}