1use 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
39pub 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
55pub type CallbackCostFacts = acativa::runtime::evolution::EvolutionCostFacts;
58
59#[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 pub fn with_routing(mut self, routing: Arc<dyn CandidateRoutingPort>) -> Self {
200 self.routing = routing;
201 self
202 }
203
204 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 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 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, 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 if !revocations_guard.contains(&adopted.source.source_id) {
251 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, rationale: format!(
261 "Adopted default from source '{}' rev '{}'",
262 adopted.source.source_id, adopted.source.revision
263 ),
264 });
265 }
266 }
267 }
268
269 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, rationale: routing_rationale(&outcome),
286 })
287 }
288}
289
290fn 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
319fn 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 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 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 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
600pub 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 pub session_id: Option<&'a str>,
618 pub single_writer_registry: Option<&'a SingleWriterSessionRegistry>,
619 pub required_tokens: Option<u64>,
622 pub token_budget_remaining: Option<u64>,
623}
624
625impl<'a> EffectRecheckContext<'a> {
626 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
671pub fn recheck_before_effect(
676 ctx: &EffectRecheckContext<'_>,
677) -> std::result::Result<EffectRecheckReceipt, EffectRecheckFailure> {
678 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 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 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#[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 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 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 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 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 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 let port = DefaultEvolutionStrategyPort::new()
915 .with_routing(routing_owner(&candidates.iter().collect::<Vec<_>>()));
916
917 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 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 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, };
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", 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, 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), };
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 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 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 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 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 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 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 #[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 #[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}