Skip to main content

monoloop_contracts/
config.rs

1//! Invocation, session, and effective configuration contracts.
2
3use crate::limits::ExtensionLimits;
4use serde::{Deserialize, Serialize};
5use std::collections::{BTreeMap, BTreeSet};
6use std::time::Duration;
7use thiserror::Error;
8
9/// How the runtime continues after model tool calls.
10#[derive(
11    Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
12)]
13pub enum ContinuationPolicy {
14    /// Runtime encodes tool results and continues the provider exchange.
15    InlineToolContinuation,
16    /// Runtime ends with `ContinuationRequired`; caller submits next transaction.
17    #[default]
18    CallerControlled,
19}
20
21/// Optional reasoning effort hint (provider-neutral label).
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
23pub enum ReasoningEffort {
24    /// Minimal effort.
25    Low,
26    /// Default effort.
27    Medium,
28    /// Higher effort.
29    High,
30}
31
32/// Optional response format constraint.
33#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
34pub enum ResponseFormat {
35    /// Unstructured text.
36    Text,
37    /// JSON object mode.
38    JsonObject,
39}
40
41/// Namespaced extension key (e.g. `openai.seed`).
42#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
43pub struct ExtensionKey(String);
44
45impl ExtensionKey {
46    /// Fallible constructor: non-empty, bounded, must contain a `.` namespace separator.
47    pub fn try_new(value: impl Into<String>, max_bytes: usize) -> Result<Self, ConfigError> {
48        let s = value.into();
49        if s.is_empty() {
50            return Err(ConfigError::EmptyExtensionKey);
51        }
52        if s.len() > max_bytes {
53            return Err(ConfigError::ExtensionKeyTooLong {
54                bytes: s.len(),
55                max: max_bytes,
56            });
57        }
58        if s.chars().any(|c| c.is_control()) {
59            return Err(ConfigError::ControlCharacter);
60        }
61        if !s.contains('.') {
62            return Err(ConfigError::ExtensionKeyMissingNamespace);
63        }
64        Ok(Self(s))
65    }
66
67    /// Borrow the key.
68    pub fn as_str(&self) -> &str {
69        &self.0
70    }
71}
72
73/// Versioned extension payload.
74#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
75pub struct VersionedExtension {
76    /// Schema version for this key.
77    pub version: u16,
78    /// JSON value (bounded at admission).
79    pub value: serde_json::Value,
80}
81
82/// Per-request invocation overrides (never secrets or endpoints).
83#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
84pub struct InvocationConfig {
85    /// Optional model id override.
86    pub model: Option<String>,
87    /// Optional temperature.
88    pub temperature: Option<f32>,
89    /// Optional reasoning effort.
90    pub reasoning_effort: Option<ReasoningEffort>,
91    /// Optional max output tokens.
92    pub max_output_tokens: Option<u32>,
93    /// Stop sequences.
94    pub stop: Vec<String>,
95    /// Optional response format.
96    pub response_format: Option<ResponseFormat>,
97    /// Continuation policy (required).
98    pub continuation_policy: ContinuationPolicy,
99    /// Optional deadline override.
100    pub deadline: Option<Duration>,
101    /// Namespaced extensions.
102    pub extensions: BTreeMap<ExtensionKey, VersionedExtension>,
103}
104
105impl Default for InvocationConfig {
106    fn default() -> Self {
107        Self {
108            model: None,
109            temperature: None,
110            reasoning_effort: None,
111            max_output_tokens: None,
112            stop: Vec::new(),
113            response_format: None,
114            continuation_policy: ContinuationPolicy::CallerControlled,
115            deadline: None,
116            extensions: BTreeMap::new(),
117        }
118    }
119}
120
121/// External-agent session configuration (no prompt, MCP URL, or secrets).
122#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
123pub struct SessionConfig {
124    /// Optional specialist profile label.
125    pub specialist_profile: Option<String>,
126    /// Optional mode label (agent/plan/ask…).
127    pub mode: Option<String>,
128    /// Optional permission profile label.
129    pub permission_profile: Option<String>,
130    /// Namespaced extensions.
131    pub extensions: BTreeMap<ExtensionKey, VersionedExtension>,
132    /// Opaque, connector-resolved target reference for this transaction —
133    /// e.g. which of several equivalent backends (same wire protocol,
134    /// different endpoint/credential) a `DirectLlm` Channel should route to.
135    /// Connector-only: never encoded onto the wire, never validated against
136    /// `OptionPolicy::allowed_extension_keys` (unlike `extensions`, which
137    /// *is* wire-visible and must be encoded or rejected — D-023). A
138    /// Connector that supports dynamic targets reads this from
139    /// `OpenConnection::session_config`; one that doesn't (a fixed
140    /// single-backend Channel) simply never looks at it.
141    pub connector_ref: Option<String>,
142}
143
144/// Channel default invocation values.
145#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
146pub struct ChannelDefaults {
147    /// Default model.
148    pub model: Option<String>,
149    /// Default temperature.
150    pub temperature: Option<f32>,
151    /// Default reasoning effort.
152    pub reasoning_effort: Option<ReasoningEffort>,
153    /// Default max output tokens.
154    pub max_output_tokens: Option<u32>,
155    /// Default stop sequences.
156    pub stop: Vec<String>,
157    /// Default response format.
158    pub response_format: Option<ResponseFormat>,
159    /// Default continuation policy.
160    pub continuation_policy: ContinuationPolicy,
161    /// Default extensions.
162    pub extensions: BTreeMap<ExtensionKey, VersionedExtension>,
163}
164
165/// Which options a Channel accepts and which are immutable once a session exists.
166#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
167pub struct OptionPolicy {
168    /// Options callers may set on invocation.
169    pub supported_invocation: BTreeSet<ConfigOption>,
170    /// Options frozen for an existing external session (must match or fail).
171    pub session_immutable: BTreeSet<ConfigOption>,
172    /// Allowed extension key namespaces (exact keys).
173    /// Empty means **no extensions permitted** (D-023).
174    pub allowed_extension_keys: BTreeSet<ExtensionKey>,
175}
176
177impl OptionPolicy {
178    /// Direct-LLM Channel: typed invocation options open; no extensions by default.
179    pub fn direct_llm() -> Self {
180        let mut p = Self::default();
181        p.supported_invocation.extend([
182            ConfigOption::Model,
183            ConfigOption::Temperature,
184            ConfigOption::ReasoningEffort,
185            ConfigOption::MaxOutputTokens,
186            ConfigOption::Stop,
187            ConfigOption::ResponseFormat,
188            ConfigOption::ContinuationPolicy,
189            ConfigOption::Deadline,
190            ConfigOption::Extensions,
191        ]);
192        p
193    }
194
195    /// External-agent Channel: session/continuation focused; no model temperature by default.
196    pub fn external_agent() -> Self {
197        let mut p = Self::default();
198        p.supported_invocation.extend([
199            ConfigOption::ContinuationPolicy,
200            ConfigOption::Deadline,
201            ConfigOption::Extensions,
202        ]);
203        p
204    }
205
206    /// Permit exact extension keys (still requires [`ConfigOption::Extensions`] supported).
207    pub fn with_extension_keys(mut self, keys: impl IntoIterator<Item = ExtensionKey>) -> Self {
208        self.allowed_extension_keys.extend(keys);
209        self
210    }
211}
212
213/// Named configuration option for policy checks.
214#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
215pub enum ConfigOption {
216    /// Model id.
217    Model,
218    /// Temperature.
219    Temperature,
220    /// Reasoning effort.
221    ReasoningEffort,
222    /// Max output tokens.
223    MaxOutputTokens,
224    /// Stop sequences.
225    Stop,
226    /// Response format.
227    ResponseFormat,
228    /// Continuation policy.
229    ContinuationPolicy,
230    /// Deadline.
231    Deadline,
232    /// Extensions map.
233    Extensions,
234}
235
236/// Immutable effective configuration after merge.
237#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
238pub struct EffectiveConfig {
239    /// Model id.
240    pub model: Option<String>,
241    /// Temperature.
242    pub temperature: Option<f32>,
243    /// Reasoning effort.
244    pub reasoning_effort: Option<ReasoningEffort>,
245    /// Max output tokens.
246    pub max_output_tokens: Option<u32>,
247    /// Stop sequences.
248    pub stop: Vec<String>,
249    /// Response format.
250    pub response_format: Option<ResponseFormat>,
251    /// Continuation policy.
252    pub continuation_policy: ContinuationPolicy,
253    /// Deadline.
254    pub deadline: Option<Duration>,
255    /// Merged extensions.
256    pub extensions: BTreeMap<ExtensionKey, VersionedExtension>,
257    /// Effective session config when external-agent (else empty defaults).
258    pub session: SessionConfig,
259}
260
261/// Merge: Channel defaults <- session configuration <- permitted invocation overrides.
262pub fn merge_effective_config(
263    defaults: &ChannelDefaults,
264    session: Option<&SessionConfig>,
265    attached_session: Option<&SessionConfig>,
266    invocation: &InvocationConfig,
267    policy: &OptionPolicy,
268    extension_limits: &ExtensionLimits,
269) -> Result<EffectiveConfig, ConfigError> {
270    validate_extensions(&defaults.extensions, extension_limits, policy)?;
271    if let Some(s) = session {
272        validate_session_labels(s)?;
273        validate_extensions(&s.extensions, extension_limits, policy)?;
274    }
275    if let Some(s) = attached_session {
276        validate_session_labels(s)?;
277        validate_extensions(&s.extensions, extension_limits, policy)?;
278    }
279    validate_extensions(&invocation.extensions, extension_limits, policy)?;
280    validate_invocation_strings(invocation)?;
281
282    // Session immutability: requested session must match attached when present.
283    if let (Some(requested), Some(attached)) = (session, attached_session) {
284        check_session_match(requested, attached, policy)?;
285    }
286
287    let mut effective = EffectiveConfig {
288        model: defaults.model.clone(),
289        temperature: defaults.temperature,
290        reasoning_effort: defaults.reasoning_effort,
291        max_output_tokens: defaults.max_output_tokens,
292        stop: defaults.stop.clone(),
293        response_format: defaults.response_format.clone(),
294        continuation_policy: defaults.continuation_policy,
295        deadline: None,
296        extensions: defaults.extensions.clone(),
297        session: session.cloned().unwrap_or_default(),
298    };
299
300    // Session extensions layer on defaults (non-secret labels only).
301    if let Some(s) = session {
302        for (k, v) in &s.extensions {
303            effective.extensions.insert(k.clone(), v.clone());
304        }
305    }
306
307    apply_invocation(&mut effective, invocation, policy)?;
308
309    // Re-check extension serialized size after merge.
310    validate_extensions(&effective.extensions, extension_limits, policy)?;
311    Ok(effective)
312}
313
314fn apply_invocation(
315    effective: &mut EffectiveConfig,
316    invocation: &InvocationConfig,
317    policy: &OptionPolicy,
318) -> Result<(), ConfigError> {
319    if invocation.model.is_some() {
320        require_supported(policy, ConfigOption::Model)?;
321        effective.model = invocation.model.clone();
322    }
323    if invocation.temperature.is_some() {
324        require_supported(policy, ConfigOption::Temperature)?;
325        if let Some(t) = invocation.temperature {
326            if !(0.0..=2.0).contains(&t) {
327                return Err(ConfigError::InvalidNumeric("temperature"));
328            }
329        }
330        effective.temperature = invocation.temperature;
331    }
332    if invocation.reasoning_effort.is_some() {
333        require_supported(policy, ConfigOption::ReasoningEffort)?;
334        effective.reasoning_effort = invocation.reasoning_effort;
335    }
336    if invocation.max_output_tokens.is_some() {
337        require_supported(policy, ConfigOption::MaxOutputTokens)?;
338        effective.max_output_tokens = invocation.max_output_tokens;
339    }
340    if !invocation.stop.is_empty() {
341        require_supported(policy, ConfigOption::Stop)?;
342        effective.stop = invocation.stop.clone();
343    }
344    if invocation.response_format.is_some() {
345        require_supported(policy, ConfigOption::ResponseFormat)?;
346        effective.response_format = invocation.response_format.clone();
347    }
348    // Continuation policy always set on invocation; must be supported.
349    require_supported(policy, ConfigOption::ContinuationPolicy)?;
350    effective.continuation_policy = invocation.continuation_policy;
351
352    if invocation.deadline.is_some() {
353        require_supported(policy, ConfigOption::Deadline)?;
354        effective.deadline = invocation.deadline;
355    }
356    if !invocation.extensions.is_empty() {
357        require_supported(policy, ConfigOption::Extensions)?;
358        for (k, v) in &invocation.extensions {
359            effective.extensions.insert(k.clone(), v.clone());
360        }
361    }
362    Ok(())
363}
364
365fn require_supported(policy: &OptionPolicy, opt: ConfigOption) -> Result<(), ConfigError> {
366    if policy.supported_invocation.contains(&opt) {
367        Ok(())
368    } else {
369        Err(ConfigError::UnsupportedOption(opt))
370    }
371}
372
373fn check_session_match(
374    requested: &SessionConfig,
375    attached: &SessionConfig,
376    policy: &OptionPolicy,
377) -> Result<(), ConfigError> {
378    if policy.session_immutable.contains(&ConfigOption::Model) {
379        // Session labels treated as immutable fields when listed.
380    }
381    // Compare specialist/mode/permission when either side sets them.
382    if requested.specialist_profile != attached.specialist_profile
383        && (requested.specialist_profile.is_some() || attached.specialist_profile.is_some())
384    {
385        return Err(ConfigError::ImmutableSessionMismatch("specialist_profile"));
386    }
387    if requested.mode != attached.mode && (requested.mode.is_some() || attached.mode.is_some()) {
388        return Err(ConfigError::ImmutableSessionMismatch("mode"));
389    }
390    if requested.permission_profile != attached.permission_profile
391        && (requested.permission_profile.is_some() || attached.permission_profile.is_some())
392    {
393        return Err(ConfigError::ImmutableSessionMismatch("permission_profile"));
394    }
395    for (k, v) in &requested.extensions {
396        if let Some(existing) = attached.extensions.get(k) {
397            if existing != v {
398                return Err(ConfigError::ImmutableSessionMismatch("extension"));
399            }
400        }
401    }
402    Ok(())
403}
404
405fn validate_session_labels(session: &SessionConfig) -> Result<(), ConfigError> {
406    for label in [
407        &session.specialist_profile,
408        &session.mode,
409        &session.permission_profile,
410    ]
411    .into_iter()
412    .flatten()
413    {
414        if label.is_empty() || label.len() > 128 || label.chars().any(|c| c.is_control()) {
415            return Err(ConfigError::InvalidSessionLabel);
416        }
417    }
418    Ok(())
419}
420
421fn validate_invocation_strings(invocation: &InvocationConfig) -> Result<(), ConfigError> {
422    if let Some(m) = &invocation.model {
423        if m.is_empty() || m.len() > 256 || m.chars().any(|c| c.is_control()) {
424            return Err(ConfigError::InvalidModel);
425        }
426    }
427    for s in &invocation.stop {
428        if s.is_empty() || s.len() > 64 || s.chars().any(|c| c.is_control()) {
429            return Err(ConfigError::InvalidStop);
430        }
431    }
432    Ok(())
433}
434
435fn validate_extensions(
436    map: &BTreeMap<ExtensionKey, VersionedExtension>,
437    limits: &ExtensionLimits,
438    policy: &OptionPolicy,
439) -> Result<(), ConfigError> {
440    if map.len() > limits.max_keys {
441        return Err(ConfigError::TooManyExtensions {
442            count: map.len(),
443            max: limits.max_keys,
444        });
445    }
446    // D-023: empty allowlist denies all extensions (not unrestricted).
447    if !map.is_empty() && policy.allowed_extension_keys.is_empty() {
448        let first = map
449            .keys()
450            .next()
451            .map(|k| k.as_str().to_string())
452            .unwrap_or_default();
453        return Err(ConfigError::UnknownExtension(first));
454    }
455    let mut total = 0usize;
456    for (k, v) in map {
457        if k.as_str().len() > limits.max_key_bytes {
458            return Err(ConfigError::ExtensionKeyTooLong {
459                bytes: k.as_str().len(),
460                max: limits.max_key_bytes,
461            });
462        }
463        if !policy.allowed_extension_keys.contains(k) {
464            return Err(ConfigError::UnknownExtension(k.as_str().to_string()));
465        }
466        let depth = json_depth(&v.value);
467        if depth > limits.max_value_depth {
468            return Err(ConfigError::ExtensionTooDeep {
469                depth,
470                max: limits.max_value_depth,
471            });
472        }
473        let encoded =
474            serde_json::to_vec(&v.value).map_err(|_| ConfigError::ExtensionEncodeFailed)?;
475        total = total
476            .saturating_add(encoded.len())
477            .saturating_add(k.as_str().len());
478    }
479    if total > limits.max_serialized_bytes {
480        return Err(ConfigError::ExtensionsTooLarge {
481            bytes: total,
482            max: limits.max_serialized_bytes,
483        });
484    }
485    Ok(())
486}
487
488fn json_depth(value: &serde_json::Value) -> u32 {
489    match value {
490        serde_json::Value::Array(items) => 1 + items.iter().map(json_depth).max().unwrap_or(0),
491        serde_json::Value::Object(map) => 1 + map.values().map(json_depth).max().unwrap_or(0),
492        _ => 1,
493    }
494}
495
496/// Configuration construction / merge error.
497#[derive(Clone, Debug, Error, PartialEq)]
498pub enum ConfigError {
499    /// Empty extension key.
500    #[error("extension key must be non-empty")]
501    EmptyExtensionKey,
502    /// Extension key missing namespace separator.
503    #[error("extension key must be namespaced (contain '.')")]
504    ExtensionKeyMissingNamespace,
505    /// Extension key too long.
506    #[error("extension key bytes {bytes} exceeds max {max}")]
507    ExtensionKeyTooLong {
508        /// Actual.
509        bytes: usize,
510        /// Max.
511        max: usize,
512    },
513    /// Control character.
514    #[error("configuration string must not contain control characters")]
515    ControlCharacter,
516    /// Too many extensions.
517    #[error("extension count {count} exceeds max {max}")]
518    TooManyExtensions {
519        /// Actual.
520        count: usize,
521        /// Max.
522        max: usize,
523    },
524    /// Extension JSON too deep.
525    #[error("extension depth {depth} exceeds max {max}")]
526    ExtensionTooDeep {
527        /// Actual.
528        depth: u32,
529        /// Max.
530        max: u32,
531    },
532    /// Extensions aggregate too large.
533    #[error("extensions serialized bytes {bytes} exceed max {max}")]
534    ExtensionsTooLarge {
535        /// Actual.
536        bytes: usize,
537        /// Max.
538        max: usize,
539    },
540    /// Unknown extension key for policy.
541    #[error("unknown extension key {0}")]
542    UnknownExtension(String),
543    /// Extension encode failed.
544    #[error("extension JSON encode failed")]
545    ExtensionEncodeFailed,
546    /// Unsupported option for Channel.
547    #[error("unsupported configuration option: {0:?}")]
548    UnsupportedOption(ConfigOption),
549    /// Invalid numeric.
550    #[error("invalid numeric value for {0}")]
551    InvalidNumeric(&'static str),
552    /// Invalid model string.
553    #[error("invalid model string")]
554    InvalidModel,
555    /// Invalid stop sequence.
556    #[error("invalid stop sequence")]
557    InvalidStop,
558    /// Invalid session label.
559    #[error("invalid session configuration label")]
560    InvalidSessionLabel,
561    /// Immutable session setting mismatch.
562    #[error("immutable session setting mismatch: {0}")]
563    ImmutableSessionMismatch(&'static str),
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569
570    fn open_policy() -> OptionPolicy {
571        let mut p = OptionPolicy::default();
572        p.supported_invocation.extend([
573            ConfigOption::Model,
574            ConfigOption::Temperature,
575            ConfigOption::ContinuationPolicy,
576            ConfigOption::Deadline,
577            ConfigOption::Extensions,
578        ]);
579        p
580    }
581
582    #[test]
583    fn merge_precedence_invocation_over_defaults() {
584        let defaults = ChannelDefaults {
585            model: Some("base".into()),
586            temperature: Some(0.2),
587            continuation_policy: ContinuationPolicy::CallerControlled,
588            ..Default::default()
589        };
590        let inv = InvocationConfig {
591            model: Some("override".into()),
592            temperature: Some(0.7),
593            continuation_policy: ContinuationPolicy::InlineToolContinuation,
594            ..Default::default()
595        };
596        let eff = merge_effective_config(
597            &defaults,
598            None,
599            None,
600            &inv,
601            &open_policy(),
602            &ExtensionLimits::default(),
603        )
604        .unwrap();
605        assert_eq!(eff.model.as_deref(), Some("override"));
606        assert_eq!(eff.temperature, Some(0.7));
607        assert_eq!(
608            eff.continuation_policy,
609            ContinuationPolicy::InlineToolContinuation
610        );
611    }
612
613    #[test]
614    fn immutable_session_mismatch_fails() {
615        let requested = SessionConfig {
616            mode: Some("agent".into()),
617            ..Default::default()
618        };
619        let attached = SessionConfig {
620            mode: Some("ask".into()),
621            ..Default::default()
622        };
623        let err = merge_effective_config(
624            &ChannelDefaults::default(),
625            Some(&requested),
626            Some(&attached),
627            &InvocationConfig {
628                continuation_policy: ContinuationPolicy::CallerControlled,
629                ..Default::default()
630            },
631            &open_policy(),
632            &ExtensionLimits::default(),
633        )
634        .unwrap_err();
635        assert!(matches!(err, ConfigError::ImmutableSessionMismatch("mode")));
636    }
637
638    #[test]
639    fn extension_bounds() {
640        let limits = ExtensionLimits {
641            max_keys: 1,
642            max_key_bytes: 32,
643            max_value_depth: 2,
644            max_serialized_bytes: 64,
645        };
646        let k = ExtensionKey::try_new("ns.a", limits.max_key_bytes).unwrap();
647        let k2 = ExtensionKey::try_new("ns.b", limits.max_key_bytes).unwrap();
648        let mut inv = InvocationConfig::default();
649        inv.extensions.insert(
650            k,
651            VersionedExtension {
652                version: 1,
653                value: serde_json::json!(1),
654            },
655        );
656        inv.extensions.insert(
657            k2,
658            VersionedExtension {
659                version: 1,
660                value: serde_json::json!(2),
661            },
662        );
663        let policy = open_policy();
664        // max_keys=1 fires before empty-allowlist deny when two keys present.
665        let err = merge_effective_config(
666            &ChannelDefaults::default(),
667            None,
668            None,
669            &inv,
670            &policy,
671            &limits,
672        )
673        .unwrap_err();
674        assert!(matches!(err, ConfigError::TooManyExtensions { .. }));
675    }
676
677    /// D-023: empty allowed_extension_keys denies any extension.
678    #[test]
679    fn empty_extension_allowlist_denies() {
680        let limits = ExtensionLimits {
681            max_keys: 8,
682            max_key_bytes: 32,
683            max_value_depth: 2,
684            max_serialized_bytes: 256,
685        };
686        let k = ExtensionKey::try_new("ns.secret", limits.max_key_bytes).unwrap();
687        let mut inv = InvocationConfig::default();
688        inv.extensions.insert(
689            k,
690            VersionedExtension {
691                version: 1,
692                value: serde_json::json!({"x": 1}),
693            },
694        );
695        let mut policy = open_policy();
696        policy.allowed_extension_keys.clear();
697        let err = merge_effective_config(
698            &ChannelDefaults::default(),
699            None,
700            None,
701            &inv,
702            &policy,
703            &limits,
704        )
705        .unwrap_err();
706        assert!(matches!(err, ConfigError::UnknownExtension(_)));
707    }
708}