Skip to main content

licoup_workflow_runtime/
ports.rs

1//! The facts and effects the workflow runtime reads but does not own.
2//!
3//! Authority-backed Membership Profile snapshots are the first member:
4//! persistent Profile intent lives in the conversation store, while everything
5//! that changes over time (model price, intelligence score, Skill
6//! availability, runtime environment and readiness) is derived per request
7//! from its existing owner and cached only inside that request. The projection
8//! allowlists opaque ids, enums, numbers and booleans; it never carries a
9//! prompt, credential, absolute path, machine identity or runtime endpoint.
10//!
11//! The rest of the module is the port surface itself: the runtime declares
12//! what it needs, and the kernel host that composes it supplies the
13//! implementations once, at its crate root. Nothing here names a host module.
14use anyhow::{Result, anyhow};
15use licoup_conversation::{Membership, MembershipAccess, MembershipProfileSnapshot, ProfileIntent};
16use licoup_workflow::{BindingValue, RunCommand, RuntimeKind};
17use serde::{Deserialize, Serialize};
18use serde_json::{Value, json};
19use std::cmp::Ordering;
20use std::collections::{BTreeMap, BTreeSet};
21use std::path::Path;
22use std::sync::{Arc, Mutex, OnceLock};
23
24/// Bounded, privacy-safe target facts read from the Agent target owner.
25#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
26#[serde(rename_all = "camelCase")]
27pub struct TargetFacts {
28    pub status: Option<String>,
29    pub model: Option<String>,
30    pub environment: Option<String>,
31    pub capabilities: Vec<String>,
32    pub readiness: Option<String>,
33    pub reliability_class: Option<String>,
34    pub latency_class: Option<u8>,
35}
36
37/// Allowlisted model price facts projected from the pricing owner. Input and
38/// output prices are kept separate so the projection never invents a blended
39/// single number that the owner does not expose.
40#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
41#[serde(rename_all = "camelCase")]
42pub struct PriceFacts {
43    pub input: f64,
44    pub output: f64,
45}
46
47/// One read of one existing owner. Implementations must project allowlisted
48/// facts only and are expected to be request-scoped (the caller reads each
49/// owner at most once per request/revision).
50pub trait ProfileSnapshotAuthority: Send {
51    fn target_facts(&mut self, agent_id: &str) -> Option<TargetFacts>;
52    fn model_price_usd_per_million_tokens(&mut self, model: &str) -> Option<PriceFacts>;
53    fn coding_score(&mut self, agent_id: &str, model: &str) -> Option<i64>;
54    fn skill_names(&mut self, agent_id: &str) -> Vec<String>;
55}
56
57pub type SharedSnapshotAuthority = Arc<Mutex<Box<dyn ProfileSnapshotAuthority>>>;
58
59/// Request-scoped wrapper that reads each owner at most once per key. The
60/// cache lives only for the duration of one projection call.
61struct RequestScopedAuthority<'a> {
62    inner: &'a mut dyn ProfileSnapshotAuthority,
63    targets: BTreeMap<String, Option<TargetFacts>>,
64    prices: BTreeMap<String, Option<PriceFacts>>,
65    scores: BTreeMap<(String, String), Option<i64>>,
66    skills: BTreeMap<String, Vec<String>>,
67}
68
69impl<'a> RequestScopedAuthority<'a> {
70    fn new(inner: &'a mut dyn ProfileSnapshotAuthority) -> Self {
71        Self {
72            inner,
73            targets: BTreeMap::new(),
74            prices: BTreeMap::new(),
75            scores: BTreeMap::new(),
76            skills: BTreeMap::new(),
77        }
78    }
79
80    fn target_facts(&mut self, agent_id: &str) -> Option<TargetFacts> {
81        if let Some(cached) = self.targets.get(agent_id) {
82            return cached.clone();
83        }
84        let read = self.inner.target_facts(agent_id);
85        self.targets.insert(agent_id.to_owned(), read.clone());
86        read
87    }
88
89    fn model_price(&mut self, model: &str) -> Option<PriceFacts> {
90        if let Some(cached) = self.prices.get(model) {
91            return *cached;
92        }
93        let read = self.inner.model_price_usd_per_million_tokens(model);
94        self.prices.insert(model.to_owned(), read);
95        read
96    }
97
98    fn score(&mut self, agent_id: &str, model: &str) -> Option<i64> {
99        let key = (agent_id.to_owned(), model.to_owned());
100        if let Some(cached) = self.scores.get(&key) {
101            return *cached;
102        }
103        let read = self.inner.coding_score(agent_id, model);
104        self.scores.insert(key, read);
105        read
106    }
107
108    fn skills(&mut self, agent_id: &str) -> Vec<String> {
109        if let Some(cached) = self.skills.get(agent_id) {
110            return cached.clone();
111        }
112        let read = self.inner.skill_names(agent_id);
113        self.skills.insert(agent_id.to_owned(), read.clone());
114        read
115    }
116}
117
118/// Derive one Membership Profile snapshot from persistent intent plus the
119/// existing owners. `authority` is locked for one projection and each owner is
120/// read at most once per request through the request-scoped cache.
121pub fn project_profile_snapshot(
122    conversation_id: &str,
123    membership: &Membership,
124    intent: &ProfileIntent,
125    is_assistant: bool,
126    authority: &SharedSnapshotAuthority,
127    model_facts: &dyn ModelFactsPort,
128) -> MembershipProfileSnapshot {
129    let mut guard = authority
130        .lock()
131        .unwrap_or_else(|poison| poison.into_inner());
132    let mut scoped = RequestScopedAuthority::new(guard.as_mut());
133    project_with(
134        &mut scoped,
135        conversation_id,
136        membership,
137        intent,
138        is_assistant,
139        model_facts,
140    )
141}
142
143/// Project one or more Membership Profiles in one request while sharing one
144/// request-scoped authority cache, so each owner is read at most once per
145/// request/revision regardless of how many Memberships are projected.
146pub fn project_profile_snapshots(
147    conversation_id: &str,
148    members: &[(Membership, ProfileIntent, bool)],
149    authority: &SharedSnapshotAuthority,
150    model_facts: &dyn ModelFactsPort,
151) -> Vec<MembershipProfileSnapshot> {
152    let mut guard = authority
153        .lock()
154        .unwrap_or_else(|poison| poison.into_inner());
155    let mut scoped = RequestScopedAuthority::new(guard.as_mut());
156    members
157        .iter()
158        .map(|(membership, intent, is_assistant)| {
159            project_with(
160                &mut scoped,
161                conversation_id,
162                membership,
163                intent,
164                *is_assistant,
165                model_facts,
166            )
167        })
168        .collect()
169}
170
171/// Hard constraints applied before any ordering. Every required fact must be
172/// present and equal/contained; a missing membership binding is a hard
173/// failure, never a silent drop.
174#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
175#[serde(rename_all = "camelCase", deny_unknown_fields)]
176pub struct CandidateFilters {
177    #[serde(default)]
178    pub required_authority: Vec<String>,
179    #[serde(default)]
180    pub required_skills: Vec<String>,
181    #[serde(default)]
182    pub required_capabilities: Vec<String>,
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub required_model: Option<String>,
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub required_environment: Option<String>,
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub required_readiness: Option<String>,
189    #[serde(default)]
190    pub membership_ids: Vec<String>,
191    #[serde(default)]
192    pub pinned_membership_ids: Vec<String>,
193    #[serde(default)]
194    pub preferred_skills: Vec<String>,
195    #[serde(default)]
196    pub preferred_capabilities: Vec<String>,
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub preferred_model: Option<String>,
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub preferred_environment: Option<String>,
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub required_task: Option<String>,
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub preferred_task: Option<String>,
205}
206
207/// Apply hard filters, then order by the stable lexicographic tuple frozen by
208/// Decision 0004: explicit pin, preference match, verified reliability,
209/// coding score, known expected price, observed latency and Membership id.
210/// Unknown optional facts remain unknown and sort after known facts.
211pub fn rank_candidates(
212    snapshots: Vec<MembershipProfileSnapshot>,
213    filters: &CandidateFilters,
214) -> Result<Vec<MembershipProfileSnapshot>, String> {
215    let required_ids = filters.membership_ids.iter().collect::<BTreeSet<_>>();
216    let available = snapshots
217        .iter()
218        .map(|snapshot| &snapshot.membership_id)
219        .collect::<BTreeSet<_>>();
220    if !required_ids.is_subset(&available) {
221        return Err("profile_candidate_rejected".to_owned());
222    }
223    let mut eligible = snapshots
224        .into_iter()
225        .filter(|snapshot| {
226            (required_ids.is_empty() || required_ids.contains(&snapshot.membership_id))
227                && candidate_eligible(snapshot, filters)
228        })
229        .collect::<Vec<_>>();
230    let remaining = eligible
231        .iter()
232        .map(|snapshot| &snapshot.membership_id)
233        .collect::<BTreeSet<_>>();
234    if !required_ids.is_subset(&remaining) {
235        return Err("profile_candidate_rejected".to_owned());
236    }
237    let pin_order = filters
238        .pinned_membership_ids
239        .iter()
240        .chain(filters.membership_ids.iter())
241        .enumerate()
242        .fold(BTreeMap::new(), |mut result, (ordinal, membership_id)| {
243            result.entry(membership_id.as_str()).or_insert(ordinal);
244            result
245        });
246    eligible.sort_by(|left, right| {
247        pin_order
248            .get(left.membership_id.as_str())
249            .copied()
250            .unwrap_or(usize::MAX)
251            .cmp(
252                &pin_order
253                    .get(right.membership_id.as_str())
254                    .copied()
255                    .unwrap_or(usize::MAX),
256            )
257            .then_with(|| preference_misses(left, filters).cmp(&preference_misses(right, filters)))
258            .then_with(|| reliability_rank(left).cmp(&reliability_rank(right)))
259            .then_with(|| optional_desc(left.intelligence_score, right.intelligence_score))
260            .then_with(|| optional_price(left).cmp(&optional_price(right)))
261            .then_with(|| optional_asc(left.latency_class, right.latency_class))
262            .then_with(|| left.membership_id.cmp(&right.membership_id))
263    });
264    Ok(eligible)
265}
266
267fn candidate_eligible(snapshot: &MembershipProfileSnapshot, filters: &CandidateFilters) -> bool {
268    snapshot
269        .required_capabilities
270        .iter()
271        .all(|required| snapshot.capabilities.iter().any(|value| value == required))
272        && snapshot
273            .skill_references
274            .iter()
275            .all(|required| snapshot.skills.iter().any(|value| value == required))
276        && filters
277            .required_authority
278            .iter()
279            .all(|required| snapshot.authority.iter().any(|value| value == required))
280        && filters
281            .required_skills
282            .iter()
283            .all(|required| snapshot.skills.iter().any(|value| value == required))
284        && filters
285            .required_capabilities
286            .iter()
287            .all(|required| snapshot.capabilities.iter().any(|value| value == required))
288        && filters
289            .required_model
290            .as_deref()
291            .map(|required| snapshot.model.as_deref() == Some(required))
292            .unwrap_or(true)
293        && filters
294            .required_environment
295            .as_deref()
296            .map(|required| snapshot.environment.as_deref() == Some(required))
297            .unwrap_or(true)
298        && filters
299            .required_readiness
300            .as_deref()
301            .map(|required| snapshot.readiness.as_deref() == Some(required))
302            .unwrap_or(true)
303        && filters
304            .required_task
305            .as_deref()
306            .map(|required| snapshot.task_tags.iter().any(|tag| tag == required))
307            .unwrap_or(true)
308}
309
310fn preference_misses(snapshot: &MembershipProfileSnapshot, filters: &CandidateFilters) -> usize {
311    let model = filters
312        .preferred_model
313        .as_deref()
314        .or(snapshot.preferred_model.as_deref());
315    let environment = filters
316        .preferred_environment
317        .as_deref()
318        .or(snapshot.preferred_environment.as_deref());
319    usize::from(model.is_some_and(|value| snapshot.model.as_deref() != Some(value)))
320        + usize::from(
321            environment.is_some_and(|value| snapshot.environment.as_deref() != Some(value)),
322        )
323        + filters
324            .preferred_skills
325            .iter()
326            .filter(|value| !snapshot.skills.contains(value))
327            .count()
328        + filters
329            .preferred_capabilities
330            .iter()
331            .chain(snapshot.preferred_capabilities.iter())
332            .filter(|value| !snapshot.capabilities.contains(value))
333            .count()
334        + usize::from(
335            filters
336                .preferred_task
337                .as_deref()
338                .is_some_and(|task| !snapshot.task_tags.iter().any(|tag| tag == task)),
339        )
340}
341
342fn reliability_rank(snapshot: &MembershipProfileSnapshot) -> (bool, u8) {
343    let Some(value) = snapshot.reliability_class.as_deref() else {
344        return (true, u8::MAX);
345    };
346    let rank = match value {
347        "verified" | "high" | "ready" => 0,
348        "standard" | "partial" => 1,
349        "low" | "unverified" => 2,
350        _ => 3,
351    };
352    (false, rank)
353}
354
355fn optional_desc(left: Option<i64>, right: Option<i64>) -> Ordering {
356    match (left, right) {
357        (Some(left), Some(right)) => right.cmp(&left),
358        (Some(_), None) => Ordering::Less,
359        (None, Some(_)) => Ordering::Greater,
360        (None, None) => Ordering::Equal,
361    }
362}
363
364fn optional_asc<T: Ord>(left: Option<T>, right: Option<T>) -> Ordering {
365    match (left, right) {
366        (Some(left), Some(right)) => left.cmp(&right),
367        (Some(_), None) => Ordering::Less,
368        (None, Some(_)) => Ordering::Greater,
369        (None, None) => Ordering::Equal,
370    }
371}
372
373fn optional_price(snapshot: &MembershipProfileSnapshot) -> (bool, u64) {
374    let Some(input) = snapshot.price_input_usd_per_million_tokens else {
375        return (true, u64::MAX);
376    };
377    let Some(output) = snapshot.price_output_usd_per_million_tokens else {
378        return (true, u64::MAX);
379    };
380    if !input.is_finite() || !output.is_finite() || input < 0.0 || output < 0.0 {
381        return (true, u64::MAX);
382    }
383    (false, ((input + output) * 1_000_000.0).round() as u64)
384}
385
386fn project_with(
387    authority: &mut RequestScopedAuthority<'_>,
388    conversation_id: &str,
389    membership: &Membership,
390    intent: &ProfileIntent,
391    is_assistant: bool,
392    model_facts: &dyn ModelFactsPort,
393) -> MembershipProfileSnapshot {
394    let agent_id = membership
395        .principal
396        .agent_id
397        .clone()
398        .unwrap_or_else(|| membership.principal.id.clone());
399    let target = authority.target_facts(&agent_id);
400    let model = target.as_ref().and_then(|facts| facts.model.clone());
401    let price = model
402        .as_deref()
403        .and_then(|model| authority.model_price(model));
404    let score = model
405        .as_deref()
406        .and_then(|model| authority.score(&agent_id, model));
407    let mut capabilities = target
408        .as_ref()
409        .map(|facts| facts.capabilities.clone())
410        .unwrap_or_default();
411    capabilities.sort();
412    capabilities.dedup();
413    let mut skills = authority.skills(&agent_id);
414    if is_assistant
415        && let Some(bundled) = model_facts.bundled_guide_skill_id()
416        && intent.skill_references.iter().any(|skill| skill == bundled)
417        && !skills.iter().any(|skill| skill == bundled)
418    {
419        skills.push(bundled.to_owned());
420    }
421    skills.sort();
422    skills.dedup();
423    let authority = match membership.access {
424        MembershipAccess::Owner => vec![
425            "conversation.act".to_owned(),
426            "conversation.manage".to_owned(),
427            "conversation.read".to_owned(),
428        ],
429        MembershipAccess::Member => vec![
430            "conversation.act".to_owned(),
431            "conversation.read".to_owned(),
432        ],
433    };
434    let task_tags = model
435        .as_deref()
436        .map(|model| model_facts.task_tags_for_model(model))
437        .unwrap_or_default();
438    MembershipProfileSnapshot {
439        conversation_id: conversation_id.to_owned(),
440        membership_id: membership.id.clone(),
441        agent_id,
442        intent_revision: intent.revision,
443        responsibility: intent.responsibility,
444        required_capabilities: intent.required_capabilities.clone(),
445        preferred_capabilities: intent.preferred_capabilities.clone(),
446        skill_references: intent.skill_references.clone(),
447        preferred_model: intent.preferred_model.clone(),
448        preferred_reasoning_effort: intent.preferred_reasoning_effort.clone(),
449        preferred_environment: intent.preferred_environment.clone(),
450        preferred_permission_mode: intent.preferred_permission_mode.clone(),
451        model,
452        capabilities,
453        skills,
454        environment: target.as_ref().and_then(|facts| facts.environment.clone()),
455        readiness: target.as_ref().and_then(|facts| facts.readiness.clone()),
456        price_input_usd_per_million_tokens: price.map(|price| price.input),
457        price_output_usd_per_million_tokens: price.map(|price| price.output),
458        intelligence_score: score,
459        task_tags,
460        reliability_class: target
461            .as_ref()
462            .and_then(|facts| facts.reliability_class.clone()),
463        latency_class: target.as_ref().and_then(|facts| facts.latency_class),
464        authority,
465    }
466}
467
468// ------------------------------------------------------------------- ports
469
470/// The allowlisted model facts the runtime may publish about one binding.
471///
472/// The model registry, its catalogue and its curated intelligence snapshot stay
473/// with the host that owns them; the runtime only asks for the two projections
474/// a route receipt and a definition listing already carried.
475pub trait ModelFactsPort: Send + Sync {
476    /// Human-facing display name for a model id, or the id itself.
477    fn model_display_name(&self, model: &str) -> String;
478    /// The allowlisted intelligence projection for a model id, when the
479    /// catalogue carries one. Unknown models stay absent rather than invented.
480    fn project_allowlisted_model(&self, model: &str) -> Option<Value>;
481    /// The curated task tags the catalogue carries for a model id.
482    fn task_tags_for_model(&self, model: &str) -> Vec<String>;
483    /// The Skill id of the product guide this host bundles, when it bundles a
484    /// non-empty one. A host that ships no guide answers `None` rather than
485    /// claiming a Skill it cannot serve.
486    fn bundled_guide_skill_id(&self) -> Option<&'static str>;
487}
488
489// The numeric usage ledger, the resolved-runtime handle, the effect seam, the
490// effect permit and the turn-dispatch error are the engine's port vocabulary;
491// they arrive unchanged so host-side implementors bind the same contract.
492pub use acativa::runtime::ports::{
493    ActorTurnError, EffectPort, ResolvedRuntime, StrategyEffectPermit, UsageLedgerPort,
494};
495
496/// One committed route receipt for a candidate decision.
497///
498/// The receipt names the sources it was derived from and the ranked
499/// Memberships, so a replay can compare the decision instead of re-deriving
500/// it.
501///
502/// The selection policy is one of those sources, and its revision is the one
503/// the caller captured for this decision rather than whatever is current when
504/// the receipt is read: a durable admission keeps the revision it was admitted
505/// under, so a later adoption governs only the next task.
506pub fn route_receipt(
507    conversation_id: &str,
508    snapshots: &[MembershipProfileSnapshot],
509    model_facts: &dyn ModelFactsPort,
510    selection_policy_revision: &str,
511) -> Value {
512    json!({
513        "conversationId": conversation_id,
514        "sourceRevisions": [
515            {"source": "selectionPolicy", "revision": selection_policy_revision},
516            {"source": "targets", "revision": "read-only-v1"},
517            {"source": "nativeCapabilities", "revision": "v0.0.1"},
518            {"source": "providerModelPricing", "revision": "catalog-v1"},
519            {"source": "agentIntelligenceCatalog", "revision": "catalog-v1"},
520            {"source": "skillHub", "revision": "request-snapshot-v1"},
521            {"source": "assistantWorkflowAuthoringBundle", "revision": "v1"},
522        ],
523        "rankedMembershipIds": snapshots
524            .iter()
525            .map(|snapshot| snapshot.membership_id.clone())
526            .collect::<Vec<_>>(),
527        "candidates": snapshots.iter().map(|snapshot| json!({
528            "membershipId": snapshot.membership_id,
529            "profileRevision": snapshot.intent_revision,
530            "responsibility": snapshot.responsibility,
531            "model": snapshot.model,
532            "capabilities": snapshot.capabilities,
533            "skills": snapshot.skills,
534            "environment": snapshot.environment,
535            "readiness": snapshot.readiness,
536            "inputPriceUsdPerMillionTokens": snapshot.price_input_usd_per_million_tokens,
537            "outputPriceUsdPerMillionTokens": snapshot.price_output_usd_per_million_tokens,
538            "codingScore": snapshot.intelligence_score,
539            "taskTags": snapshot.task_tags,
540            "intelligence": snapshot.model.as_deref().and_then(|model| model_facts.project_allowlisted_model(model)),
541            "reliabilityClass": snapshot.reliability_class,
542            "latencyClass": snapshot.latency_class,
543            "authority": snapshot.authority,
544        })).collect::<Vec<_>>(),
545    })
546}
547
548// ------------------------------------------------------- host composition
549
550/// Everything the runtime reads from the host that composes it.
551///
552/// The host installs one of these per process at its crate root. A process
553/// that installs nothing keeps every port fail-closed: no effect runs, no fact
554/// is invented, and the ledger records nothing.
555#[derive(Clone)]
556pub struct HostPorts {
557    /// The request-scoped Membership Profile authority.
558    pub profile: SharedSnapshotAuthority,
559    /// The allowlisted model facts.
560    pub model: Arc<dyn ModelFactsPort>,
561    /// The numeric Graph usage ledger.
562    pub usage: Arc<dyn UsageLedgerPort>,
563    /// The effect seam.
564    pub effect: Arc<dyn EffectPort>,
565}
566
567impl HostPorts {
568    /// The composition a process has when it installed none: every read is
569    /// absent and every effect is refused, so a missing installation is a
570    /// typed refusal rather than invented work.
571    pub fn unavailable() -> Self {
572        Self {
573            profile: Arc::new(Mutex::new(Box::new(UnavailableProfileAuthority))),
574            model: Arc::new(UnavailableModelFacts),
575            usage: Arc::new(UnavailableUsageLedger),
576            effect: Arc::new(UnavailableEffect),
577        }
578    }
579}
580
581impl std::fmt::Debug for HostPorts {
582    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
583        formatter.debug_struct("HostPorts").finish_non_exhaustive()
584    }
585}
586
587static HOST_PORTS: OnceLock<HostPorts> = OnceLock::new();
588
589/// Install the process-wide composition. The first installation wins; a later
590/// one is ignored, so a host that composes the same answers twice stays one
591/// composition.
592pub fn install_host_ports(ports: HostPorts) {
593    let _ = HOST_PORTS.set(ports);
594}
595
596/// The installed composition, or the refusing default.
597pub fn host_ports() -> &'static HostPorts {
598    HOST_PORTS.get_or_init(HostPorts::unavailable)
599}
600
601struct UnavailableProfileAuthority;
602
603impl ProfileSnapshotAuthority for UnavailableProfileAuthority {
604    fn target_facts(&mut self, _agent_id: &str) -> Option<TargetFacts> {
605        None
606    }
607
608    fn model_price_usd_per_million_tokens(&mut self, _model: &str) -> Option<PriceFacts> {
609        None
610    }
611
612    fn coding_score(&mut self, _agent_id: &str, _model: &str) -> Option<i64> {
613        None
614    }
615
616    fn skill_names(&mut self, _agent_id: &str) -> Vec<String> {
617        Vec::new()
618    }
619}
620
621struct UnavailableModelFacts;
622
623impl ModelFactsPort for UnavailableModelFacts {
624    fn model_display_name(&self, model: &str) -> String {
625        // Display typography is a pure naming projection, not a catalogue
626        // fact. The host answers the same function; an uninstalled catalogue
627        // still formats the id instead of publishing it raw.
628        licoup_foundation::core::model_naming::model_display_name(model)
629    }
630
631    fn project_allowlisted_model(&self, _model: &str) -> Option<Value> {
632        None
633    }
634
635    fn task_tags_for_model(&self, _model: &str) -> Vec<String> {
636        Vec::new()
637    }
638
639    fn bundled_guide_skill_id(&self) -> Option<&'static str> {
640        None
641    }
642}
643
644struct UnavailableUsageLedger;
645
646impl UsageLedgerPort for UnavailableUsageLedger {
647    fn begin_graph_run(&self, _payload: &Value) -> Result<()> {
648        Ok(())
649    }
650
651    fn record_graph_command(&self, _payload: &Value) -> Result<()> {
652        Ok(())
653    }
654
655    fn workflow_report(&self, _payload: &Value) -> Result<Value> {
656        Err(anyhow!("workflow_host_port_unavailable"))
657    }
658}
659
660struct UnavailableEffect;
661
662impl EffectPort for UnavailableEffect {
663    fn runtime_descriptors(&self) -> Vec<Value> {
664        Vec::new()
665    }
666
667    fn compatible_runtime_id(
668        &self,
669        _kind: RuntimeKind,
670        _version_requirement: &str,
671    ) -> Option<String> {
672        None
673    }
674
675    fn resolve_runtime(
676        &self,
677        _runtime_id: &str,
678        _kind: RuntimeKind,
679        _version_requirement: &str,
680    ) -> Result<Arc<dyn ResolvedRuntime>> {
681        Err(anyhow!("workflow_host_port_unavailable"))
682    }
683
684    fn actor_fingerprint(
685        &self,
686        _value_id: &str,
687        _model: &str,
688        _reasoning_effort: &str,
689    ) -> Result<String> {
690        Err(anyhow!("workflow_host_port_unavailable"))
691    }
692
693    fn actor_capabilities(&self, _value_id: &str) -> Result<Value> {
694        Err(anyhow!("workflow_host_port_unavailable"))
695    }
696
697    fn admit_strategy_cwd(&self, _cwd: &str) -> Result<()> {
698        Err(anyhow!("workflow_host_port_unavailable"))
699    }
700
701    fn execute_script(
702        &self,
703        _command: &RunCommand,
704        _authorization_digest: &str,
705        _runtime: &Arc<dyn ResolvedRuntime>,
706        _revision_content: &Path,
707        _runtime_state_root: &Path,
708        _permit: &mut StrategyEffectPermit,
709    ) -> Result<Value> {
710        Err(anyhow!("workflow_host_port_unavailable"))
711    }
712
713    fn execute_actor(
714        &self,
715        _command: &RunCommand,
716        _authorization_digest: &str,
717        _binding: &BindingValue,
718        _permit: &mut StrategyEffectPermit,
719        _cwd: Option<&str>,
720    ) -> Result<Value> {
721        Err(anyhow!("workflow_host_port_unavailable"))
722    }
723
724    fn predecessor_locator(&self, _facts: &Value) -> Value {
725        Value::Null
726    }
727
728    fn dispatch_lane_operation(&self, _operation: &str, _params: &Value) -> Result<Value> {
729        Err(anyhow!("workflow_host_port_unavailable"))
730    }
731
732    fn new_correlation_id(&self) -> String {
733        String::new()
734    }
735
736    fn record_run_stop(
737        &self,
738        _portable_root: &Path,
739        _correlation_id: &str,
740        _confirmed: bool,
741    ) -> Result<()> {
742        Ok(())
743    }
744}
745#[cfg(test)]
746mod tests {
747    use super::*;
748    use licoup_conversation::{
749        LICOUP_GUIDE_SKILL_ID, MembershipAccess, MembershipStatus, Principal, PrincipalKind,
750        ProfileResponsibility,
751    };
752
753    #[test]
754    fn request_scoped_authority_reads_each_owner_once() {
755        let calls = Arc::new(Mutex::new(BTreeMap::<&'static str, usize>::new()));
756        let mut inner = CountingAuthority {
757            calls: Arc::clone(&calls),
758        };
759        let mut scoped = RequestScopedAuthority::new(&mut inner);
760        for _ in 0..3 {
761            assert_eq!(
762                scoped.target_facts("agent:one").unwrap().status.as_deref(),
763                Some("ready")
764            );
765            assert_eq!(
766                scoped.model_price("model-a"),
767                Some(PriceFacts {
768                    input: 1.0,
769                    output: 2.0
770                })
771            );
772            assert_eq!(scoped.score("agent:one", "model-a"), Some(2));
773            assert_eq!(scoped.skills("agent:one"), vec!["skill-a".to_owned()]);
774        }
775        let recorded = calls.lock().unwrap();
776        assert_eq!(
777            recorded.clone(),
778            BTreeMap::from([
779                ("target_facts", 1),
780                ("model_price", 1),
781                ("score", 1),
782                ("skills", 1),
783            ])
784        );
785    }
786
787    #[test]
788    fn intent_cannot_assert_derived_truth_or_authority() {
789        let calls = Arc::new(Mutex::new(BTreeMap::<&'static str, usize>::new()));
790        let authority: SharedSnapshotAuthority =
791            Arc::new(Mutex::new(Box::new(CountingAuthority {
792                calls: Arc::clone(&calls),
793            })));
794        let membership = membership("membership:one", MembershipAccess::Member);
795        let intent = ProfileIntent {
796            revision: 3,
797            required_capabilities: vec!["caller-asserted-capability".to_owned()],
798            preferred_capabilities: vec!["caller-preference".to_owned()],
799            skill_references: vec![
800                "caller-asserted-skill".to_owned(),
801                LICOUP_GUIDE_SKILL_ID.to_owned(),
802            ],
803            preferred_model: Some("caller-model".to_owned()),
804            preferred_reasoning_effort: Some("high".to_owned()),
805            preferred_environment: Some("caller-environment".to_owned()),
806            preferred_permission_mode: Some("caller-permission-mode".to_owned()),
807            preferred_instructions: Some("caller-instructions".to_owned()),
808            preferred_context_window: Some(128_000),
809            responsibility: ProfileResponsibility::Assistant,
810            updated_at_unix_ms: 9,
811        };
812        let snapshot = project_profile_snapshot(
813            "conversation:g",
814            &membership,
815            &intent,
816            true,
817            &authority,
818            &UnavailableModelFacts,
819        );
820        assert_eq!(snapshot.membership_id, "membership:one");
821        assert_eq!(snapshot.intent_revision, 3);
822        assert_eq!(snapshot.model.as_deref(), Some("target-model"));
823        assert_eq!(snapshot.price_input_usd_per_million_tokens, Some(1.0));
824        assert_eq!(snapshot.price_output_usd_per_million_tokens, Some(2.0));
825        assert_eq!(snapshot.intelligence_score, Some(2));
826        assert!(
827            !snapshot
828                .capabilities
829                .contains(&"caller-asserted-capability".to_owned())
830        );
831        assert_eq!(
832            snapshot.capabilities,
833            vec!["conversationDriver:supported".to_owned()]
834        );
835        assert!(snapshot.skills.contains(&"skill-a".to_owned()));
836        assert!(
837            !snapshot
838                .skills
839                .contains(&"caller-asserted-skill".to_owned())
840        );
841        // A skill reference in the intent is not a bundled Skill. The guide is
842        // present only when this host says it ships that Skill.
843        assert!(!snapshot.skills.contains(&LICOUP_GUIDE_SKILL_ID.to_owned()));
844        let bundled = project_profile_snapshot(
845            "conversation:g",
846            &membership,
847            &intent,
848            true,
849            &authority,
850            &BundledGuideFacts,
851        );
852        assert!(bundled.skills.contains(&"skill-a".to_owned()));
853        assert!(!bundled.skills.contains(&"caller-asserted-skill".to_owned()));
854        assert!(bundled.skills.contains(&LICOUP_GUIDE_SKILL_ID.to_owned()));
855        assert_eq!(snapshot.environment.as_deref(), Some("local"));
856        assert_eq!(snapshot.readiness.as_deref(), Some("ready"));
857        assert_eq!(
858            snapshot.authority,
859            vec![
860                "conversation.act".to_owned(),
861                "conversation.read".to_owned()
862            ]
863        );
864        let encoded = serde_json::to_value(&snapshot).unwrap();
865        assert!(encoded.get("id").is_none());
866        assert!(encoded.get("displayName").is_none());
867        assert!(encoded.get("updatedAtUnixMs").is_none());
868    }
869
870    #[test]
871    fn hard_filters_precede_the_stable_lexicographic_order() {
872        let mut first = snapshot("membership:b");
873        first.reliability_class = Some("verified".to_owned());
874        first.intelligence_score = Some(8);
875        first.price_input_usd_per_million_tokens = Some(4.0);
876        first.price_output_usd_per_million_tokens = Some(8.0);
877        first.latency_class = Some(2);
878        let mut pinned = snapshot("membership:c");
879        pinned.reliability_class = None;
880        pinned.intelligence_score = None;
881        let mut cheap = snapshot("membership:a");
882        cheap.reliability_class = Some("verified".to_owned());
883        cheap.intelligence_score = Some(8);
884        cheap.price_input_usd_per_million_tokens = Some(1.0);
885        cheap.price_output_usd_per_million_tokens = Some(2.0);
886        cheap.latency_class = Some(1);
887        let ranked = rank_candidates(
888            vec![first, pinned, cheap],
889            &CandidateFilters {
890                required_capabilities: vec!["conversationDriver:supported".to_owned()],
891                pinned_membership_ids: vec!["membership:c".to_owned()],
892                ..CandidateFilters::default()
893            },
894        )
895        .unwrap();
896        assert_eq!(
897            ranked
898                .iter()
899                .map(|candidate| candidate.membership_id.as_str())
900                .collect::<Vec<_>>(),
901            vec!["membership:c", "membership:a", "membership:b"]
902        );
903        let rejected = rank_candidates(
904            ranked,
905            &CandidateFilters {
906                membership_ids: vec!["membership:c".to_owned()],
907                required_skills: vec!["missing".to_owned()],
908                ..CandidateFilters::default()
909            },
910        );
911        assert_eq!(rejected.unwrap_err(), "profile_candidate_rejected");
912    }
913
914    #[test]
915    fn task_filters_boost_matching_catalog_tags() {
916        let mut frontend = snapshot("membership:frontend");
917        frontend.task_tags = vec!["frontend".to_owned()];
918        frontend.intelligence_score = Some(4);
919        let mut backend = snapshot("membership:backend");
920        backend.task_tags = vec!["backend".to_owned()];
921        backend.intelligence_score = Some(9);
922        let ranked = rank_candidates(
923            vec![frontend.clone(), backend.clone()],
924            &CandidateFilters {
925                preferred_task: Some("frontend".to_owned()),
926                ..CandidateFilters::default()
927            },
928        )
929        .unwrap();
930        assert_eq!(ranked[0].membership_id, "membership:frontend");
931        let required = rank_candidates(
932            vec![frontend, backend],
933            &CandidateFilters {
934                required_task: Some("frontend".to_owned()),
935                ..CandidateFilters::default()
936            },
937        )
938        .unwrap();
939        assert_eq!(required.len(), 1);
940        assert_eq!(required[0].membership_id, "membership:frontend");
941    }
942
943    fn membership(id: &str, access: MembershipAccess) -> Membership {
944        Membership {
945            id: id.to_owned(),
946            conversation_id: "conversation:g".to_owned(),
947            principal: Principal {
948                id: "agent:one".to_owned(),
949                kind: PrincipalKind::Agent,
950                display_name: "One".to_owned(),
951                agent_id: Some("agent:one".to_owned()),
952                created_at_unix_ms: 1,
953            },
954            access,
955            status: MembershipStatus::Active,
956            joined_at_unix_ms: 1,
957            left_at_unix_ms: None,
958        }
959    }
960
961    fn snapshot(id: &str) -> MembershipProfileSnapshot {
962        MembershipProfileSnapshot {
963            conversation_id: "conversation:g".to_owned(),
964            membership_id: id.to_owned(),
965            agent_id: "agent:one".to_owned(),
966            intent_revision: 1,
967            responsibility: ProfileResponsibility::Member,
968            required_capabilities: Vec::new(),
969            preferred_capabilities: Vec::new(),
970            skill_references: Vec::new(),
971            preferred_model: None,
972            preferred_reasoning_effort: None,
973            preferred_environment: None,
974            preferred_permission_mode: None,
975            model: Some("model-a".to_owned()),
976            capabilities: vec!["conversationDriver:supported".to_owned()],
977            skills: vec!["skill-a".to_owned()],
978            environment: Some("local".to_owned()),
979            readiness: Some("ready".to_owned()),
980            price_input_usd_per_million_tokens: None,
981            price_output_usd_per_million_tokens: None,
982            intelligence_score: None,
983            task_tags: Vec::new(),
984            reliability_class: None,
985            latency_class: None,
986            authority: vec!["conversation.act".to_owned()],
987        }
988    }
989
990    struct CountingAuthority {
991        calls: Arc<Mutex<BTreeMap<&'static str, usize>>>,
992    }
993
994    impl ProfileSnapshotAuthority for CountingAuthority {
995        fn target_facts(&mut self, _agent_id: &str) -> Option<TargetFacts> {
996            *self
997                .calls
998                .lock()
999                .unwrap()
1000                .entry("target_facts")
1001                .or_insert(0) += 1;
1002            Some(TargetFacts {
1003                status: Some("ready".to_owned()),
1004                model: Some("target-model".to_owned()),
1005                environment: Some("local".to_owned()),
1006                capabilities: vec!["conversationDriver:supported".to_owned()],
1007                readiness: Some("ready".to_owned()),
1008                reliability_class: Some("verified".to_owned()),
1009                latency_class: Some(1),
1010            })
1011        }
1012
1013        fn model_price_usd_per_million_tokens(&mut self, _model: &str) -> Option<PriceFacts> {
1014            *self.calls.lock().unwrap().entry("model_price").or_insert(0) += 1;
1015            Some(PriceFacts {
1016                input: 1.0,
1017                output: 2.0,
1018            })
1019        }
1020
1021        fn coding_score(&mut self, _agent_id: &str, _model: &str) -> Option<i64> {
1022            *self.calls.lock().unwrap().entry("score").or_insert(0) += 1;
1023            Some(2)
1024        }
1025
1026        fn skill_names(&mut self, _agent_id: &str) -> Vec<String> {
1027            *self.calls.lock().unwrap().entry("skills").or_insert(0) += 1;
1028            vec!["skill-a".to_owned()]
1029        }
1030    }
1031
1032    struct BundledGuideFacts;
1033
1034    impl ModelFactsPort for BundledGuideFacts {
1035        fn model_display_name(&self, model: &str) -> String {
1036            model.to_owned()
1037        }
1038
1039        fn project_allowlisted_model(&self, _model: &str) -> Option<Value> {
1040            None
1041        }
1042
1043        fn task_tags_for_model(&self, _model: &str) -> Vec<String> {
1044            Vec::new()
1045        }
1046
1047        fn bundled_guide_skill_id(&self) -> Option<&'static str> {
1048            Some(LICOUP_GUIDE_SKILL_ID)
1049        }
1050    }
1051}
1052#[cfg(test)]
1053mod port_stability {
1054    //! Stability of the port surface itself.
1055    //!
1056    //! The host answers these ports from outside this crate, so the surface is
1057    //! a published contract: a renamed or retyped method, a different field, a
1058    //! changed contract constant or a dropped re-export is a breaking change
1059    //! that has to fail here rather than at the far end of the composition.
1060    //!
1061    //! Two independent mechanisms cover the surface. The declarations are read
1062    //! back from this module's own source, so an added, removed or retyped port
1063    //! item fails as a difference against the frozen record below. The witness
1064    //! implementations, the `HostPorts` construction and the receipt/filter
1065    //! projections pin the same items to the compiler and to the wire format,
1066    //! so a signature cannot drift while the record is edited to follow it.
1067    use super::*;
1068    use licoup_conversation::ProfileResponsibility;
1069
1070    const PORT_SOURCE: &str = include_str!("ports.rs");
1071    const CRATE_ROOT: &str = include_str!("lib.rs");
1072
1073    /// Every public item this module declares, as `kind name`.
1074    ///
1075    /// Removing a name is as much a surface change as adding one. The engine's
1076    /// shared port vocabulary — `EffectPort`, `UsageLedgerPort`,
1077    /// `ResolvedRuntime`, `StrategyEffectPermit`, `ActorTurnError` — is
1078    /// declared once in `acativa::runtime::ports` and re-exported here, so the
1079    /// record below names only what this file itself publishes.
1080    const PUBLISHED_PORT_ITEMS: &[&str] = &[
1081        "fn host_ports",
1082        "fn install_host_ports",
1083        "fn project_profile_snapshot",
1084        "fn project_profile_snapshots",
1085        "fn rank_candidates",
1086        "fn route_receipt",
1087        "struct CandidateFilters",
1088        "struct HostPorts",
1089        "struct PriceFacts",
1090        "struct TargetFacts",
1091        "trait ModelFactsPort",
1092        "trait ProfileSnapshotAuthority",
1093        "type SharedSnapshotAuthority",
1094    ];
1095
1096    /// The items that deliberately stay internal to this module. They carry no
1097    /// visibility of their own, so the scanner never sees them as declarations
1098    /// and no re-export is owed for them.
1099    const UNPUBLISHED_PORT_ITEMS: &[&str] = &["RequestScopedAuthority"];
1100
1101    /// Every trait method, in declaration order, for the traits this file
1102    /// itself declares. The engine-owned traits keep their own frozen record
1103    /// in `acativa`'s port module.
1104    const FROZEN_TRAIT_METHODS: &[(&str, &[&str])] = &[
1105        (
1106            "ModelFactsPort",
1107            &[
1108                "model_display_name",
1109                "project_allowlisted_model",
1110                "task_tags_for_model",
1111                "bundled_guide_skill_id",
1112            ],
1113        ),
1114        (
1115            "ProfileSnapshotAuthority",
1116            &[
1117                "target_facts",
1118                "model_price_usd_per_million_tokens",
1119                "coding_score",
1120                "skill_names",
1121            ],
1122        ),
1123    ];
1124
1125    /// Every struct field, in declaration order.
1126    const FROZEN_STRUCT_FIELDS: &[(&str, &[&str])] = &[
1127        (
1128            "CandidateFilters",
1129            &[
1130                "required_authority",
1131                "required_skills",
1132                "required_capabilities",
1133                "required_model",
1134                "required_environment",
1135                "required_readiness",
1136                "membership_ids",
1137                "pinned_membership_ids",
1138                "preferred_skills",
1139                "preferred_capabilities",
1140                "preferred_model",
1141                "preferred_environment",
1142                "required_task",
1143                "preferred_task",
1144            ],
1145        ),
1146        ("HostPorts", &["profile", "model", "usage", "effect"]),
1147        ("PriceFacts", &["input", "output"]),
1148        (
1149            "TargetFacts",
1150            &[
1151                "status",
1152                "model",
1153                "environment",
1154                "capabilities",
1155                "readiness",
1156                "reliability_class",
1157                "latency_class",
1158            ],
1159        ),
1160    ];
1161
1162    /// Every port item the crate root re-exports, and nothing else.
1163    const ROOT_REEXPORTED_PORT_ITEMS: &[&str] = &[
1164        "ActorTurnError",
1165        "CandidateFilters",
1166        "EffectPort",
1167        "HostPorts",
1168        "ModelFactsPort",
1169        "PriceFacts",
1170        "ProfileSnapshotAuthority",
1171        "ResolvedRuntime",
1172        "SharedSnapshotAuthority",
1173        "StrategyEffectPermit",
1174        "TargetFacts",
1175        "UsageLedgerPort",
1176        "host_ports",
1177        "install_host_ports",
1178        "project_profile_snapshot",
1179        "project_profile_snapshots",
1180        "rank_candidates",
1181        "route_receipt",
1182    ];
1183
1184    #[derive(Clone, Debug, Eq, PartialEq)]
1185    struct PortItem {
1186        kind: String,
1187        name: String,
1188    }
1189
1190    impl PortItem {
1191        fn render(&self) -> String {
1192            format!("{} {}", self.kind, self.name)
1193        }
1194    }
1195
1196    /// One declaration of the port module: who it is and what it contains.
1197    #[derive(Clone, Debug)]
1198    struct PortDeclaration {
1199        item: PortItem,
1200        members: Vec<String>,
1201    }
1202
1203    /// Read the port declarations back from this module's own source.
1204    ///
1205    /// Documentation, formatting and phrasing stay free: comments are stripped
1206    /// and whitespace is collapsed, while every keyword, name and type is
1207    /// compared. A declaration is the structural line at column zero together
1208    /// with the indented lines that state its signature; a trait or struct
1209    /// additionally keeps the members one level inside its own block.
1210    fn scan_port_declarations(source: &str) -> Vec<PortDeclaration> {
1211        let lines = structural_lines(source);
1212        let mut declarations = Vec::new();
1213        let mut index = 0;
1214        while index < lines.len() {
1215            if lines[index].indent != 0 {
1216                index += 1;
1217                continue;
1218            }
1219            let Some(item) = port_item(&lines[index].text, false) else {
1220                index += 1;
1221                continue;
1222            };
1223            let opened = index;
1224            signature_end(&lines, &mut index);
1225            let members = member_names(&lines, opened, index);
1226            declarations.push(PortDeclaration { item, members });
1227        }
1228        declarations
1229    }
1230
1231    /// The names one level inside a declaration's own block.
1232    ///
1233    /// Only the direct members count: an `impl` block, a nested module or a
1234    /// member's own body belong to implementation, not to the port surface.
1235    fn member_names(lines: &[StructuralLine], opened: usize, end: usize) -> Vec<String> {
1236        if !lines[opened].text.ends_with('{') || end <= opened + 1 {
1237            return Vec::new();
1238        }
1239        let Some(body_indent) = lines[opened + 1..end]
1240            .iter()
1241            .find(|line| line.indent > lines[opened].indent)
1242            .map(|line| line.indent)
1243        else {
1244            return Vec::new();
1245        };
1246        let mut members = Vec::new();
1247        for line in &lines[opened + 1..end] {
1248            if line.indent != body_indent {
1249                continue;
1250            }
1251            if let Some(member) = port_item(&line.text, true)
1252                .map(|item| item.name)
1253                .or_else(|| struct_field(&line.text))
1254            {
1255                members.push(member);
1256            }
1257        }
1258        members
1259    }
1260
1261    struct StructuralLine {
1262        indent: usize,
1263        text: String,
1264    }
1265
1266    /// The source without comments and blank lines, keeping text and indent.
1267    fn structural_lines(source: &str) -> Vec<StructuralLine> {
1268        let mut lines = Vec::new();
1269        let mut in_block_comment = false;
1270        for raw in source.lines() {
1271            let mut text = String::new();
1272            let mut quotes = 0usize;
1273            let mut characters = raw.chars().peekable();
1274            while let Some(character) = characters.next() {
1275                if in_block_comment {
1276                    if character == '*' && characters.peek() == Some(&'/') {
1277                        characters.next();
1278                        in_block_comment = false;
1279                    }
1280                    continue;
1281                }
1282                if character == '"' {
1283                    quotes = 1 - quotes;
1284                } else if quotes == 0 && character == '/' && characters.peek() == Some(&'/') {
1285                    break;
1286                } else if quotes == 0 && character == '/' && characters.peek() == Some(&'*') {
1287                    characters.next();
1288                    in_block_comment = true;
1289                    continue;
1290                }
1291                text.push(character);
1292            }
1293            if !text.trim().is_empty() {
1294                lines.push(StructuralLine {
1295                    indent: text.len() - text.trim_start().len(),
1296                    text: text.trim().to_owned(),
1297                });
1298            }
1299        }
1300        lines
1301    }
1302
1303    /// The declaration a structural line states, when it is in the port
1304    /// surface. At module level the item has to be published; inside a trait
1305    /// the members carry no visibility of their own.
1306    fn port_item(text: &str, nested: bool) -> Option<PortItem> {
1307        if !text.starts_with("pub ") && !nested {
1308            return None;
1309        }
1310        if !text.starts_with("pub ")
1311            && !text.starts_with("fn ")
1312            && !text.starts_with("type ")
1313            && !text.starts_with("const ")
1314            && !text.starts_with("static ")
1315        {
1316            return None;
1317        }
1318        let declaration = declaration_part(text)?;
1319        let keyword = declaration.split_whitespace().next()?;
1320        let kind = match keyword {
1321            "fn" => "fn",
1322            "type" => "type",
1323            "struct" => "struct",
1324            "enum" => "enum",
1325            "trait" => "trait",
1326            "mod" => "mod",
1327            "const" => "const",
1328            "static" => "static",
1329            _ => return None,
1330        };
1331        let name = declaration[keyword.len()..]
1332            .trim_start()
1333            .split(|character: char| !(character.is_alphanumeric() || character == '_'))
1334            .next()
1335            .filter(|name| !name.is_empty())?;
1336        Some(PortItem {
1337            kind: kind.to_owned(),
1338            name: name.to_owned(),
1339        })
1340    }
1341
1342    /// A struct field written as `pub name: Type`.
1343    fn struct_field(text: &str) -> Option<String> {
1344        let declaration = text.strip_prefix("pub ")?.split(';').next()?;
1345        let (name, _) = declaration.split_once(':')?;
1346        let name = name.trim();
1347        (!name.is_empty()).then(|| name.to_owned())
1348    }
1349
1350    /// The declaration without its visibility marker and without any body.
1351    fn declaration_part(text: &str) -> Option<String> {
1352        let declaration = text.strip_prefix("pub ").unwrap_or(text);
1353        if let Some(opening) = declaration.rfind('{') {
1354            return Some(declaration[..opening].trim().to_owned());
1355        }
1356        let declaration = declaration.split(';').next()?.trim();
1357        (!declaration.is_empty()).then(|| declaration.to_owned())
1358    }
1359
1360    /// Step past the signature lines of one declaration.
1361    fn signature_end(lines: &[StructuralLine], index: &mut usize) -> usize {
1362        let mut depth = 0i64;
1363        let mut opened_block = false;
1364        while *index < lines.len() {
1365            let text = lines[*index].text.clone();
1366            *index += 1;
1367            depth += balance(&text);
1368            if depth > 0 {
1369                opened_block = true;
1370            }
1371            if (opened_block && depth == 0) || (!opened_block && text.ends_with(';')) {
1372                break;
1373            }
1374        }
1375        *index
1376    }
1377
1378    fn balance(text: &str) -> i64 {
1379        text.chars().fold(0, |depth, character| match character {
1380            '{' => depth + 1,
1381            '}' => depth - 1,
1382            _ => depth,
1383        })
1384    }
1385
1386    fn frozen_members<'a>(frozen: &'a [(&'a str, &'a [&'a str])]) -> BTreeSet<&'a str> {
1387        frozen.iter().map(|(name, _)| *name).collect()
1388    }
1389
1390    /// Compare the record against the declarations in both directions: a
1391    /// record entry that no longer exists and a declaration the record does
1392    /// not publish are each a contract change.
1393    fn contract_differences(declared: &[PortItem], frozen: &[&str]) -> Vec<String> {
1394        let declared = declared
1395            .iter()
1396            .map(PortItem::render)
1397            .collect::<BTreeSet<_>>();
1398        let frozen = frozen
1399            .iter()
1400            .map(|item| (*item).to_owned())
1401            .collect::<BTreeSet<_>>();
1402        let mut differences = declared
1403            .difference(&frozen)
1404            .map(|item| format!("{item} is declared but not published in the contract"))
1405            .collect::<Vec<_>>();
1406        differences.extend(
1407            frozen
1408                .difference(&declared)
1409                .map(|item| format!("{item} is published but no longer declared")),
1410        );
1411        differences
1412    }
1413
1414    fn member_differences(
1415        declarations: &[PortDeclaration],
1416        kind: &str,
1417        frozen: &[(&str, &[&str])],
1418    ) -> Vec<String> {
1419        let declared = declarations
1420            .iter()
1421            .filter(|declaration| declaration.item.kind == kind)
1422            .map(|declaration| (declaration.item.name.clone(), declaration.members.clone()))
1423            .collect::<BTreeMap<_, _>>();
1424        let mut differences = Vec::new();
1425        for (name, expected) in frozen {
1426            match declared.get(*name) {
1427                None => differences.push(format!("{kind} {name} is no longer declared")),
1428                Some(actual) if actual != expected => differences.push(format!(
1429                    "{kind} {name} changed\n  published: {expected:?}\n  declared:  {actual:?}",
1430                )),
1431                Some(_) => {}
1432            }
1433        }
1434        for (name, members) in &declared {
1435            // A type whose members are private publishes no member set of its
1436            // own; its public methods are pinned by the signature witnesses.
1437            if members.is_empty() {
1438                continue;
1439            }
1440            if !frozen_members(frozen).contains(name.as_str()) {
1441                differences.push(format!(
1442                    "{kind} {name} is not part of the published contract"
1443                ));
1444            }
1445        }
1446        differences
1447    }
1448
1449    /// Render one member record the way it is written in this module, so a
1450    /// deliberate surface change can be recorded by copying the printed form
1451    /// instead of transcribing names by hand.
1452    fn render_member_record(declarations: &[PortDeclaration], kind: &str) -> String {
1453        let mut rendered = declarations
1454            .iter()
1455            .filter(|declaration| declaration.item.kind == kind && !declaration.members.is_empty())
1456            .map(|declaration| {
1457                let members = declaration
1458                    .members
1459                    .iter()
1460                    .map(|member| format!("\"{member}\""))
1461                    .collect::<Vec<_>>()
1462                    .join(", ");
1463                format!("        (\"{}\", &[{}]),\n", declaration.item.name, members,)
1464            })
1465            .collect::<Vec<_>>();
1466        rendered.sort();
1467        rendered.concat()
1468    }
1469
1470    fn render_item_list(declarations: &[PortDeclaration], kind: &str) -> String {
1471        let mut rendered = declarations
1472            .iter()
1473            .filter(|declaration| declaration.item.kind == kind)
1474            .map(|declaration| format!("        \"{}\",\n", declaration.item.render()))
1475            .collect::<Vec<_>>();
1476        rendered.sort();
1477        rendered.concat()
1478    }
1479
1480    /// Regenerate the frozen records above from the current declarations.
1481    ///
1482    /// Run `cargo test --lib port_stability::print -- --nocapture` and copy the
1483    /// printed arrays over the constants. This is the only supported way to
1484    /// change the record: a reviewer sees the exact difference the change
1485    /// makes to the published surface.
1486    #[test]
1487    fn print_the_frozen_records() {
1488        let declarations = scan_port_declarations(PORT_SOURCE);
1489        println!("PUBLISHED_PORT_ITEMS = [");
1490        print!("{}", render_item_list(&declarations, "fn"));
1491        print!("{}", render_item_list(&declarations, "struct"));
1492        print!("{}", render_item_list(&declarations, "trait"));
1493        print!("{}", render_item_list(&declarations, "type"));
1494        println!("]");
1495        println!(
1496            "FROZEN_TRAIT_METHODS = [\n{}]",
1497            render_member_record(&declarations, "trait"),
1498        );
1499        println!(
1500            "FROZEN_STRUCT_FIELDS = [\n{}]",
1501            render_member_record(&declarations, "struct"),
1502        );
1503    }
1504
1505    /// One candidate for the wire-contract checks, written out in full so the
1506    /// projection under test reads the same data a route would produce.
1507    fn stability_snapshot(id: &str) -> MembershipProfileSnapshot {
1508        MembershipProfileSnapshot {
1509            conversation_id: "conversation:g".to_owned(),
1510            membership_id: id.to_owned(),
1511            agent_id: "agent:one".to_owned(),
1512            intent_revision: 1,
1513            responsibility: ProfileResponsibility::Member,
1514            required_capabilities: Vec::new(),
1515            preferred_capabilities: Vec::new(),
1516            skill_references: Vec::new(),
1517            preferred_model: None,
1518            preferred_reasoning_effort: None,
1519            preferred_environment: None,
1520            preferred_permission_mode: None,
1521            model: Some("model-a".to_owned()),
1522            capabilities: vec!["conversationDriver:supported".to_owned()],
1523            skills: vec!["skill-a".to_owned()],
1524            environment: Some("local".to_owned()),
1525            readiness: Some("ready".to_owned()),
1526            price_input_usd_per_million_tokens: None,
1527            price_output_usd_per_million_tokens: None,
1528            intelligence_score: None,
1529            task_tags: Vec::new(),
1530            reliability_class: None,
1531            latency_class: None,
1532            authority: vec!["conversation.act".to_owned()],
1533        }
1534    }
1535
1536    /// The port surface as this module declares it: the public items, the
1537    /// methods of every port trait and the fields of every port struct, each
1538    /// compared in both directions against the published record.
1539    #[test]
1540    fn port_surface_declarations_are_frozen() {
1541        let declarations = scan_port_declarations(PORT_SOURCE);
1542        let declared = declarations
1543            .iter()
1544            .map(|declaration| declaration.item.clone())
1545            .collect::<Vec<_>>();
1546        let mut differences = contract_differences(&declared, PUBLISHED_PORT_ITEMS);
1547        differences.extend(UNPUBLISHED_PORT_ITEMS.iter().flat_map(|item| {
1548            let declared = PORT_SOURCE.contains(&format!("struct {item}"));
1549            let published = PORT_SOURCE.contains(&format!("pub struct {item}"));
1550            let mut found = Vec::new();
1551            if !declared {
1552                found.push(format!("{item} is listed as internal but is not declared"));
1553            }
1554            if published {
1555                found.push(format!("{item} is listed as internal but is published"));
1556            }
1557            found
1558        }));
1559        differences.extend(member_differences(
1560            &declarations,
1561            "trait",
1562            FROZEN_TRAIT_METHODS,
1563        ));
1564        differences.extend(member_differences(
1565            &declarations,
1566            "struct",
1567            FROZEN_STRUCT_FIELDS,
1568        ));
1569        assert!(
1570            differences.is_empty(),
1571            "the workflow port contract changed; update the host composition and the published record together:\n{}",
1572            differences.join("\n"),
1573        );
1574        assert_eq!(
1575            declarations.len(),
1576            PUBLISHED_PORT_ITEMS.len(),
1577            "the set of declarations this module owns changed",
1578        );
1579    }
1580
1581    /// The compiler-side pin: each port trait keeps exactly these methods with
1582    /// exactly these signatures. A renamed, retyped, added or removed method
1583    /// stops this module from compiling.
1584    ///
1585    /// The witness calls name the concrete type that implements the trait and
1586    /// take `&dyn` only afterwards: Rust cannot coerce an unselected trait
1587    /// method item to a `fn` pointer over `dyn Trait`, and selecting it beside
1588    /// the methods above is exactly the signature check this test exists for.
1589    #[test]
1590    fn port_trait_signatures_are_frozen() {
1591        let _: fn(&mut UnavailableProfileAuthority, &str) -> Option<TargetFacts> =
1592            UnavailableProfileAuthority::target_facts;
1593        let _: fn(&mut UnavailableProfileAuthority, &str) -> Option<PriceFacts> =
1594            UnavailableProfileAuthority::model_price_usd_per_million_tokens;
1595        let _: fn(&mut UnavailableProfileAuthority, &str, &str) -> Option<i64> =
1596            UnavailableProfileAuthority::coding_score;
1597        let _: fn(&mut UnavailableProfileAuthority, &str) -> Vec<String> =
1598            UnavailableProfileAuthority::skill_names;
1599        let _: fn(&mut dyn ProfileSnapshotAuthority, &str, &str) -> Option<i64> =
1600            |authority, agent_id, model| {
1601                (authority as &mut dyn ProfileSnapshotAuthority).coding_score(agent_id, model)
1602            };
1603
1604        let _: fn(&UnavailableModelFacts, &str) -> String =
1605            UnavailableModelFacts::model_display_name;
1606        let _: fn(&UnavailableModelFacts, &str) -> Option<Value> =
1607            UnavailableModelFacts::project_allowlisted_model;
1608        let _: fn(&UnavailableModelFacts, &str) -> Vec<String> =
1609            UnavailableModelFacts::task_tags_for_model;
1610        let _: fn(&UnavailableModelFacts) -> Option<&'static str> =
1611            UnavailableModelFacts::bundled_guide_skill_id;
1612        let _: fn(&dyn ModelFactsPort, &str) -> String =
1613            |port, model| (port as &dyn ModelFactsPort).model_display_name(model);
1614
1615        let _: fn(&UnavailableUsageLedger, &Value) -> Result<()> =
1616            UnavailableUsageLedger::begin_graph_run;
1617        let _: fn(&UnavailableUsageLedger, &Value) -> Result<()> =
1618            UnavailableUsageLedger::record_graph_command;
1619        let _: fn(&UnavailableUsageLedger, &Value) -> Result<Value> =
1620            UnavailableUsageLedger::workflow_report;
1621        let _: fn(&dyn UsageLedgerPort, &Value) -> Result<()> =
1622            |port, payload| (port as &dyn UsageLedgerPort).begin_graph_run(payload);
1623
1624        struct RuntimeWitness;
1625        impl ResolvedRuntime for RuntimeWitness {
1626            fn fingerprint(&self) -> &str {
1627                "port-signature-witness"
1628            }
1629
1630            fn as_any(&self) -> &dyn std::any::Any {
1631                self
1632            }
1633        }
1634        let _: for<'a> fn(&'a RuntimeWitness) -> &'a str = RuntimeWitness::fingerprint;
1635        let _: for<'a> fn(&'a RuntimeWitness) -> &'a dyn std::any::Any = RuntimeWitness::as_any;
1636        let _: for<'a> fn(&'a dyn ResolvedRuntime) -> &'a str =
1637            |runtime| (runtime as &dyn ResolvedRuntime).fingerprint();
1638        let _: for<'a> fn(&'a dyn ResolvedRuntime) -> &'a dyn std::any::Any =
1639            |runtime| (runtime as &dyn ResolvedRuntime).as_any();
1640
1641        let _: fn(&UnavailableEffect) -> Vec<Value> = UnavailableEffect::runtime_descriptors;
1642        let _: fn(&UnavailableEffect, RuntimeKind, &str) -> Option<String> =
1643            UnavailableEffect::compatible_runtime_id;
1644        let _: fn(&UnavailableEffect, &str, RuntimeKind, &str) -> Result<Arc<dyn ResolvedRuntime>> =
1645            UnavailableEffect::resolve_runtime;
1646        let _: fn(&UnavailableEffect, &str, &str, &str) -> Result<String> =
1647            UnavailableEffect::actor_fingerprint;
1648        let _: fn(&UnavailableEffect, &str) -> Result<()> = UnavailableEffect::admit_strategy_cwd;
1649        let _: fn(
1650            &UnavailableEffect,
1651            &RunCommand,
1652            &str,
1653            &Arc<dyn ResolvedRuntime>,
1654            &Path,
1655            &Path,
1656            &mut StrategyEffectPermit,
1657        ) -> Result<Value> = UnavailableEffect::execute_script;
1658        let _: fn(
1659            &UnavailableEffect,
1660            &RunCommand,
1661            &str,
1662            &BindingValue,
1663            &mut StrategyEffectPermit,
1664            Option<&str>,
1665        ) -> Result<Value> = UnavailableEffect::execute_actor;
1666        let _: fn(&UnavailableEffect, &Value) -> Value = UnavailableEffect::predecessor_locator;
1667        let _: fn(&UnavailableEffect, &str, &Value) -> Result<Value> =
1668            UnavailableEffect::dispatch_lane_operation;
1669        let _: fn(&UnavailableEffect) -> String = UnavailableEffect::new_correlation_id;
1670        let _: fn(&UnavailableEffect, &Path, &str, bool) -> Result<()> =
1671            UnavailableEffect::record_run_stop;
1672        let _: fn(&dyn EffectPort, RuntimeKind, &str) -> Option<String> =
1673            |port, kind, requirement| {
1674                (port as &dyn EffectPort).compatible_runtime_id(kind, requirement)
1675            };
1676
1677        let error = ActorTurnError::dispatch_failed("port-signature-witness");
1678        let _: &str = error.detail();
1679        assert_eq!(
1680            error.to_string(),
1681            "port-signature-witness",
1682            "the port error keeps displaying its owner's detail",
1683        );
1684        let _: fn(&mut StrategyEffectPermit, &RunCommand, &str, &str) -> Result<()> =
1685            StrategyEffectPermit::consume;
1686        let _: fn(&str, &str, &str) -> Result<StrategyEffectPermit> = StrategyEffectPermit::issue;
1687    }
1688
1689    /// The compiler-side pin for the port data: every field, in the exact
1690    /// shape the host fills. A retyped or renamed field stops this compiling.
1691    #[test]
1692    fn port_struct_fields_are_frozen() {
1693        let target_facts = TargetFacts {
1694            status: Some("ready".to_owned()),
1695            model: Some("model-a".to_owned()),
1696            environment: Some("local".to_owned()),
1697            capabilities: vec!["conversationDriver:supported".to_owned()],
1698            readiness: Some("ready".to_owned()),
1699            reliability_class: Some("verified".to_owned()),
1700            latency_class: Some(1),
1701        };
1702        let price_facts = PriceFacts {
1703            input: 1.0,
1704            output: 2.0,
1705        };
1706        let filters = CandidateFilters {
1707            required_authority: vec!["conversation.read".to_owned()],
1708            required_skills: vec!["skill-a".to_owned()],
1709            required_capabilities: vec!["conversationDriver:supported".to_owned()],
1710            required_model: Some("model-a".to_owned()),
1711            required_environment: Some("local".to_owned()),
1712            required_readiness: Some("ready".to_owned()),
1713            membership_ids: vec!["membership:one".to_owned()],
1714            pinned_membership_ids: Vec::new(),
1715            preferred_skills: Vec::new(),
1716            preferred_capabilities: Vec::new(),
1717            preferred_model: None,
1718            preferred_environment: None,
1719            required_task: Some("frontend".to_owned()),
1720            preferred_task: None,
1721        };
1722        let failure = ActorTurnError::dispatch_failed("port-field-witness");
1723        let ports = HostPorts {
1724            profile: Arc::new(Mutex::new(Box::new(UnavailableProfileAuthority))),
1725            model: Arc::new(UnavailableModelFacts),
1726            usage: Arc::new(UnavailableUsageLedger),
1727            effect: Arc::new(UnavailableEffect),
1728        };
1729        let snapshots =
1730            project_profile_snapshots("conversation:g", &[], &ports.profile, &*ports.model);
1731        let receipt = route_receipt("conversation:g", &snapshots, &*ports.model, "policy-v1");
1732        let permit = StrategyEffectPermit::issue(
1733            "command",
1734            "port-stability-authorization-digest-with-exactly-64-characters-x",
1735            "port-stability-effect-fingerprint-with-exactly-64-characters-len",
1736        )
1737        .expect("a complete permit issues");
1738        assert!(receipt.is_object(), "the receipt keeps its document shape");
1739        assert_eq!(filters.clone(), filters, "the filter data stays comparable");
1740        assert_eq!(price_facts.input + price_facts.output, 3.0);
1741        assert_eq!(target_facts.latency_class, Some(1));
1742        assert_eq!(failure.detail(), "port-field-witness");
1743        assert!(format!("{permit:?}").starts_with("StrategyEffectPermit"));
1744        assert_eq!(
1745            ports
1746                .profile
1747                .lock()
1748                .unwrap_or_else(|poison| poison.into_inner())
1749                .coding_score("agent:one", "model-a"),
1750            None,
1751        );
1752        assert_eq!(
1753            HostPorts::unavailable()
1754                .usage
1755                .workflow_report(&Value::Null)
1756                .unwrap_err()
1757                .to_string(),
1758            "workflow_host_port_unavailable",
1759            "the uninstalled composition stays fail-closed",
1760        );
1761    }
1762
1763    /// The published member contract: the host answers these ports from
1764    /// outside this crate, so each trait stays object-safe, keeps the
1765    /// `Send`/`Send + Sync` bound the composition relies on, and keeps every
1766    /// method the host implements.
1767    #[test]
1768    fn port_traits_keep_their_published_bounds_and_members() {
1769        fn object_safe<T: ?Sized>() {}
1770        fn sendable<T: Send + ?Sized>() {}
1771        fn shareable<T: Send + Sync + ?Sized>() {}
1772
1773        object_safe::<dyn ProfileSnapshotAuthority>();
1774        object_safe::<dyn ModelFactsPort>();
1775        object_safe::<dyn UsageLedgerPort>();
1776        object_safe::<dyn ResolvedRuntime>();
1777        object_safe::<dyn EffectPort>();
1778        sendable::<dyn ProfileSnapshotAuthority>();
1779        shareable::<dyn ModelFactsPort>();
1780        shareable::<dyn UsageLedgerPort>();
1781        shareable::<dyn ResolvedRuntime>();
1782        shareable::<dyn EffectPort>();
1783
1784        struct WitnessProfileAuthority;
1785        struct WitnessModelFacts;
1786        struct WitnessUsageLedger;
1787        struct WitnessResolvedRuntime;
1788        struct WitnessEffect;
1789
1790        impl ProfileSnapshotAuthority for WitnessProfileAuthority {
1791            fn target_facts(&mut self, _agent_id: &str) -> Option<TargetFacts> {
1792                None
1793            }
1794
1795            fn model_price_usd_per_million_tokens(&mut self, _model: &str) -> Option<PriceFacts> {
1796                None
1797            }
1798
1799            fn coding_score(&mut self, _agent_id: &str, _model: &str) -> Option<i64> {
1800                None
1801            }
1802
1803            fn skill_names(&mut self, _agent_id: &str) -> Vec<String> {
1804                Vec::new()
1805            }
1806        }
1807
1808        impl ModelFactsPort for WitnessModelFacts {
1809            fn model_display_name(&self, model: &str) -> String {
1810                model.to_owned()
1811            }
1812
1813            fn project_allowlisted_model(&self, _model: &str) -> Option<Value> {
1814                None
1815            }
1816
1817            fn task_tags_for_model(&self, _model: &str) -> Vec<String> {
1818                Vec::new()
1819            }
1820
1821            fn bundled_guide_skill_id(&self) -> Option<&'static str> {
1822                None
1823            }
1824        }
1825
1826        impl UsageLedgerPort for WitnessUsageLedger {
1827            fn begin_graph_run(&self, _payload: &Value) -> Result<()> {
1828                Ok(())
1829            }
1830
1831            fn record_graph_command(&self, _payload: &Value) -> Result<()> {
1832                Ok(())
1833            }
1834
1835            fn workflow_report(&self, _payload: &Value) -> Result<Value> {
1836                Ok(Value::Null)
1837            }
1838        }
1839
1840        impl ResolvedRuntime for WitnessResolvedRuntime {
1841            fn fingerprint(&self) -> &str {
1842                "witness"
1843            }
1844
1845            fn as_any(&self) -> &dyn std::any::Any {
1846                self
1847            }
1848        }
1849
1850        impl EffectPort for WitnessEffect {
1851            fn runtime_descriptors(&self) -> Vec<Value> {
1852                Vec::new()
1853            }
1854
1855            fn compatible_runtime_id(
1856                &self,
1857                _kind: RuntimeKind,
1858                _version_requirement: &str,
1859            ) -> Option<String> {
1860                None
1861            }
1862
1863            fn resolve_runtime(
1864                &self,
1865                _runtime_id: &str,
1866                _kind: RuntimeKind,
1867                _version_requirement: &str,
1868            ) -> Result<Arc<dyn ResolvedRuntime>> {
1869                Ok(Arc::new(WitnessResolvedRuntime))
1870            }
1871
1872            fn actor_fingerprint(
1873                &self,
1874                _value_id: &str,
1875                _model: &str,
1876                _reasoning_effort: &str,
1877            ) -> Result<String> {
1878                Ok("witness".to_owned())
1879            }
1880
1881            fn actor_capabilities(&self, _value_id: &str) -> Result<Value> {
1882                Ok(json!({ "ok": true }))
1883            }
1884
1885            fn admit_strategy_cwd(&self, _cwd: &str) -> Result<()> {
1886                Ok(())
1887            }
1888
1889            fn execute_script(
1890                &self,
1891                _command: &RunCommand,
1892                _authorization_digest: &str,
1893                _runtime: &Arc<dyn ResolvedRuntime>,
1894                _revision_content: &Path,
1895                _runtime_state_root: &Path,
1896                _permit: &mut StrategyEffectPermit,
1897            ) -> Result<Value> {
1898                Ok(Value::Null)
1899            }
1900
1901            fn execute_actor(
1902                &self,
1903                _command: &RunCommand,
1904                _authorization_digest: &str,
1905                _binding: &BindingValue,
1906                _permit: &mut StrategyEffectPermit,
1907                _cwd: Option<&str>,
1908            ) -> Result<Value> {
1909                Ok(Value::Null)
1910            }
1911
1912            fn predecessor_locator(&self, _facts: &Value) -> Value {
1913                Value::Null
1914            }
1915
1916            fn dispatch_lane_operation(&self, _operation: &str, _params: &Value) -> Result<Value> {
1917                Ok(Value::Null)
1918            }
1919
1920            fn new_correlation_id(&self) -> String {
1921                String::new()
1922            }
1923
1924            fn record_run_stop(
1925                &self,
1926                _portable_root: &Path,
1927                _correlation_id: &str,
1928                _confirmed: bool,
1929            ) -> Result<()> {
1930                Ok(())
1931            }
1932        }
1933
1934        fn assert_host_ports_shape<T: Send + Sync + 'static>() {}
1935        assert_host_ports_shape::<HostPorts>();
1936        let ports = HostPorts {
1937            profile: Arc::new(Mutex::new(Box::new(WitnessProfileAuthority))),
1938            model: Arc::new(WitnessModelFacts),
1939            usage: Arc::new(WitnessUsageLedger),
1940            effect: Arc::new(WitnessEffect),
1941        };
1942        assert!(ports.effect.runtime_descriptors().is_empty());
1943        assert!(
1944            ports
1945                .effect
1946                .resolve_runtime("witness", RuntimeKind::new("node"), ">=0")
1947                .is_ok(),
1948        );
1949        drop(
1950            ports
1951                .profile
1952                .lock()
1953                .unwrap_or_else(|poison| poison.into_inner()),
1954        );
1955    }
1956
1957    /// The contract constants the runtime publishes into its wire documents.
1958    /// The host and a replay both read them, so a changed projection fails
1959    /// here as a value difference.
1960    #[test]
1961    fn port_wire_contracts_are_frozen() {
1962        let filters = CandidateFilters {
1963            required_authority: vec!["conversation.read".to_owned()],
1964            required_skills: vec!["skill-a".to_owned()],
1965            required_capabilities: vec!["conversationDriver:supported".to_owned()],
1966            required_model: Some("model-a".to_owned()),
1967            required_environment: Some("local".to_owned()),
1968            required_readiness: Some("ready".to_owned()),
1969            membership_ids: vec!["membership:one".to_owned()],
1970            pinned_membership_ids: vec!["membership:two".to_owned()],
1971            preferred_skills: Vec::new(),
1972            preferred_capabilities: Vec::new(),
1973            preferred_model: None,
1974            preferred_environment: None,
1975            required_task: Some("frontend".to_owned()),
1976            preferred_task: None,
1977        };
1978        let encoded = serde_json::to_value(&filters).expect("candidate filters serialize");
1979        let keys = encoded
1980            .as_object()
1981            .expect("candidate filters serialize as an object")
1982            .keys()
1983            .cloned()
1984            .collect::<BTreeSet<_>>();
1985        assert_eq!(
1986            keys,
1987            BTreeSet::from([
1988                "membershipIds".to_owned(),
1989                "pinnedMembershipIds".to_owned(),
1990                "preferredCapabilities".to_owned(),
1991                "preferredSkills".to_owned(),
1992                "requiredAuthority".to_owned(),
1993                "requiredCapabilities".to_owned(),
1994                "requiredEnvironment".to_owned(),
1995                "requiredModel".to_owned(),
1996                "requiredReadiness".to_owned(),
1997                "requiredSkills".to_owned(),
1998                "requiredTask".to_owned(),
1999            ]),
2000            "the candidate-filter wire contract changed",
2001        );
2002        assert!(
2003            !keys.contains("preferredModel")
2004                && !keys.contains("preferredEnvironment")
2005                && !keys.contains("preferredTask"),
2006            "absent preferences stay absent rather than serialize as null",
2007        );
2008
2009        let mut candidate = stability_snapshot("membership:one");
2010        candidate.capabilities = Vec::new();
2011        candidate.skills = Vec::new();
2012        candidate.authority = Vec::new();
2013        let receipt = route_receipt(
2014            "conversation:g",
2015            &[candidate],
2016            &UnavailableModelFacts,
2017            "policy-v1",
2018        );
2019        let object = receipt.as_object().expect("the route receipt is an object");
2020        assert_eq!(
2021            object.keys().cloned().collect::<BTreeSet<_>>(),
2022            BTreeSet::from([
2023                "candidates".to_owned(),
2024                "conversationId".to_owned(),
2025                "rankedMembershipIds".to_owned(),
2026                "sourceRevisions".to_owned(),
2027            ]),
2028            "the route-receipt wire contract changed",
2029        );
2030        assert_eq!(
2031            receipt.get("conversationId").and_then(Value::as_str),
2032            Some("conversation:g"),
2033        );
2034        assert_eq!(
2035            receipt
2036                .get("rankedMembershipIds")
2037                .and_then(Value::as_array)
2038                .map(Vec::len),
2039            Some(1),
2040        );
2041        let revisions = receipt
2042            .get("sourceRevisions")
2043            .and_then(Value::as_array)
2044            .expect("the source revisions are a list")
2045            .iter()
2046            .map(|entry| {
2047                (
2048                    entry
2049                        .get("source")
2050                        .and_then(Value::as_str)
2051                        .unwrap_or_default(),
2052                    entry
2053                        .get("revision")
2054                        .and_then(Value::as_str)
2055                        .unwrap_or_default(),
2056                )
2057            })
2058            .collect::<Vec<_>>();
2059        assert_eq!(
2060            revisions,
2061            vec![
2062                ("selectionPolicy", "policy-v1"),
2063                ("targets", "read-only-v1"),
2064                ("nativeCapabilities", "v0.0.1"),
2065                ("providerModelPricing", "catalog-v1"),
2066                ("agentIntelligenceCatalog", "catalog-v1"),
2067                ("skillHub", "request-snapshot-v1"),
2068                ("assistantWorkflowAuthoringBundle", "v1"),
2069            ],
2070            "the published source revisions changed",
2071        );
2072        let candidates = receipt
2073            .get("candidates")
2074            .and_then(Value::as_array)
2075            .expect("the candidates are a list");
2076        assert_eq!(candidates.len(), 1);
2077        assert_eq!(
2078            candidates[0]
2079                .as_object()
2080                .expect("one candidate is an object")
2081                .keys()
2082                .cloned()
2083                .collect::<BTreeSet<_>>(),
2084            BTreeSet::from([
2085                "authority".to_owned(),
2086                "capabilities".to_owned(),
2087                "codingScore".to_owned(),
2088                "environment".to_owned(),
2089                "inputPriceUsdPerMillionTokens".to_owned(),
2090                "intelligence".to_owned(),
2091                "latencyClass".to_owned(),
2092                "membershipId".to_owned(),
2093                "model".to_owned(),
2094                "outputPriceUsdPerMillionTokens".to_owned(),
2095                "profileRevision".to_owned(),
2096                "readiness".to_owned(),
2097                "reliabilityClass".to_owned(),
2098                "responsibility".to_owned(),
2099                "skills".to_owned(),
2100                "taskTags".to_owned(),
2101            ]),
2102            "the published candidate projection changed",
2103        );
2104    }
2105
2106    /// The crate root publishes the whole host-facing port surface in one
2107    /// place. A port that exists here but is not reachable from the
2108    /// composition is a contract break even when it compiles.
2109    #[test]
2110    fn port_surface_is_published_from_the_crate_root() {
2111        let published = CRATE_ROOT
2112            .lines()
2113            .skip_while(|line| !line.contains("pub use ports::{"))
2114            .skip(1)
2115            .take_while(|line| !line.contains("};"))
2116            .flat_map(|line| line.split(','))
2117            .map(|name| name.trim().to_owned())
2118            .filter(|name| !name.is_empty())
2119            .collect::<BTreeSet<_>>();
2120        assert_eq!(
2121            published,
2122            ROOT_REEXPORTED_PORT_ITEMS
2123                .iter()
2124                .map(|name| (*name).to_owned())
2125                .collect::<BTreeSet<_>>(),
2126            "the crate root re-exports of the workflow port surface changed",
2127        );
2128        let host_facing = scan_port_declarations(PORT_SOURCE)
2129            .into_iter()
2130            .filter(|declaration| {
2131                !UNPUBLISHED_PORT_ITEMS.contains(&declaration.item.render().as_str())
2132            })
2133            .map(|declaration| declaration.item.name)
2134            .collect::<BTreeSet<_>>();
2135        let mut missing = host_facing
2136            .difference(&published)
2137            .cloned()
2138            .collect::<Vec<_>>();
2139        missing.sort();
2140        assert!(
2141            missing.is_empty(),
2142            "these port items are not reachable from the crate root: {missing:?}",
2143        );
2144    }
2145}