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}
133
134/// Channel default invocation values.
135#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
136pub struct ChannelDefaults {
137    /// Default model.
138    pub model: Option<String>,
139    /// Default temperature.
140    pub temperature: Option<f32>,
141    /// Default reasoning effort.
142    pub reasoning_effort: Option<ReasoningEffort>,
143    /// Default max output tokens.
144    pub max_output_tokens: Option<u32>,
145    /// Default stop sequences.
146    pub stop: Vec<String>,
147    /// Default response format.
148    pub response_format: Option<ResponseFormat>,
149    /// Default continuation policy.
150    pub continuation_policy: ContinuationPolicy,
151    /// Default extensions.
152    pub extensions: BTreeMap<ExtensionKey, VersionedExtension>,
153}
154
155/// Which options a Channel accepts and which are immutable once a session exists.
156#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
157pub struct OptionPolicy {
158    /// Options callers may set on invocation.
159    pub supported_invocation: BTreeSet<ConfigOption>,
160    /// Options frozen for an existing external session (must match or fail).
161    pub session_immutable: BTreeSet<ConfigOption>,
162    /// Allowed extension key namespaces (exact keys).
163    /// Empty means **no extensions permitted** (D-023).
164    pub allowed_extension_keys: BTreeSet<ExtensionKey>,
165}
166
167impl OptionPolicy {
168    /// Direct-LLM Channel: typed invocation options open; no extensions by default.
169    pub fn direct_llm() -> Self {
170        let mut p = Self::default();
171        p.supported_invocation.extend([
172            ConfigOption::Model,
173            ConfigOption::Temperature,
174            ConfigOption::ReasoningEffort,
175            ConfigOption::MaxOutputTokens,
176            ConfigOption::Stop,
177            ConfigOption::ResponseFormat,
178            ConfigOption::ContinuationPolicy,
179            ConfigOption::Deadline,
180            ConfigOption::Extensions,
181        ]);
182        p
183    }
184
185    /// External-agent Channel: session/continuation focused; no model temperature by default.
186    pub fn external_agent() -> Self {
187        let mut p = Self::default();
188        p.supported_invocation.extend([
189            ConfigOption::ContinuationPolicy,
190            ConfigOption::Deadline,
191            ConfigOption::Extensions,
192        ]);
193        p
194    }
195
196    /// Permit exact extension keys (still requires [`ConfigOption::Extensions`] supported).
197    pub fn with_extension_keys(mut self, keys: impl IntoIterator<Item = ExtensionKey>) -> Self {
198        self.allowed_extension_keys.extend(keys);
199        self
200    }
201}
202
203/// Named configuration option for policy checks.
204#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
205pub enum ConfigOption {
206    /// Model id.
207    Model,
208    /// Temperature.
209    Temperature,
210    /// Reasoning effort.
211    ReasoningEffort,
212    /// Max output tokens.
213    MaxOutputTokens,
214    /// Stop sequences.
215    Stop,
216    /// Response format.
217    ResponseFormat,
218    /// Continuation policy.
219    ContinuationPolicy,
220    /// Deadline.
221    Deadline,
222    /// Extensions map.
223    Extensions,
224}
225
226/// Immutable effective configuration after merge.
227#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
228pub struct EffectiveConfig {
229    /// Model id.
230    pub model: Option<String>,
231    /// Temperature.
232    pub temperature: Option<f32>,
233    /// Reasoning effort.
234    pub reasoning_effort: Option<ReasoningEffort>,
235    /// Max output tokens.
236    pub max_output_tokens: Option<u32>,
237    /// Stop sequences.
238    pub stop: Vec<String>,
239    /// Response format.
240    pub response_format: Option<ResponseFormat>,
241    /// Continuation policy.
242    pub continuation_policy: ContinuationPolicy,
243    /// Deadline.
244    pub deadline: Option<Duration>,
245    /// Merged extensions.
246    pub extensions: BTreeMap<ExtensionKey, VersionedExtension>,
247    /// Effective session config when external-agent (else empty defaults).
248    pub session: SessionConfig,
249}
250
251/// Merge: Channel defaults <- session configuration <- permitted invocation overrides.
252pub fn merge_effective_config(
253    defaults: &ChannelDefaults,
254    session: Option<&SessionConfig>,
255    attached_session: Option<&SessionConfig>,
256    invocation: &InvocationConfig,
257    policy: &OptionPolicy,
258    extension_limits: &ExtensionLimits,
259) -> Result<EffectiveConfig, ConfigError> {
260    validate_extensions(&defaults.extensions, extension_limits, policy)?;
261    if let Some(s) = session {
262        validate_session_labels(s)?;
263        validate_extensions(&s.extensions, extension_limits, policy)?;
264    }
265    if let Some(s) = attached_session {
266        validate_session_labels(s)?;
267        validate_extensions(&s.extensions, extension_limits, policy)?;
268    }
269    validate_extensions(&invocation.extensions, extension_limits, policy)?;
270    validate_invocation_strings(invocation)?;
271
272    // Session immutability: requested session must match attached when present.
273    if let (Some(requested), Some(attached)) = (session, attached_session) {
274        check_session_match(requested, attached, policy)?;
275    }
276
277    let mut effective = EffectiveConfig {
278        model: defaults.model.clone(),
279        temperature: defaults.temperature,
280        reasoning_effort: defaults.reasoning_effort,
281        max_output_tokens: defaults.max_output_tokens,
282        stop: defaults.stop.clone(),
283        response_format: defaults.response_format.clone(),
284        continuation_policy: defaults.continuation_policy,
285        deadline: None,
286        extensions: defaults.extensions.clone(),
287        session: session.cloned().unwrap_or_default(),
288    };
289
290    // Session extensions layer on defaults (non-secret labels only).
291    if let Some(s) = session {
292        for (k, v) in &s.extensions {
293            effective.extensions.insert(k.clone(), v.clone());
294        }
295    }
296
297    apply_invocation(&mut effective, invocation, policy)?;
298
299    // Re-check extension serialized size after merge.
300    validate_extensions(&effective.extensions, extension_limits, policy)?;
301    Ok(effective)
302}
303
304fn apply_invocation(
305    effective: &mut EffectiveConfig,
306    invocation: &InvocationConfig,
307    policy: &OptionPolicy,
308) -> Result<(), ConfigError> {
309    if invocation.model.is_some() {
310        require_supported(policy, ConfigOption::Model)?;
311        effective.model = invocation.model.clone();
312    }
313    if invocation.temperature.is_some() {
314        require_supported(policy, ConfigOption::Temperature)?;
315        if let Some(t) = invocation.temperature {
316            if !(0.0..=2.0).contains(&t) {
317                return Err(ConfigError::InvalidNumeric("temperature"));
318            }
319        }
320        effective.temperature = invocation.temperature;
321    }
322    if invocation.reasoning_effort.is_some() {
323        require_supported(policy, ConfigOption::ReasoningEffort)?;
324        effective.reasoning_effort = invocation.reasoning_effort;
325    }
326    if invocation.max_output_tokens.is_some() {
327        require_supported(policy, ConfigOption::MaxOutputTokens)?;
328        effective.max_output_tokens = invocation.max_output_tokens;
329    }
330    if !invocation.stop.is_empty() {
331        require_supported(policy, ConfigOption::Stop)?;
332        effective.stop = invocation.stop.clone();
333    }
334    if invocation.response_format.is_some() {
335        require_supported(policy, ConfigOption::ResponseFormat)?;
336        effective.response_format = invocation.response_format.clone();
337    }
338    // Continuation policy always set on invocation; must be supported.
339    require_supported(policy, ConfigOption::ContinuationPolicy)?;
340    effective.continuation_policy = invocation.continuation_policy;
341
342    if invocation.deadline.is_some() {
343        require_supported(policy, ConfigOption::Deadline)?;
344        effective.deadline = invocation.deadline;
345    }
346    if !invocation.extensions.is_empty() {
347        require_supported(policy, ConfigOption::Extensions)?;
348        for (k, v) in &invocation.extensions {
349            effective.extensions.insert(k.clone(), v.clone());
350        }
351    }
352    Ok(())
353}
354
355fn require_supported(policy: &OptionPolicy, opt: ConfigOption) -> Result<(), ConfigError> {
356    if policy.supported_invocation.contains(&opt) {
357        Ok(())
358    } else {
359        Err(ConfigError::UnsupportedOption(opt))
360    }
361}
362
363fn check_session_match(
364    requested: &SessionConfig,
365    attached: &SessionConfig,
366    policy: &OptionPolicy,
367) -> Result<(), ConfigError> {
368    if policy.session_immutable.contains(&ConfigOption::Model) {
369        // Session labels treated as immutable fields when listed.
370    }
371    // Compare specialist/mode/permission when either side sets them.
372    if requested.specialist_profile != attached.specialist_profile
373        && (requested.specialist_profile.is_some() || attached.specialist_profile.is_some())
374    {
375        return Err(ConfigError::ImmutableSessionMismatch("specialist_profile"));
376    }
377    if requested.mode != attached.mode && (requested.mode.is_some() || attached.mode.is_some()) {
378        return Err(ConfigError::ImmutableSessionMismatch("mode"));
379    }
380    if requested.permission_profile != attached.permission_profile
381        && (requested.permission_profile.is_some() || attached.permission_profile.is_some())
382    {
383        return Err(ConfigError::ImmutableSessionMismatch("permission_profile"));
384    }
385    for (k, v) in &requested.extensions {
386        if let Some(existing) = attached.extensions.get(k) {
387            if existing != v {
388                return Err(ConfigError::ImmutableSessionMismatch("extension"));
389            }
390        }
391    }
392    Ok(())
393}
394
395fn validate_session_labels(session: &SessionConfig) -> Result<(), ConfigError> {
396    for label in [
397        &session.specialist_profile,
398        &session.mode,
399        &session.permission_profile,
400    ]
401    .into_iter()
402    .flatten()
403    {
404        if label.is_empty() || label.len() > 128 || label.chars().any(|c| c.is_control()) {
405            return Err(ConfigError::InvalidSessionLabel);
406        }
407    }
408    Ok(())
409}
410
411fn validate_invocation_strings(invocation: &InvocationConfig) -> Result<(), ConfigError> {
412    if let Some(m) = &invocation.model {
413        if m.is_empty() || m.len() > 256 || m.chars().any(|c| c.is_control()) {
414            return Err(ConfigError::InvalidModel);
415        }
416    }
417    for s in &invocation.stop {
418        if s.is_empty() || s.len() > 64 || s.chars().any(|c| c.is_control()) {
419            return Err(ConfigError::InvalidStop);
420        }
421    }
422    Ok(())
423}
424
425fn validate_extensions(
426    map: &BTreeMap<ExtensionKey, VersionedExtension>,
427    limits: &ExtensionLimits,
428    policy: &OptionPolicy,
429) -> Result<(), ConfigError> {
430    if map.len() > limits.max_keys {
431        return Err(ConfigError::TooManyExtensions {
432            count: map.len(),
433            max: limits.max_keys,
434        });
435    }
436    // D-023: empty allowlist denies all extensions (not unrestricted).
437    if !map.is_empty() && policy.allowed_extension_keys.is_empty() {
438        let first = map
439            .keys()
440            .next()
441            .map(|k| k.as_str().to_string())
442            .unwrap_or_default();
443        return Err(ConfigError::UnknownExtension(first));
444    }
445    let mut total = 0usize;
446    for (k, v) in map {
447        if k.as_str().len() > limits.max_key_bytes {
448            return Err(ConfigError::ExtensionKeyTooLong {
449                bytes: k.as_str().len(),
450                max: limits.max_key_bytes,
451            });
452        }
453        if !policy.allowed_extension_keys.contains(k) {
454            return Err(ConfigError::UnknownExtension(k.as_str().to_string()));
455        }
456        let depth = json_depth(&v.value);
457        if depth > limits.max_value_depth {
458            return Err(ConfigError::ExtensionTooDeep {
459                depth,
460                max: limits.max_value_depth,
461            });
462        }
463        let encoded =
464            serde_json::to_vec(&v.value).map_err(|_| ConfigError::ExtensionEncodeFailed)?;
465        total = total
466            .saturating_add(encoded.len())
467            .saturating_add(k.as_str().len());
468    }
469    if total > limits.max_serialized_bytes {
470        return Err(ConfigError::ExtensionsTooLarge {
471            bytes: total,
472            max: limits.max_serialized_bytes,
473        });
474    }
475    Ok(())
476}
477
478fn json_depth(value: &serde_json::Value) -> u32 {
479    match value {
480        serde_json::Value::Array(items) => 1 + items.iter().map(json_depth).max().unwrap_or(0),
481        serde_json::Value::Object(map) => 1 + map.values().map(json_depth).max().unwrap_or(0),
482        _ => 1,
483    }
484}
485
486/// Configuration construction / merge error.
487#[derive(Clone, Debug, Error, PartialEq)]
488pub enum ConfigError {
489    /// Empty extension key.
490    #[error("extension key must be non-empty")]
491    EmptyExtensionKey,
492    /// Extension key missing namespace separator.
493    #[error("extension key must be namespaced (contain '.')")]
494    ExtensionKeyMissingNamespace,
495    /// Extension key too long.
496    #[error("extension key bytes {bytes} exceeds max {max}")]
497    ExtensionKeyTooLong {
498        /// Actual.
499        bytes: usize,
500        /// Max.
501        max: usize,
502    },
503    /// Control character.
504    #[error("configuration string must not contain control characters")]
505    ControlCharacter,
506    /// Too many extensions.
507    #[error("extension count {count} exceeds max {max}")]
508    TooManyExtensions {
509        /// Actual.
510        count: usize,
511        /// Max.
512        max: usize,
513    },
514    /// Extension JSON too deep.
515    #[error("extension depth {depth} exceeds max {max}")]
516    ExtensionTooDeep {
517        /// Actual.
518        depth: u32,
519        /// Max.
520        max: u32,
521    },
522    /// Extensions aggregate too large.
523    #[error("extensions serialized bytes {bytes} exceed max {max}")]
524    ExtensionsTooLarge {
525        /// Actual.
526        bytes: usize,
527        /// Max.
528        max: usize,
529    },
530    /// Unknown extension key for policy.
531    #[error("unknown extension key {0}")]
532    UnknownExtension(String),
533    /// Extension encode failed.
534    #[error("extension JSON encode failed")]
535    ExtensionEncodeFailed,
536    /// Unsupported option for Channel.
537    #[error("unsupported configuration option: {0:?}")]
538    UnsupportedOption(ConfigOption),
539    /// Invalid numeric.
540    #[error("invalid numeric value for {0}")]
541    InvalidNumeric(&'static str),
542    /// Invalid model string.
543    #[error("invalid model string")]
544    InvalidModel,
545    /// Invalid stop sequence.
546    #[error("invalid stop sequence")]
547    InvalidStop,
548    /// Invalid session label.
549    #[error("invalid session configuration label")]
550    InvalidSessionLabel,
551    /// Immutable session setting mismatch.
552    #[error("immutable session setting mismatch: {0}")]
553    ImmutableSessionMismatch(&'static str),
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559
560    fn open_policy() -> OptionPolicy {
561        let mut p = OptionPolicy::default();
562        p.supported_invocation.extend([
563            ConfigOption::Model,
564            ConfigOption::Temperature,
565            ConfigOption::ContinuationPolicy,
566            ConfigOption::Deadline,
567            ConfigOption::Extensions,
568        ]);
569        p
570    }
571
572    #[test]
573    fn merge_precedence_invocation_over_defaults() {
574        let defaults = ChannelDefaults {
575            model: Some("base".into()),
576            temperature: Some(0.2),
577            continuation_policy: ContinuationPolicy::CallerControlled,
578            ..Default::default()
579        };
580        let inv = InvocationConfig {
581            model: Some("override".into()),
582            temperature: Some(0.7),
583            continuation_policy: ContinuationPolicy::InlineToolContinuation,
584            ..Default::default()
585        };
586        let eff = merge_effective_config(
587            &defaults,
588            None,
589            None,
590            &inv,
591            &open_policy(),
592            &ExtensionLimits::default(),
593        )
594        .unwrap();
595        assert_eq!(eff.model.as_deref(), Some("override"));
596        assert_eq!(eff.temperature, Some(0.7));
597        assert_eq!(
598            eff.continuation_policy,
599            ContinuationPolicy::InlineToolContinuation
600        );
601    }
602
603    #[test]
604    fn immutable_session_mismatch_fails() {
605        let requested = SessionConfig {
606            mode: Some("agent".into()),
607            ..Default::default()
608        };
609        let attached = SessionConfig {
610            mode: Some("ask".into()),
611            ..Default::default()
612        };
613        let err = merge_effective_config(
614            &ChannelDefaults::default(),
615            Some(&requested),
616            Some(&attached),
617            &InvocationConfig {
618                continuation_policy: ContinuationPolicy::CallerControlled,
619                ..Default::default()
620            },
621            &open_policy(),
622            &ExtensionLimits::default(),
623        )
624        .unwrap_err();
625        assert!(matches!(err, ConfigError::ImmutableSessionMismatch("mode")));
626    }
627
628    #[test]
629    fn extension_bounds() {
630        let limits = ExtensionLimits {
631            max_keys: 1,
632            max_key_bytes: 32,
633            max_value_depth: 2,
634            max_serialized_bytes: 64,
635        };
636        let k = ExtensionKey::try_new("ns.a", limits.max_key_bytes).unwrap();
637        let k2 = ExtensionKey::try_new("ns.b", limits.max_key_bytes).unwrap();
638        let mut inv = InvocationConfig::default();
639        inv.extensions.insert(
640            k,
641            VersionedExtension {
642                version: 1,
643                value: serde_json::json!(1),
644            },
645        );
646        inv.extensions.insert(
647            k2,
648            VersionedExtension {
649                version: 1,
650                value: serde_json::json!(2),
651            },
652        );
653        let policy = open_policy();
654        // max_keys=1 fires before empty-allowlist deny when two keys present.
655        let err = merge_effective_config(
656            &ChannelDefaults::default(),
657            None,
658            None,
659            &inv,
660            &policy,
661            &limits,
662        )
663        .unwrap_err();
664        assert!(matches!(err, ConfigError::TooManyExtensions { .. }));
665    }
666
667    /// D-023: empty allowed_extension_keys denies any extension.
668    #[test]
669    fn empty_extension_allowlist_denies() {
670        let limits = ExtensionLimits {
671            max_keys: 8,
672            max_key_bytes: 32,
673            max_value_depth: 2,
674            max_serialized_bytes: 256,
675        };
676        let k = ExtensionKey::try_new("ns.secret", limits.max_key_bytes).unwrap();
677        let mut inv = InvocationConfig::default();
678        inv.extensions.insert(
679            k,
680            VersionedExtension {
681                version: 1,
682                value: serde_json::json!({"x": 1}),
683            },
684        );
685        let mut policy = open_policy();
686        policy.allowed_extension_keys.clear();
687        let err = merge_effective_config(
688            &ChannelDefaults::default(),
689            None,
690            None,
691            &inv,
692            &policy,
693            &limits,
694        )
695        .unwrap_err();
696        assert!(matches!(err, ConfigError::UnknownExtension(_)));
697    }
698}