Skip to main content

falsegreen_agent/
genui.rs

1//! Trusted, versioned presentation surfaces for the Agent terminal.
2//!
3//! This module deliberately contains no terminal control sequences and no
4//! executable callbacks. A model may propose this typed tree, but the host
5//! validates it, binds actions to host-owned operations, and invokes existing
6//! Agent/Core/MCP paths only after rechecking the rendered identity.
7
8use std::collections::{BTreeMap, BTreeSet};
9use std::io::{self, Write};
10use std::sync::{
11    Arc,
12    atomic::{AtomicU64, Ordering},
13};
14
15use serde::{Deserialize, Serialize};
16use serde_json::{Map, Value};
17use sha2::{Digest, Sha256};
18use thiserror::Error;
19use unicode_width::UnicodeWidthChar;
20use uuid::Uuid;
21
22use crate::event::{EventError, EventKind, EventStore};
23use crate::workspace::WorkspaceState;
24
25/// FalseGreen-native read-only state projections, re-exported under the
26/// existing GenUI namespace for integrations that own a surface host.
27pub mod native {
28    pub use crate::genui_native::*;
29}
30
31pub const PROTOCOL_ID: &str = "falsegreen.agent.genui.surface";
32pub const SCHEMA_ID: &str = "surface-v1";
33pub const PROTOCOL_MAJOR: u16 = 1;
34pub const PROTOCOL_MINOR: u16 = 0;
35pub const DEFAULT_MAX_DEPTH: usize = 32;
36pub const DEFAULT_MAX_COMPONENTS: usize = 512;
37pub const DEFAULT_MAX_ACTIONS: usize = 256;
38pub const DEFAULT_MAX_TEXT_BYTES: usize = 64 * 1024;
39pub const DEFAULT_MAX_ROWS: usize = 1_024;
40pub const DEFAULT_MAX_COLUMNS: usize = 128;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct ProtocolVersion {
45    pub major: u16,
46    pub minor: u16,
47}
48
49impl ProtocolVersion {
50    #[must_use]
51    pub const fn current() -> Self {
52        Self {
53            major: PROTOCOL_MAJOR,
54            minor: PROTOCOL_MINOR,
55        }
56    }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum UnsupportedComponentPolicy {
62    Reject,
63    Placeholder,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(deny_unknown_fields)]
68pub struct HostCapabilities {
69    pub protocol: ProtocolVersion,
70    pub forward_compatible_minor: bool,
71    pub supported_components: BTreeSet<String>,
72    pub supported_actions: BTreeSet<ActionKind>,
73    pub unsupported_component_policy: UnsupportedComponentPolicy,
74    pub max_depth: usize,
75    pub max_components: usize,
76    pub max_actions: usize,
77    pub terminal_width: usize,
78}
79
80impl Default for HostCapabilities {
81    fn default() -> Self {
82        Self {
83            protocol: ProtocolVersion::current(),
84            forward_compatible_minor: false,
85            supported_components: ComponentKind::all_names()
86                .into_iter()
87                .map(str::to_owned)
88                .collect(),
89            supported_actions: [
90                ActionKind::LocalPresentation,
91                ActionKind::FormValueUpdate,
92                ActionKind::Navigation,
93                ActionKind::McpTool,
94                ActionKind::Consequential,
95            ]
96            .into_iter()
97            .collect(),
98            unsupported_component_policy: UnsupportedComponentPolicy::Placeholder,
99            max_depth: DEFAULT_MAX_DEPTH,
100            max_components: DEFAULT_MAX_COMPONENTS,
101            max_actions: DEFAULT_MAX_ACTIONS,
102            terminal_width: 100,
103        }
104    }
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct NegotiatedCapabilities {
110    pub protocol: ProtocolVersion,
111    pub unsupported_component_policy: UnsupportedComponentPolicy,
112    pub supported_components: BTreeSet<String>,
113    pub supported_actions: BTreeSet<ActionKind>,
114    pub terminal_width: usize,
115}
116
117pub fn negotiate(
118    offered: &[ProtocolVersion],
119    host: &HostCapabilities,
120) -> Result<NegotiatedCapabilities, GenUiError> {
121    let version = offered
122        .iter()
123        .copied()
124        .filter(|version| version.major == host.protocol.major)
125        .filter(|version| {
126            version.minor <= host.protocol.minor
127                || host.forward_compatible_minor && version.minor >= host.protocol.minor
128        })
129        .max()
130        .ok_or(GenUiError::UnsupportedProtocol {
131            offered: offered.to_vec(),
132            supported: host.protocol,
133        })?;
134    Ok(NegotiatedCapabilities {
135        protocol: version,
136        unsupported_component_policy: host.unsupported_component_policy,
137        supported_components: host.supported_components.clone(),
138        supported_actions: host.supported_actions.clone(),
139        terminal_width: host.terminal_width.max(1),
140    })
141}
142
143#[derive(Debug, Error, Clone, PartialEq, Eq)]
144pub enum GenUiError {
145    #[error("unsupported surface protocol; offered {offered:?}, host supports {supported:?}")]
146    UnsupportedProtocol {
147        offered: Vec<ProtocolVersion>,
148        supported: ProtocolVersion,
149    },
150    #[error("surface protocol identity is invalid: {0}")]
151    InvalidProtocol(String),
152    #[error("surface validation failed: {0}")]
153    InvalidSurface(String),
154    #[error("unsupported component {component:?}")]
155    UnsupportedComponent { component: String },
156    #[error("unsupported action kind {0:?}")]
157    UnsupportedAction(ActionKind),
158    #[error("action binding is stale: {0}")]
159    StaleAction(String),
160    #[error("action binding does not match the rendered label or payload")]
161    BindingMismatch,
162    #[error("consequential action {0:?} was already activated")]
163    DuplicateAction(String),
164    #[error("MCP schema is unsupported: {0}")]
165    UnsupportedMcpSchema(String),
166    #[error("MCP submission is invalid: {0}")]
167    InvalidMcpSubmission(String),
168    #[error("action is not authorized by the host catalog: {0}")]
169    UnauthorizedAction(String),
170    #[error("host confirmation is required for this action")]
171    ConfirmationRequired,
172    #[error("GenUI session event could not be persisted: {0}")]
173    EventStore(String),
174}
175
176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
177#[serde(deny_unknown_fields)]
178pub struct Surface {
179    pub id: String,
180    pub protocol: ProtocolVersion,
181    pub schema: String,
182    pub root: Component,
183    #[serde(default, skip_serializing_if = "Vec::is_empty")]
184    pub actions: Vec<Action>,
185    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
186    pub metadata: BTreeMap<String, String>,
187}
188
189impl Surface {
190    #[must_use]
191    pub fn new(id: impl Into<String>, root: Component) -> Self {
192        Self {
193            id: id.into(),
194            protocol: ProtocolVersion::current(),
195            schema: SCHEMA_ID.to_owned(),
196            root,
197            actions: Vec::new(),
198            metadata: BTreeMap::new(),
199        }
200    }
201
202    pub fn validate(&self) -> Result<(), GenUiError> {
203        self.validate_with_catalog(&HostCapabilities::default(), &ActionCatalog::default())
204    }
205
206    pub fn validate_with(&self, host: &HostCapabilities) -> Result<(), GenUiError> {
207        self.validate_with_catalog(host, &ActionCatalog::default())
208    }
209
210    /// Validate only the structural/protocol portion of a Surface. This is
211    /// used by the G5 composition adapter before host-issued action handles
212    /// are checked against the real `ActionCatalog`; it never grants action
213    /// admission on its own.
214    pub fn validate_structure(&self, host: &HostCapabilities) -> Result<(), GenUiError> {
215        self.validate_shape(host)
216    }
217
218    /// Validate a model-proposed surface against the exact host-owned action
219    /// catalog. Shape validation alone is intentionally insufficient for
220    /// executable actions.
221    pub fn validate_with_catalog(
222        &self,
223        host: &HostCapabilities,
224        catalog: &ActionCatalog,
225    ) -> Result<(), GenUiError> {
226        self.validate_shape(host)?;
227        let surface_digest = self.binding_surface_digest()?;
228        for action in &self.actions {
229            catalog.validate_action(self, action, &surface_digest)?;
230        }
231        Ok(())
232    }
233
234    /// Alias used by integrations that make the authority boundary explicit.
235    pub fn validate_for_host(
236        &self,
237        host: &HostCapabilities,
238        catalog: &ActionCatalog,
239    ) -> Result<(), GenUiError> {
240        self.validate_with_catalog(host, catalog)
241    }
242
243    fn validate_shape(&self, host: &HostCapabilities) -> Result<(), GenUiError> {
244        validate_id("surface", &self.id)?;
245        if self.schema != SCHEMA_ID {
246            return Err(GenUiError::InvalidProtocol(format!(
247                "unknown schema identity {:?}",
248                self.schema
249            )));
250        }
251        if self.protocol.major != host.protocol.major
252            || (self.protocol.minor > host.protocol.minor && !host.forward_compatible_minor)
253        {
254            return Err(GenUiError::UnsupportedProtocol {
255                offered: vec![self.protocol],
256                supported: host.protocol,
257            });
258        }
259        // Component and action IDs share one surface-local namespace, and the
260        // surface ID is also reserved within that surface. This prevents raw
261        // IDs from becoming ambiguous at a host boundary.
262        let mut component_ids = BTreeSet::new();
263        let mut count = 0usize;
264        validate_component(
265            &self.root,
266            1,
267            &mut count,
268            host.max_depth,
269            host.max_components,
270            &mut component_ids,
271            host,
272        )?;
273        if component_ids.contains(&self.id) {
274            return Err(GenUiError::InvalidSurface(format!(
275                "surface/component ID collision {:?}",
276                self.id
277            )));
278        }
279        if self.actions.len() > host.max_actions {
280            return Err(GenUiError::InvalidSurface(format!(
281                "action count {} exceeds bound {}",
282                self.actions.len(),
283                host.max_actions
284            )));
285        }
286        let mut action_ids = BTreeSet::new();
287        for action in &self.actions {
288            if !action_ids.insert(action.id.clone()) {
289                return Err(GenUiError::InvalidSurface(format!(
290                    "duplicate action ID {:?}",
291                    action.id
292                )));
293            }
294            if component_ids.contains(&action.id) {
295                return Err(GenUiError::InvalidSurface(format!(
296                    "component/action ID collision {:?}",
297                    action.id
298                )));
299            }
300            if self.id == action.id {
301                return Err(GenUiError::InvalidSurface(format!(
302                    "surface/action ID collision {:?}",
303                    action.id
304                )));
305            }
306            action.validate(&self.id, host)?;
307        }
308        Ok(())
309    }
310
311    pub fn canonical_json(&self) -> Result<Vec<u8>, GenUiError> {
312        // Digesting a surface is an identity operation and does not itself
313        // grant execution authority. Host admission is performed by
314        // `validate_with_catalog` before activation.
315        self.validate_shape(&HostCapabilities::default())?;
316        canonical_json_bytes(self).map_err(|error| GenUiError::InvalidSurface(error.to_string()))
317    }
318
319    pub fn digest(&self) -> Result<String, GenUiError> {
320        // The executable binding digest intentionally covers the structural
321        // surface only. Each action's ID, label, kind, and state identity are
322        // validated independently so one mutated sibling can be suppressed
323        // without revoking every other valid action on the surface.
324        self.binding_surface_digest()
325    }
326
327    /// Identity of the structural surface that owns an executable action.
328    /// Action entries are validated independently so a single mutated action
329    /// can be revoked without hiding unchanged executable siblings.
330    fn binding_surface_digest(&self) -> Result<String, GenUiError> {
331        let mut structural = self.clone();
332        structural.actions.clear();
333        structural.canonical_json().map(|bytes| sha256_hex(&bytes))
334    }
335}
336
337#[derive(Debug, Clone, PartialEq, Serialize)]
338pub struct Component {
339    pub id: String,
340    #[serde(flatten)]
341    pub kind: ComponentKind,
342}
343
344impl<'de> Deserialize<'de> for Component {
345    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
346    where
347        D: serde::Deserializer<'de>,
348    {
349        let mut object = Map::<String, Value>::deserialize(deserializer)?;
350        let id = object
351            .remove("id")
352            .ok_or_else(|| serde::de::Error::missing_field("id"))?;
353        let id = String::deserialize(id).map_err(serde::de::Error::custom)?;
354        let kind =
355            ComponentKind::deserialize(Value::Object(object)).map_err(serde::de::Error::custom)?;
356        Ok(Self { id, kind })
357    }
358}
359
360impl Component {
361    #[must_use]
362    pub fn text(id: impl Into<String>, text: impl Into<String>) -> Self {
363        Self {
364            id: id.into(),
365            kind: ComponentKind::Text { text: text.into() },
366        }
367    }
368
369    #[must_use]
370    pub fn status(
371        id: impl Into<String>,
372        label: impl Into<String>,
373        value: impl Into<String>,
374    ) -> Self {
375        Self {
376            id: id.into(),
377            kind: ComponentKind::Status {
378                label: label.into(),
379                value: value.into(),
380                level: StatusLevel::Info,
381            },
382        }
383    }
384}
385
386#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
387#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
388pub enum ComponentKind {
389    Text {
390        text: String,
391    },
392    Markdown {
393        markdown: String,
394    },
395    Status {
396        label: String,
397        value: String,
398        level: StatusLevel,
399    },
400    Progress {
401        label: String,
402        current: u64,
403        total: Option<u64>,
404    },
405    Table {
406        columns: Vec<String>,
407        rows: Vec<Vec<String>>,
408    },
409    KeyValue {
410        entries: BTreeMap<String, String>,
411    },
412    Diff {
413        before: String,
414        after: String,
415    },
416    TestResults {
417        passed: u64,
418        failed: u64,
419        skipped: u64,
420        details: Vec<String>,
421    },
422    Form {
423        fields: Vec<FormField>,
424    },
425    Choice {
426        label: String,
427        options: Vec<ChoiceOption>,
428        selected: Option<String>,
429    },
430    Evidence {
431        title: String,
432        items: Vec<EvidenceItem>,
433    },
434    Timeline {
435        entries: Vec<TimelineEntry>,
436    },
437    Stack {
438        children: Vec<Component>,
439    },
440    Columns {
441        columns: Vec<Vec<Component>>,
442    },
443}
444
445impl ComponentKind {
446    #[must_use]
447    pub fn name(&self) -> &'static str {
448        match self {
449            Self::Text { .. } => "text",
450            Self::Markdown { .. } => "markdown",
451            Self::Status { .. } => "status",
452            Self::Progress { .. } => "progress",
453            Self::Table { .. } => "table",
454            Self::KeyValue { .. } => "key_value",
455            Self::Diff { .. } => "diff",
456            Self::TestResults { .. } => "test_results",
457            Self::Form { .. } => "form",
458            Self::Choice { .. } => "choice",
459            Self::Evidence { .. } => "evidence",
460            Self::Timeline { .. } => "timeline",
461            Self::Stack { .. } => "stack",
462            Self::Columns { .. } => "columns",
463        }
464    }
465
466    #[must_use]
467    pub fn all_names() -> Vec<&'static str> {
468        vec![
469            "text",
470            "markdown",
471            "status",
472            "progress",
473            "table",
474            "key_value",
475            "diff",
476            "test_results",
477            "form",
478            "choice",
479            "evidence",
480            "timeline",
481            "stack",
482            "columns",
483        ]
484    }
485}
486
487#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
488#[serde(rename_all = "snake_case")]
489pub enum StatusLevel {
490    Info,
491    Success,
492    Warning,
493    Error,
494}
495
496#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
497#[serde(deny_unknown_fields)]
498pub struct FormField {
499    pub id: String,
500    pub label: String,
501    pub field_type: FieldType,
502    pub required: bool,
503    #[serde(default, skip_serializing_if = "Option::is_none")]
504    pub description: Option<String>,
505    #[serde(default, skip_serializing_if = "Option::is_none")]
506    pub value: Option<Value>,
507    #[serde(default, skip_serializing_if = "Vec::is_empty")]
508    pub choices: Vec<String>,
509    /// Exact JSON values represented by `choices`.  The legacy display labels
510    /// remain strings for renderer compatibility; this parallel vector keeps
511    /// booleans and integers from being coerced through presentation text.
512    #[serde(default, skip_serializing_if = "Vec::is_empty")]
513    pub choice_values: Vec<Value>,
514    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
515    pub constraints: BTreeMap<String, Value>,
516}
517
518#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
519#[serde(rename_all = "snake_case")]
520pub enum FieldType {
521    String,
522    Number,
523    Integer,
524    Boolean,
525}
526
527#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
528#[serde(deny_unknown_fields)]
529pub struct ChoiceOption {
530    pub value: String,
531    pub label: String,
532}
533
534#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
535#[serde(deny_unknown_fields)]
536pub struct EvidenceItem {
537    pub label: String,
538    pub value: String,
539}
540
541#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
542#[serde(deny_unknown_fields)]
543pub struct TimelineEntry {
544    pub label: String,
545    pub detail: String,
546    pub state: TimelineState,
547}
548
549#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
550#[serde(rename_all = "snake_case")]
551pub enum TimelineState {
552    Pending,
553    Active,
554    Complete,
555    Failed,
556}
557
558#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
559#[serde(rename_all = "snake_case")]
560pub enum ActionKind {
561    LocalPresentation,
562    FormValueUpdate,
563    Navigation,
564    McpTool,
565    Consequential,
566}
567
568/// Explicit executable transport identity retained by every host-owned
569/// catalog binding. `Auto` is valid only while resolving an untrusted
570/// discovery value; it must never survive into an executable binding.
571#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
572#[serde(rename_all = "snake_case")]
573pub enum ActionSourceType {
574    Auto,
575    Mcp,
576    HostLocal,
577}
578
579/// Canonical executable compatibility table shared by every trust boundary.
580/// `Auto` is discovery-only and is intentionally absent from this table.
581/// Keeping this as the one predicate prevents a legacy binder, renderer, or
582/// durable replay path from inferring transport from an action kind.
583#[must_use]
584pub const fn action_kind_source_compatible(
585    action_kind: ActionKind,
586    source_type: ActionSourceType,
587) -> bool {
588    matches!(
589        (action_kind, source_type),
590        (ActionKind::McpTool, ActionSourceType::Mcp)
591            | (ActionKind::Consequential, ActionSourceType::Mcp)
592            | (ActionKind::Consequential, ActionSourceType::HostLocal)
593            | (ActionKind::Navigation, ActionSourceType::HostLocal)
594            | (ActionKind::FormValueUpdate, ActionSourceType::HostLocal)
595            | (ActionKind::LocalPresentation, ActionSourceType::HostLocal)
596    )
597}
598
599/// Model-visible action presentation. Executable authority is deliberately not
600/// serialized here; the opaque ID is resolved against the host catalog.
601#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
602#[serde(deny_unknown_fields)]
603pub struct Action {
604    pub id: String,
605    pub label: String,
606    pub kind: ActionKind,
607    pub state_digest: String,
608}
609
610/// Host-only binding. This type intentionally has no serde implementation.
611#[derive(Debug, Clone, PartialEq, Eq)]
612pub struct ResolvedActionBinding {
613    action_id: String,
614    action_kind: ActionKind,
615    source_type: ActionSourceType,
616    label_digest: String,
617    surface_id: String,
618    surface_digest: String,
619    state_digest: String,
620    state_generation: u64,
621    principal: String,
622    authorization_context: String,
623    session_id: String,
624    provider_id: String,
625    server_id: String,
626    tool_name: String,
627    remote_tool_name: String,
628    schema_digest: String,
629    policy_version: u64,
630    policy_digest: String,
631    requires_confirmation: bool,
632}
633
634impl ResolvedActionBinding {
635    #[must_use]
636    pub fn action_id(&self) -> &str {
637        &self.action_id
638    }
639    #[must_use]
640    pub fn action_kind(&self) -> ActionKind {
641        self.action_kind
642    }
643    #[must_use]
644    pub fn source_type(&self) -> ActionSourceType {
645        self.source_type
646    }
647    #[must_use]
648    pub fn label_digest(&self) -> &str {
649        &self.label_digest
650    }
651    #[must_use]
652    pub fn surface_id(&self) -> &str {
653        &self.surface_id
654    }
655    #[must_use]
656    pub fn surface_digest(&self) -> &str {
657        &self.surface_digest
658    }
659    #[must_use]
660    pub fn state_digest(&self) -> &str {
661        &self.state_digest
662    }
663    #[must_use]
664    pub fn state_generation(&self) -> u64 {
665        self.state_generation
666    }
667    #[must_use]
668    pub fn principal(&self) -> &str {
669        &self.principal
670    }
671    #[must_use]
672    pub fn authorization_context(&self) -> &str {
673        &self.authorization_context
674    }
675    #[must_use]
676    pub fn session_id(&self) -> &str {
677        &self.session_id
678    }
679    #[must_use]
680    pub fn provider_id(&self) -> &str {
681        &self.provider_id
682    }
683    #[must_use]
684    pub fn server_id(&self) -> &str {
685        &self.server_id
686    }
687    #[must_use]
688    pub fn tool_name(&self) -> &str {
689        &self.tool_name
690    }
691    #[must_use]
692    pub fn remote_tool_name(&self) -> &str {
693        &self.remote_tool_name
694    }
695    #[must_use]
696    pub fn schema_digest(&self) -> &str {
697        &self.schema_digest
698    }
699    #[must_use]
700    pub fn policy_version(&self) -> u64 {
701        self.policy_version
702    }
703    #[must_use]
704    pub fn policy_digest(&self) -> &str {
705        &self.policy_digest
706    }
707    #[must_use]
708    pub fn requires_confirmation(&self) -> bool {
709        self.requires_confirmation
710    }
711}
712
713#[derive(Debug, Clone)]
714pub struct HostConfirmation {
715    identity: Value,
716    payload: Value,
717}
718
719impl HostConfirmation {
720    pub(crate) fn identity(&self) -> &Value {
721        &self.identity
722    }
723
724    pub(crate) fn payload(&self) -> &Value {
725        &self.payload
726    }
727}
728
729/// Host-owned action bindings. A model-proposed action is never executable
730/// unless an exact opaque ID is present in this catalog.
731#[derive(Debug, Clone)]
732pub struct ActionCatalog {
733    bindings: BTreeMap<String, ResolvedActionBinding>,
734    current_states: BTreeMap<(String, String), HostStateIdentity>,
735    // Keys are populated only after the host has established or restored the
736    // corresponding row in EventStore.  Renderer admission uses this marker
737    // in addition to the in-memory identity so an unseeded catalog can never
738    // expose an executable action.
739    durable_state_keys: BTreeSet<(String, String)>,
740    action_owners: BTreeMap<String, String>,
741    /// Mutation-time authority token owned by this catalog. It is deliberately
742    /// process-local metadata rather than serialized model input.
743    generation: Arc<AtomicU64>,
744    /// For a staged clone, the live generation from which this catalog was
745    /// copied. Live catalogs keep this equal to `generation`.
746    base_generation: u64,
747    mutation_depth: u8,
748    mutation_changed: bool,
749}
750
751impl Default for ActionCatalog {
752    fn default() -> Self {
753        Self {
754            bindings: BTreeMap::new(),
755            current_states: BTreeMap::new(),
756            durable_state_keys: BTreeSet::new(),
757            action_owners: BTreeMap::new(),
758            generation: Arc::new(AtomicU64::new(1)),
759            base_generation: 1,
760            mutation_depth: 0,
761            mutation_changed: false,
762        }
763    }
764}
765
766#[derive(Debug, Clone, PartialEq, Eq)]
767struct HostStateIdentity {
768    generation: u64,
769    digest: String,
770    authorization_context: String,
771}
772
773impl ActionCatalog {
774    #[must_use]
775    pub fn generation(&self) -> u64 {
776        self.generation.load(Ordering::Acquire)
777    }
778
779    #[must_use]
780    pub(crate) fn base_generation(&self) -> u64 {
781        self.base_generation
782    }
783
784    /// Clone the live catalog into an isolated staging catalog. Mutations on
785    /// the clone advance only its local token; publication performs the one
786    /// live-generation increment after the final CAS succeeds.
787    #[must_use]
788    pub(crate) fn clone_for_staging(&self) -> Self {
789        let mut staged = self.clone();
790        let generation = self.generation();
791        staged.generation = Arc::new(AtomicU64::new(generation));
792        staged.base_generation = generation;
793        staged.mutation_depth = 0;
794        staged.mutation_changed = false;
795        staged
796    }
797
798    pub(crate) fn publish_staged(&mut self, staged: Self) {
799        let next_generation = self.generation().saturating_add(1);
800        // Preserve the live catalog's shared generation Arc. Ordinary clones
801        // intentionally share this token so they observe the publication and
802        // still detect an ABA mutation; only staging clones receive isolated
803        // tokens in `clone_for_staging`.
804        self.bindings = staged.bindings;
805        self.current_states = staged.current_states;
806        self.durable_state_keys = staged.durable_state_keys;
807        self.action_owners = staged.action_owners;
808        self.base_generation = next_generation;
809        self.mutation_depth = 0;
810        self.mutation_changed = false;
811        self.generation.store(next_generation, Ordering::Release);
812    }
813
814    fn bump_generation(&mut self) {
815        if self.mutation_depth > 0 {
816            self.mutation_changed = true;
817            return;
818        }
819        let _ = self
820            .generation
821            .fetch_update(Ordering::AcqRel, Ordering::Acquire, |value| {
822                Some(value.saturating_add(1))
823            });
824    }
825
826    fn with_mutation<R>(
827        &mut self,
828        operation: impl FnOnce(&mut Self) -> Result<R, GenUiError>,
829    ) -> Result<R, GenUiError> {
830        // Run the complete operation against an isolated snapshot.  A
831        // multi-step mutation (for example bind + state + remote identity)
832        // therefore has no observable effect until every step succeeds.
833        let original_generation = self.generation();
834        let live_catalog = original_generation == self.base_generation;
835        let original_bindings = self.bindings.clone();
836        let original_states = self.current_states.clone();
837        let original_durable_keys = self.durable_state_keys.clone();
838        let original_owners = self.action_owners.clone();
839        let mut staged = self.clone_for_staging();
840        staged.mutation_depth = 0;
841        staged.mutation_changed = false;
842        let result = operation(&mut staged);
843        let Ok(value) = result else {
844            // The live catalog and its shared generation token are untouched.
845            return result;
846        };
847        let changed = staged.bindings != original_bindings
848            || staged.current_states != original_states
849            || staged.durable_state_keys != original_durable_keys
850            || staged.action_owners != original_owners;
851        if changed {
852            self.bindings = staged.bindings;
853            self.current_states = staged.current_states;
854            self.durable_state_keys = staged.durable_state_keys;
855            self.action_owners = staged.action_owners;
856            if live_catalog {
857                self.base_generation = original_generation.saturating_add(1);
858            }
859            let _ = self
860                .generation
861                .fetch_update(Ordering::AcqRel, Ordering::Acquire, |value| {
862                    // The live token is still expected to be the snapshot
863                    // generation because publication is serialized by the
864                    // host guard.  Preserve monotonicity if a caller shares
865                    // a catalog clone and advances it independently.
866                    Some(value.max(original_generation).saturating_add(1))
867                });
868        }
869        self.mutation_depth = 0;
870        self.mutation_changed = false;
871        Ok(value)
872    }
873
874    pub fn validate_source_type(
875        action_kind: ActionKind,
876        source_type: ActionSourceType,
877    ) -> Result<(), GenUiError> {
878        if action_kind_source_compatible(action_kind, source_type) {
879            Ok(())
880        } else {
881            Err(GenUiError::UnauthorizedAction(format!(
882                "invalid action kind/source type pair: {action_kind:?}/{source_type:?}"
883            )))
884        }
885    }
886
887    #[must_use]
888    pub fn new_action_id() -> String {
889        format!("gui_action_{}", Uuid::new_v4().simple())
890    }
891
892    /// Bind an MCP action when the exposed namespace and the exact remote
893    /// tool identity are both available from trusted discovery. The durable
894    /// current state must already exist and match the action; this method can
895    /// never seed or advance that state.
896    #[allow(clippy::too_many_arguments)]
897    pub fn bind_mcp_action_with_remote(
898        &mut self,
899        store: &EventStore,
900        action: &Action,
901        surface_id: &str,
902        surface_digest: &str,
903        session_id: &str,
904        principal: &str,
905        provider_id: &str,
906        server_id: &str,
907        exposed_tool_name: &str,
908        remote_tool_name: &str,
909        schema_digest: &str,
910        requires_confirmation: bool,
911    ) -> Result<(), GenUiError> {
912        // Reject an invalid source/kind pair before reading or mutating any
913        // durable or in-memory authority. This is the public MCP boundary;
914        // local presentation actions must use the explicit host-local API.
915        Self::validate_source_type(action.kind, ActionSourceType::Mcp)?;
916        let Some((state_generation, state_digest, authorization_context)) = store
917            .genui_current_state(session_id, principal)
918            .map_err(event_error)?
919        else {
920            return Err(GenUiError::StaleAction(
921                "a durable trusted current state must exist before binding".to_owned(),
922            ));
923        };
924        if state_digest != action.state_digest {
925            return Err(GenUiError::StaleAction(
926                "action state digest does not match durable trusted state".to_owned(),
927            ));
928        }
929        validate_id("remote tool", remote_tool_name)?;
930        let policy_digest = sha256_hex(
931            format!(
932                "falsegreen.tool-policy.v1|{}|1",
933                if requires_confirmation {
934                    "consequential"
935                } else {
936                    "read_only"
937                }
938            )
939            .as_bytes(),
940        );
941        self.with_mutation(|catalog| {
942            catalog.bind_action_with_source(
943                action,
944                surface_id,
945                surface_digest,
946                session_id,
947                principal,
948                &authorization_context,
949                state_generation,
950                &state_digest,
951                provider_id,
952                server_id,
953                exposed_tool_name,
954                schema_digest,
955                1,
956                &policy_digest,
957                requires_confirmation,
958                ActionSourceType::Mcp,
959            )?;
960            catalog.set_current_state(
961                session_id,
962                principal,
963                state_generation,
964                &state_digest,
965                &authorization_context,
966            )?;
967            catalog.set_remote_tool_name(action.id.as_str(), remote_tool_name)
968        })
969    }
970
971    #[allow(clippy::too_many_arguments)]
972    pub(crate) fn bind_mcp_action_with_context(
973        &mut self,
974        action: &Action,
975        surface_id: &str,
976        surface_digest: &str,
977        session_id: &str,
978        principal: &str,
979        authorization_context: &str,
980        state_generation: u64,
981        state_digest: &str,
982        provider_id: &str,
983        server_id: &str,
984        tool_name: &str,
985        schema_digest: &str,
986        policy_version: u64,
987        policy_digest: &str,
988        requires_confirmation: bool,
989        source_type: ActionSourceType,
990    ) -> Result<(), GenUiError> {
991        // Compatibility is explicit at the call site.  This legacy name is
992        // retained only for source compatibility with in-crate G1-G4 code;
993        // it no longer infers HostLocal/MCP from ActionKind.
994        self.bind_action_with_source(
995            action,
996            surface_id,
997            surface_digest,
998            session_id,
999            principal,
1000            authorization_context,
1001            state_generation,
1002            state_digest,
1003            provider_id,
1004            server_id,
1005            tool_name,
1006            schema_digest,
1007            policy_version,
1008            policy_digest,
1009            requires_confirmation,
1010            source_type,
1011        )
1012    }
1013
1014    /// Public host-local binding boundary. The explicit source type is stored
1015    /// in the resulting executable binding and is never inferred from the
1016    /// provider/server strings.
1017    #[allow(clippy::too_many_arguments)]
1018    pub fn bind_host_local_action_with_context(
1019        &mut self,
1020        action: &Action,
1021        surface_id: &str,
1022        surface_digest: &str,
1023        session_id: &str,
1024        principal: &str,
1025        authorization_context: &str,
1026        state_generation: u64,
1027        state_digest: &str,
1028        provider_id: &str,
1029        server_id: &str,
1030        tool_name: &str,
1031        schema_digest: &str,
1032        policy_version: u64,
1033        policy_digest: &str,
1034        requires_confirmation: bool,
1035    ) -> Result<(), GenUiError> {
1036        self.bind_action_with_source(
1037            action,
1038            surface_id,
1039            surface_digest,
1040            session_id,
1041            principal,
1042            authorization_context,
1043            state_generation,
1044            state_digest,
1045            provider_id,
1046            server_id,
1047            tool_name,
1048            schema_digest,
1049            policy_version,
1050            policy_digest,
1051            requires_confirmation,
1052            ActionSourceType::HostLocal,
1053        )
1054    }
1055
1056    /// Bind a host-local action with an explicit source identity. The source
1057    /// type is stored in the executable binding so legacy execution paths
1058    /// cannot infer MCP merely from a provider/server name.
1059    #[allow(clippy::too_many_arguments)]
1060    pub(crate) fn bind_action_with_source(
1061        &mut self,
1062        action: &Action,
1063        surface_id: &str,
1064        surface_digest: &str,
1065        session_id: &str,
1066        principal: &str,
1067        authorization_context: &str,
1068        state_generation: u64,
1069        state_digest: &str,
1070        provider_id: &str,
1071        server_id: &str,
1072        tool_name: &str,
1073        schema_digest: &str,
1074        policy_version: u64,
1075        policy_digest: &str,
1076        requires_confirmation: bool,
1077        source_type: ActionSourceType,
1078    ) -> Result<(), GenUiError> {
1079        Self::validate_source_type(action.kind, source_type)?;
1080        action.validate(surface_id, &HostCapabilities::default())?;
1081        let label_digest = sha256_hex(normalize_action_label(&action.label)?.as_bytes());
1082        validate_digest("surface", surface_digest)?;
1083        validate_digest("schema", schema_digest)?;
1084        validate_digest("policy", policy_digest)?;
1085        if state_digest != action.state_digest {
1086            return Err(GenUiError::StaleAction(
1087                "host state digest does not match the presented action".to_owned(),
1088            ));
1089        }
1090        for (name, value) in [
1091            ("session", session_id),
1092            ("principal", principal),
1093            ("authorization context", authorization_context),
1094            ("provider", provider_id),
1095            ("server", server_id),
1096            ("tool", tool_name),
1097        ] {
1098            validate_id(name, value)?;
1099        }
1100        if requires_confirmation != (action.kind == ActionKind::Consequential) {
1101            return Err(GenUiError::UnauthorizedAction(
1102                "host policy and action kind disagree".to_owned(),
1103            ));
1104        }
1105        let state_key = (session_id.to_owned(), principal.to_owned());
1106        if let Some(current) = self.current_states.get(&state_key) {
1107            if state_generation > current.generation {
1108                return Err(GenUiError::StaleAction(
1109                    "binding cannot advance trusted state generation".to_owned(),
1110                ));
1111            }
1112            if state_generation == current.generation
1113                && (state_digest != current.digest
1114                    || authorization_context != current.authorization_context)
1115            {
1116                return Err(GenUiError::StaleAction(
1117                    "binding state identity does not match the current trusted state".to_owned(),
1118                ));
1119            }
1120        }
1121        self.bindings.insert(
1122            action.id.clone(),
1123            ResolvedActionBinding {
1124                action_id: action.id.clone(),
1125                action_kind: action.kind,
1126                source_type,
1127                label_digest,
1128                surface_id: surface_id.to_owned(),
1129                surface_digest: surface_digest.to_owned(),
1130                state_digest: state_digest.to_owned(),
1131                state_generation,
1132                principal: principal.to_owned(),
1133                authorization_context: authorization_context.to_owned(),
1134                session_id: session_id.to_owned(),
1135                provider_id: provider_id.to_owned(),
1136                server_id: server_id.to_owned(),
1137                tool_name: tool_name.to_owned(),
1138                remote_tool_name: tool_name.to_owned(),
1139                schema_digest: schema_digest.to_owned(),
1140                policy_version,
1141                policy_digest: policy_digest.to_owned(),
1142                requires_confirmation,
1143            },
1144        );
1145        self.action_owners
1146            .entry(action.id.clone())
1147            .or_insert_with(|| "__surface_root__".to_owned());
1148        self.bump_generation();
1149        Ok(())
1150    }
1151
1152    /// Update the trusted state/authorization snapshot used for activation.
1153    /// Generations are host-owned and cannot move backwards or change their
1154    /// digest in place. Model-proposed Surface state is never sufficient by
1155    /// itself.
1156    pub(crate) fn set_current_state(
1157        &mut self,
1158        session_id: &str,
1159        principal: &str,
1160        generation: u64,
1161        digest: &str,
1162        authorization_context: &str,
1163    ) -> Result<(), GenUiError> {
1164        validate_id("session", session_id)?;
1165        validate_id("principal", principal)?;
1166        validate_id("authorization context", authorization_context)?;
1167        if digest.is_empty() {
1168            return Err(GenUiError::StaleAction(
1169                "trusted state digest must not be empty".to_owned(),
1170            ));
1171        }
1172        let key = (session_id.to_owned(), principal.to_owned());
1173        if let Some(previous) = self.current_states.get(&key) {
1174            if generation < previous.generation {
1175                return Err(GenUiError::StaleAction(
1176                    "trusted state generation cannot rewind".to_owned(),
1177                ));
1178            }
1179            if generation == previous.generation
1180                && (digest != previous.digest
1181                    || authorization_context != previous.authorization_context)
1182            {
1183                return Err(GenUiError::StaleAction(
1184                    "trusted state identity cannot change without advancing generation".to_owned(),
1185                ));
1186            }
1187        }
1188        let next = HostStateIdentity {
1189            generation,
1190            digest: digest.to_owned(),
1191            authorization_context: authorization_context.to_owned(),
1192        };
1193        let changed =
1194            self.current_states.get(&key) != Some(&next) || !self.durable_state_keys.contains(&key);
1195        self.current_states.insert(key.clone(), next);
1196        // `set_current_state` is crate-private and exists for trusted host
1197        // transitions plus focused in-crate tests. Public callers can only
1198        // reach this through the durable transition/restore APIs below.
1199        self.durable_state_keys.insert(key);
1200        if changed {
1201            self.bump_generation();
1202        }
1203        Ok(())
1204    }
1205
1206    /// Rehydrate a trusted current-state snapshot after process restart. A
1207    /// durable state that is older than an already-advanced in-memory state
1208    /// is rejected rather than rewinding the catalog.
1209    pub fn restore_current_state(
1210        &mut self,
1211        store: &EventStore,
1212        session_id: &str,
1213        principal: &str,
1214    ) -> Result<bool, GenUiError> {
1215        let Some((generation, digest, authorization_context)) = store
1216            .genui_current_state(session_id, principal)
1217            .map_err(event_error)?
1218        else {
1219            return Ok(false);
1220        };
1221        self.set_current_state(
1222            session_id,
1223            principal,
1224            generation,
1225            &digest,
1226            &authorization_context,
1227        )?;
1228        self.durable_state_keys
1229            .insert((session_id.to_owned(), principal.to_owned()));
1230        Ok(true)
1231    }
1232
1233    /// Associate an executable action with the component subtree that owns
1234    /// it. The renderer uses this host-owned association to revoke only
1235    /// actions under an unsupported component.
1236    pub fn bind_action_owner(
1237        &mut self,
1238        action_id: &str,
1239        component_id: &str,
1240    ) -> Result<(), GenUiError> {
1241        if !self.bindings.contains_key(action_id) {
1242            return Err(GenUiError::UnauthorizedAction(
1243                "action ID is not present in host catalog".to_owned(),
1244            ));
1245        }
1246        validate_id("component", component_id)?;
1247        if let Some(existing) = self.action_owners.get(action_id)
1248            && existing != component_id
1249            && existing != "__surface_root__"
1250        {
1251            return Err(GenUiError::UnauthorizedAction(
1252                "action owner is already bound to a different component".to_owned(),
1253            ));
1254        }
1255        let changed = self.action_owners.get(action_id).map(String::as_str) != Some(component_id);
1256        self.action_owners
1257            .insert(action_id.to_owned(), component_id.to_owned());
1258        if changed {
1259            self.bump_generation();
1260        }
1261        Ok(())
1262    }
1263
1264    pub(crate) fn set_remote_tool_name(
1265        &mut self,
1266        action_id: &str,
1267        remote_tool_name: &str,
1268    ) -> Result<(), GenUiError> {
1269        validate_id("remote tool", remote_tool_name)?;
1270        let binding = self
1271            .bindings
1272            .get_mut(action_id)
1273            .ok_or_else(|| GenUiError::UnauthorizedAction("unknown action ID".to_owned()))?;
1274        if binding.remote_tool_name != remote_tool_name {
1275            binding.remote_tool_name = remote_tool_name.to_owned();
1276            self.bump_generation();
1277        }
1278        Ok(())
1279    }
1280
1281    pub fn validate_surface(
1282        &self,
1283        surface: &Surface,
1284        host: &HostCapabilities,
1285    ) -> Result<(), GenUiError> {
1286        surface.validate_shape(host)?;
1287        let digest = surface.binding_surface_digest()?;
1288        for action in &surface.actions {
1289            self.validate_action(surface, action, &digest)?;
1290        }
1291        Ok(())
1292    }
1293
1294    /// Commit the negotiated renderer admission to the executable catalog.
1295    /// Any action on a degraded/unsupported surface is revoked before it can
1296    /// be focused or activated.
1297    pub(crate) fn admit_rendered_surface(
1298        &mut self,
1299        surface: &Surface,
1300        capabilities: &NegotiatedCapabilities,
1301    ) -> Result<(), GenUiError> {
1302        self.with_mutation(|catalog| catalog.admit_rendered_surface_inner(surface, capabilities))
1303    }
1304
1305    fn admit_rendered_surface_inner(
1306        &mut self,
1307        surface: &Surface,
1308        capabilities: &NegotiatedCapabilities,
1309    ) -> Result<(), GenUiError> {
1310        let host = HostCapabilities {
1311            protocol: capabilities.protocol,
1312            supported_components: capabilities.supported_components.clone(),
1313            supported_actions: capabilities.supported_actions.clone(),
1314            unsupported_component_policy: capabilities.unsupported_component_policy,
1315            terminal_width: capabilities.terminal_width.max(1),
1316            ..HostCapabilities::default()
1317        };
1318        surface.validate_shape(&host)?;
1319        let surface_digest = surface.binding_surface_digest()?;
1320        let unsupported = unsupported_component_ids(&surface.root, &host.supported_components);
1321        for action in &surface.actions {
1322            let owned_by_unsupported =
1323                self.action_owners
1324                    .get(&action.id)
1325                    .map_or(!unsupported.is_empty(), |owner| {
1326                        if owner == "__surface_root__" {
1327                            unsupported.contains(&surface.root.id)
1328                        } else {
1329                            unsupported.iter().any(|root| {
1330                                root == owner || component_contains_id(&surface.root, root, owner)
1331                            })
1332                        }
1333                    });
1334            let has_valid_durable_state = self.bindings.get(&action.id).is_some_and(|binding| {
1335                self.durable_state_keys
1336                    .contains(&(binding.session_id.clone(), binding.principal.clone()))
1337                    && self.current_state_matches(binding)
1338            });
1339            if !capabilities.supported_actions.contains(&action.kind)
1340                || owned_by_unsupported
1341                || !has_valid_durable_state
1342                || self
1343                    .validate_action(surface, action, &surface_digest)
1344                    .is_err()
1345            {
1346                self.remove(&action.id);
1347            }
1348        }
1349        Ok(())
1350    }
1351
1352    /// Durable-state-aware renderer admission. This is the canonical path for
1353    /// surfaces backed by an EventStore: a row is read and compared to the
1354    /// host binding before an action is retained in the executable catalog.
1355    pub fn admit_rendered_surface_with_store(
1356        &mut self,
1357        surface: &Surface,
1358        capabilities: &NegotiatedCapabilities,
1359        store: &EventStore,
1360    ) -> Result<(), GenUiError> {
1361        self.with_mutation(|catalog| {
1362            catalog.admit_rendered_surface_with_store_inner(surface, capabilities, store)
1363        })
1364    }
1365
1366    fn admit_rendered_surface_with_store_inner(
1367        &mut self,
1368        surface: &Surface,
1369        capabilities: &NegotiatedCapabilities,
1370        store: &EventStore,
1371    ) -> Result<(), GenUiError> {
1372        // Rehydrate the durable rows first so this path works after process
1373        // restart even when the caller constructed a fresh catalog. Missing
1374        // or malformed rows are deliberately left unmarked and are revoked
1375        // by the admission pass below.
1376        for action in &surface.actions {
1377            let Some(binding) = self.bindings.get(&action.id).cloned() else {
1378                continue;
1379            };
1380            if let Some((generation, digest, authorization_context)) = store
1381                .genui_current_state(&binding.session_id, &binding.principal)
1382                .map_err(event_error)?
1383            {
1384                let _ = self.set_current_state(
1385                    &binding.session_id,
1386                    &binding.principal,
1387                    generation,
1388                    &digest,
1389                    &authorization_context,
1390                );
1391            }
1392        }
1393        self.admit_rendered_surface_inner(surface, capabilities)?;
1394        let surface_digest = surface.binding_surface_digest()?;
1395        let action_ids = surface
1396            .actions
1397            .iter()
1398            .map(|action| action.id.clone())
1399            .collect::<Vec<_>>();
1400        for action_id in action_ids {
1401            let valid = self.bindings.get(&action_id).is_some_and(|_| {
1402                surface
1403                    .actions
1404                    .iter()
1405                    .find(|action| action.id == action_id)
1406                    .is_some_and(|action| {
1407                        self.validate_action(surface, action, &surface_digest)
1408                            .is_ok()
1409                            && self
1410                                .validate_durable_current_action(store, &action_id)
1411                                .is_ok()
1412                    })
1413            });
1414            if !valid {
1415                self.remove(&action_id);
1416            } else if let Some(binding) = self.bindings.get(&action_id) {
1417                self.durable_state_keys
1418                    .insert((binding.session_id.clone(), binding.principal.clone()));
1419            }
1420        }
1421        Ok(())
1422    }
1423
1424    pub fn validate_action(
1425        &self,
1426        surface: &Surface,
1427        action: &Action,
1428        surface_digest: &str,
1429    ) -> Result<(), GenUiError> {
1430        action.validate(&surface.id, &HostCapabilities::default())?;
1431        let binding = self.bindings.get(&action.id).ok_or_else(|| {
1432            GenUiError::UnauthorizedAction("action ID is not present in host catalog".to_owned())
1433        })?;
1434        Self::validate_source_type(binding.action_kind, binding.source_type)?;
1435        let label = normalize_action_label(&action.label)?;
1436        if binding.action_id != action.id
1437            || binding.action_kind != action.kind
1438            || binding.label_digest != sha256_hex(label.as_bytes())
1439            || binding.surface_id != surface.id
1440            || binding.surface_digest != surface_digest
1441            || binding.state_digest != action.state_digest
1442            || label.is_empty()
1443            || binding.requires_confirmation != (action.kind == ActionKind::Consequential)
1444            || !self
1445                .current_states
1446                .get(&(binding.session_id.clone(), binding.principal.clone()))
1447                .is_some_and(|state| {
1448                    state.generation == binding.state_generation
1449                        && state.digest == binding.state_digest
1450                        && state.authorization_context == binding.authorization_context
1451                })
1452        {
1453            return Err(GenUiError::StaleAction(
1454                "host binding no longer matches rendered action".to_owned(),
1455            ));
1456        }
1457        Ok(())
1458    }
1459
1460    #[must_use]
1461    pub fn resolve(&self, action_id: &str) -> Option<&ResolvedActionBinding> {
1462        self.bindings.get(action_id)
1463    }
1464
1465    pub fn remove(&mut self, action_id: &str) -> bool {
1466        let owner_removed = self.action_owners.remove(action_id).is_some();
1467        let binding_removed = self.bindings.remove(action_id).is_some();
1468        if owner_removed || binding_removed {
1469            self.bump_generation();
1470        }
1471        binding_removed
1472    }
1473
1474    /// Clear all executable catalog authority in one mutation transaction.
1475    pub fn clear(&mut self) {
1476        if self.bindings.is_empty()
1477            && self.current_states.is_empty()
1478            && self.durable_state_keys.is_empty()
1479            && self.action_owners.is_empty()
1480        {
1481            return;
1482        }
1483        self.bindings.clear();
1484        self.current_states.clear();
1485        self.durable_state_keys.clear();
1486        self.action_owners.clear();
1487        self.bump_generation();
1488    }
1489
1490    #[must_use]
1491    pub fn digest(&self) -> String {
1492        let entries: Vec<Value> = self
1493            .bindings
1494            .values()
1495            .map(|b| {
1496                serde_json::json!({
1497                    "action_id": b.action_id,
1498                    "action_kind": b.action_kind,
1499                    "source_type": b.source_type,
1500                    "label_digest": b.label_digest,
1501                    "surface_id": b.surface_id,
1502                    "surface_digest": b.surface_digest,
1503                    "state_digest": b.state_digest,
1504                    "state_generation": b.state_generation,
1505                    "principal": b.principal,
1506                    "authorization_context": b.authorization_context,
1507                    "session_id": b.session_id,
1508                    "provider_id": b.provider_id,
1509                    "server_id": b.server_id,
1510                    "tool_name": b.tool_name,
1511                    "remote_tool_name": b.remote_tool_name,
1512                    "schema_digest": b.schema_digest,
1513                    "policy_version": b.policy_version,
1514                    "policy_digest": b.policy_digest,
1515                    "requires_confirmation": b.requires_confirmation,
1516                    "owner_component_id": self.action_owners.get(&b.action_id),
1517                })
1518            })
1519            .collect();
1520        digest_json(&entries)
1521    }
1522
1523    pub fn present_action(
1524        &self,
1525        store: &mut EventStore,
1526        session_id: &str,
1527        action_id: &str,
1528    ) -> Result<(), GenUiError> {
1529        let binding = self
1530            .bindings
1531            .get(action_id)
1532            .ok_or_else(|| GenUiError::UnauthorizedAction("unknown action ID".to_owned()))?;
1533        if binding.session_id != session_id {
1534            return Err(GenUiError::UnauthorizedAction(
1535                "session is not bound to this action".to_owned(),
1536            ));
1537        }
1538        if !self.current_state_matches(binding) {
1539            return Err(GenUiError::StaleAction("trusted state changed".to_owned()));
1540        }
1541        self.validate_durable_current_action(store, action_id)?;
1542        store
1543            .append_genui_lifecycle(
1544                session_id,
1545                EventKind::GenUiActionPresented,
1546                &identity_payload(binding, None),
1547            )
1548            .map(|_| ())
1549            .map_err(event_error)
1550    }
1551
1552    pub fn request_confirmation(
1553        &self,
1554        store: &mut EventStore,
1555        session_id: &str,
1556        action_id: &str,
1557        payload: &Value,
1558    ) -> Result<HostConfirmation, GenUiError> {
1559        let binding = self
1560            .bindings
1561            .get(action_id)
1562            .ok_or_else(|| GenUiError::UnauthorizedAction("unknown action ID".to_owned()))?;
1563        if !binding.requires_confirmation {
1564            return Err(GenUiError::UnauthorizedAction(
1565                "read-only action does not require confirmation".to_owned(),
1566            ));
1567        }
1568        if binding.session_id != session_id {
1569            return Err(GenUiError::UnauthorizedAction(
1570                "session is not bound to this action".to_owned(),
1571            ));
1572        }
1573        if !self.current_state_matches(binding) {
1574            return Err(GenUiError::StaleAction("trusted state changed".to_owned()));
1575        }
1576        self.validate_durable_current_action(store, action_id)?;
1577        let identity = identity_payload(binding, Some(payload));
1578        store
1579            .append_genui_lifecycle(
1580                session_id,
1581                EventKind::GenUiActionConfirmationRequested,
1582                &identity,
1583            )
1584            .map_err(event_error)?;
1585        Ok(HostConfirmation {
1586            identity,
1587            payload: payload.clone(),
1588        })
1589    }
1590
1591    pub(crate) fn append_confirmed_after_live_validation(
1592        &self,
1593        store: &mut EventStore,
1594        confirmation: &HostConfirmation,
1595    ) -> Result<(), GenUiError> {
1596        self.validate_confirmation_identity(confirmation)?;
1597        let action_id = confirmation
1598            .identity
1599            .get("action_id")
1600            .and_then(Value::as_str)
1601            .ok_or(GenUiError::ConfirmationRequired)?;
1602        if confirmation
1603            .identity
1604            .get("payload_digest")
1605            .and_then(Value::as_str)
1606            != Some(digest_json(&confirmation.payload).as_str())
1607        {
1608            return Err(GenUiError::ConfirmationRequired);
1609        }
1610        self.validate_durable_current_action(store, action_id)?;
1611        let session_id = confirmation
1612            .identity
1613            .get("session_id")
1614            .and_then(Value::as_str)
1615            .ok_or(GenUiError::ConfirmationRequired)?;
1616        store
1617            .append_genui_lifecycle(
1618                session_id,
1619                EventKind::GenUiActionConfirmed,
1620                &confirmation.identity,
1621            )
1622            .map(|_| ())
1623            .map_err(event_error)
1624    }
1625
1626    pub(crate) fn validate_confirmation_identity(
1627        &self,
1628        confirmation: &HostConfirmation,
1629    ) -> Result<&ResolvedActionBinding, GenUiError> {
1630        let session_id = confirmation
1631            .identity
1632            .get("session_id")
1633            .and_then(Value::as_str)
1634            .ok_or(GenUiError::ConfirmationRequired)?;
1635        let action_id = confirmation
1636            .identity
1637            .get("action_id")
1638            .and_then(Value::as_str)
1639            .ok_or(GenUiError::ConfirmationRequired)?;
1640        let binding = self
1641            .bindings
1642            .get(action_id)
1643            .ok_or(GenUiError::ConfirmationRequired)?;
1644        let expected = identity_payload(binding, None);
1645        let identity_matches = expected.as_object().is_some_and(|expected| {
1646            confirmation.identity.as_object().is_some_and(|actual| {
1647                expected
1648                    .iter()
1649                    .all(|(key, value)| actual.get(key) == Some(value))
1650            })
1651        });
1652        if binding.session_id != session_id
1653            || !identity_matches
1654            || confirmation
1655                .identity
1656                .get("payload_digest")
1657                .and_then(Value::as_str)
1658                .is_none()
1659        {
1660            return Err(GenUiError::ConfirmationRequired);
1661        }
1662        if !self.current_state_matches(binding) {
1663            return Err(GenUiError::ConfirmationRequired);
1664        }
1665        Ok(binding)
1666    }
1667
1668    pub fn identity_for(&self, action_id: &str, payload: &Value) -> Result<Value, GenUiError> {
1669        let binding = self
1670            .bindings
1671            .get(action_id)
1672            .ok_or_else(|| GenUiError::UnauthorizedAction("unknown action ID".to_owned()))?;
1673        Ok(identity_payload(binding, Some(payload)))
1674    }
1675
1676    fn current_state_matches(&self, binding: &ResolvedActionBinding) -> bool {
1677        self.current_states
1678            .get(&(binding.session_id.clone(), binding.principal.clone()))
1679            .is_some_and(|state| {
1680                state.generation == binding.state_generation
1681                    && state.digest == binding.state_digest
1682                    && state.authorization_context == binding.authorization_context
1683            })
1684    }
1685
1686    pub fn validate_current_action(&self, action_id: &str) -> Result<(), GenUiError> {
1687        let binding = self
1688            .bindings
1689            .get(action_id)
1690            .ok_or_else(|| GenUiError::UnauthorizedAction("unknown action ID".to_owned()))?;
1691        if self.current_state_matches(binding) {
1692            Ok(())
1693        } else {
1694            Err(GenUiError::StaleAction(
1695                "trusted state changed immediately before execution".to_owned(),
1696            ))
1697        }
1698    }
1699
1700    pub(crate) fn validate_durable_current_action(
1701        &self,
1702        store: &EventStore,
1703        action_id: &str,
1704    ) -> Result<(), GenUiError> {
1705        let binding = self
1706            .bindings
1707            .get(action_id)
1708            .ok_or_else(|| GenUiError::UnauthorizedAction("unknown action ID".to_owned()))?;
1709        let Some((generation, digest, authorization_context)) = store
1710            .genui_current_state(&binding.session_id, &binding.principal)
1711            .map_err(event_error)?
1712        else {
1713            return Err(GenUiError::StaleAction(
1714                "no durable trusted current state exists for this action".to_owned(),
1715            ));
1716        };
1717        if generation == binding.state_generation
1718            && digest == binding.state_digest
1719            && authorization_context == binding.authorization_context
1720        {
1721            Ok(())
1722        } else {
1723            Err(GenUiError::StaleAction(
1724                "durable trusted state changed immediately before execution".to_owned(),
1725            ))
1726        }
1727    }
1728
1729    /// Validate an action against a freshly derived host workspace identity at
1730    /// the final transport boundary. The durable row and the in-memory binding
1731    /// must agree on generation, digest, and authorization context, and the
1732    /// just-read filesystem identity must equal that same trusted row.
1733    pub(crate) fn validate_fresh_workspace_identity(
1734        &self,
1735        store: &EventStore,
1736        action_id: &str,
1737        fresh_digest: &str,
1738        fresh_authorization_context: &str,
1739    ) -> Result<(), GenUiError> {
1740        let binding = self
1741            .bindings
1742            .get(action_id)
1743            .ok_or_else(|| GenUiError::UnauthorizedAction("unknown action ID".to_owned()))?;
1744        let Some((generation, digest, authorization_context)) = store
1745            .genui_current_state(&binding.session_id, &binding.principal)
1746            .map_err(event_error)?
1747        else {
1748            return Err(GenUiError::StaleAction(
1749                "no durable trusted current state exists for this action".to_owned(),
1750            ));
1751        };
1752        if generation != binding.state_generation
1753            || digest != binding.state_digest
1754            || authorization_context != binding.authorization_context
1755            || digest != fresh_digest
1756            || authorization_context != fresh_authorization_context
1757        {
1758            return Err(GenUiError::StaleAction(
1759                "current workspace state changed before MCP transport".to_owned(),
1760            ));
1761        }
1762        Ok(())
1763    }
1764}
1765
1766impl Action {
1767    pub fn validate(&self, surface_id: &str, host: &HostCapabilities) -> Result<(), GenUiError> {
1768        validate_id("action", &self.id)?;
1769        if !self.id.starts_with("gui_action_") {
1770            return Err(GenUiError::InvalidSurface(
1771                "action IDs must be opaque host-issued IDs".to_owned(),
1772            ));
1773        }
1774        let label = normalize_action_label(&self.label)?;
1775        if label.trim().is_empty() || label.chars().count() > 512 {
1776            return Err(GenUiError::InvalidSurface(format!(
1777                "action {:?} has an empty or oversized label",
1778                self.id
1779            )));
1780        }
1781        if !host.supported_actions.contains(&self.kind) {
1782            return Err(GenUiError::UnsupportedAction(self.kind));
1783        }
1784        if surface_id.is_empty() {
1785            return Err(GenUiError::InvalidSurface(
1786                "action has no owning surface".to_owned(),
1787            ));
1788        }
1789        Ok(())
1790    }
1791}
1792
1793fn identity_payload(binding: &ResolvedActionBinding, payload: Option<&Value>) -> Value {
1794    let mut object = Map::new();
1795    for (key, value) in [
1796        ("action_id", Value::String(binding.action_id.clone())),
1797        (
1798            "action_kind",
1799            serde_json::to_value(binding.action_kind).expect("ActionKind is serializable"),
1800        ),
1801        (
1802            "source_type",
1803            serde_json::to_value(binding.source_type).expect("ActionSourceType is serializable"),
1804        ),
1805        ("label_digest", Value::String(binding.label_digest.clone())),
1806        ("surface_id", Value::String(binding.surface_id.clone())),
1807        (
1808            "surface_digest",
1809            Value::String(binding.surface_digest.clone()),
1810        ),
1811        ("state_digest", Value::String(binding.state_digest.clone())),
1812        (
1813            "state_generation",
1814            Value::Number(binding.state_generation.into()),
1815        ),
1816        ("session_id", Value::String(binding.session_id.clone())),
1817        ("principal", Value::String(binding.principal.clone())),
1818        (
1819            "authorization_context",
1820            Value::String(binding.authorization_context.clone()),
1821        ),
1822        ("provider_id", Value::String(binding.provider_id.clone())),
1823        ("server_id", Value::String(binding.server_id.clone())),
1824        ("tool_name", Value::String(binding.tool_name.clone())),
1825        (
1826            "remote_tool_name",
1827            Value::String(binding.remote_tool_name.clone()),
1828        ),
1829        (
1830            "schema_digest",
1831            Value::String(binding.schema_digest.clone()),
1832        ),
1833        (
1834            "policy_version",
1835            Value::Number(binding.policy_version.into()),
1836        ),
1837        (
1838            "policy_digest",
1839            Value::String(binding.policy_digest.clone()),
1840        ),
1841        (
1842            "schema_version",
1843            Value::Number(crate::event::GENUI_EVENT_SCHEMA_VERSION.into()),
1844        ),
1845        (
1846            "requires_confirmation",
1847            Value::Bool(binding.requires_confirmation),
1848        ),
1849    ] {
1850        object.insert(key.to_owned(), value);
1851    }
1852    if let Some(payload) = payload {
1853        object.insert(
1854            "payload_digest".to_owned(),
1855            Value::String(digest_json(payload)),
1856        );
1857    }
1858    Value::Object(object)
1859}
1860
1861fn event_error(error: EventError) -> GenUiError {
1862    GenUiError::EventStore(error.to_string())
1863}
1864
1865fn validate_component(
1866    component: &Component,
1867    depth: usize,
1868    count: &mut usize,
1869    max_depth: usize,
1870    max_components: usize,
1871    ids: &mut BTreeSet<String>,
1872    host: &HostCapabilities,
1873) -> Result<(), GenUiError> {
1874    if depth > max_depth {
1875        return Err(GenUiError::InvalidSurface(format!(
1876            "component nesting exceeds bound {max_depth}"
1877        )));
1878    }
1879    *count = count.saturating_add(1);
1880    if *count > max_components {
1881        return Err(GenUiError::InvalidSurface(format!(
1882            "component count exceeds bound {max_components}"
1883        )));
1884    }
1885    validate_id("component", &component.id)?;
1886    if !ids.insert(component.id.clone()) {
1887        return Err(GenUiError::InvalidSurface(format!(
1888            "duplicate component ID {:?}",
1889            component.id
1890        )));
1891    }
1892    let name = component.kind.name();
1893    if !host.supported_components.contains(name)
1894        && host.unsupported_component_policy == UnsupportedComponentPolicy::Reject
1895    {
1896        return Err(GenUiError::UnsupportedComponent {
1897            component: name.to_owned(),
1898        });
1899    }
1900    validate_kind(&component.kind, host.max_columns())?;
1901    match &component.kind {
1902        ComponentKind::Stack { children } => {
1903            for child in children {
1904                validate_component(
1905                    child,
1906                    depth + 1,
1907                    count,
1908                    max_depth,
1909                    max_components,
1910                    ids,
1911                    host,
1912                )?;
1913            }
1914        }
1915        ComponentKind::Columns { columns } => {
1916            for column in columns {
1917                for child in column {
1918                    validate_component(
1919                        child,
1920                        depth + 1,
1921                        count,
1922                        max_depth,
1923                        max_components,
1924                        ids,
1925                        host,
1926                    )?;
1927                }
1928            }
1929        }
1930        _ => {}
1931    }
1932    Ok(())
1933}
1934
1935impl HostCapabilities {
1936    fn max_columns(&self) -> usize {
1937        DEFAULT_MAX_COLUMNS
1938    }
1939}
1940
1941fn validate_kind(kind: &ComponentKind, max_columns: usize) -> Result<(), GenUiError> {
1942    let mut text_bytes = 0usize;
1943    let mut add_text = |text: &str| {
1944        text_bytes = text_bytes.saturating_add(text.len());
1945        text_bytes <= DEFAULT_MAX_TEXT_BYTES
1946    };
1947    let valid = match kind {
1948        ComponentKind::Text { text } => add_text(text),
1949        ComponentKind::Markdown { markdown } => add_text(markdown),
1950        ComponentKind::Status { label, value, .. } => add_text(label) && add_text(value),
1951        ComponentKind::Progress {
1952            label,
1953            total,
1954            current,
1955        } => add_text(label) && total.is_none_or(|total| *current <= total),
1956        ComponentKind::Table { columns, rows } => {
1957            columns.len() <= max_columns
1958                && rows.len() <= DEFAULT_MAX_ROWS
1959                && rows
1960                    .iter()
1961                    .all(|row| row.len() == columns.len() && row.iter().all(|cell| add_text(cell)))
1962                && columns.iter().all(|column| add_text(column))
1963        }
1964        ComponentKind::KeyValue { entries } => {
1965            entries.len() <= DEFAULT_MAX_ROWS
1966                && entries
1967                    .iter()
1968                    .all(|(key, value)| add_text(key) && add_text(value))
1969        }
1970        ComponentKind::Diff { before, after } => add_text(before) && add_text(after),
1971        ComponentKind::TestResults { details, .. } => {
1972            details.len() <= DEFAULT_MAX_ROWS && details.iter().all(|detail| add_text(detail))
1973        }
1974        ComponentKind::Form { fields } => {
1975            fields.len() <= DEFAULT_MAX_ROWS
1976                && fields.iter().all(|field| {
1977                    validate_id("field", &field.id).is_ok()
1978                        && add_text(&field.label)
1979                        && field
1980                            .description
1981                            .as_ref()
1982                            .is_none_or(|description| add_text(description))
1983                        && field.choices.len() <= max_columns
1984                        && field.choices.iter().all(|choice| add_text(choice))
1985                        && (field.choice_values.is_empty()
1986                            || (field.choice_values.len() == field.choices.len()
1987                                && field
1988                                    .choice_values
1989                                    .iter()
1990                                    .all(|value| validate_value_size(value).is_ok())))
1991                        && field
1992                            .value
1993                            .as_ref()
1994                            .is_none_or(|value| validate_value_size(value).is_ok())
1995                        && field.constraints.keys().all(|key| {
1996                            matches!(
1997                                key.as_str(),
1998                                "minLength" | "maxLength" | "minimum" | "maximum" | "pattern"
1999                            )
2000                        })
2001                })
2002        }
2003        ComponentKind::Choice {
2004            label,
2005            options,
2006            selected,
2007        } => {
2008            add_text(label)
2009                && options.len() <= DEFAULT_MAX_ROWS
2010                && options
2011                    .iter()
2012                    .all(|option| add_text(&option.value) && add_text(&option.label))
2013                && selected
2014                    .as_ref()
2015                    .is_none_or(|value| options.iter().any(|option| &option.value == value))
2016        }
2017        ComponentKind::Evidence { title, items } => {
2018            add_text(title)
2019                && items.len() <= DEFAULT_MAX_ROWS
2020                && items
2021                    .iter()
2022                    .all(|item| add_text(&item.label) && add_text(&item.value))
2023        }
2024        ComponentKind::Timeline { entries } => {
2025            entries.len() <= DEFAULT_MAX_ROWS
2026                && entries
2027                    .iter()
2028                    .all(|entry| add_text(&entry.label) && add_text(&entry.detail))
2029        }
2030        ComponentKind::Stack { children } => children.len() <= DEFAULT_MAX_ROWS,
2031        ComponentKind::Columns { columns } => {
2032            columns.len() <= max_columns
2033                && columns
2034                    .iter()
2035                    .all(|column| column.len() <= DEFAULT_MAX_ROWS)
2036        }
2037    };
2038    if valid && text_bytes <= DEFAULT_MAX_TEXT_BYTES {
2039        Ok(())
2040    } else {
2041        Err(GenUiError::InvalidSurface(format!(
2042            "component payload exceeds bounded size or has invalid shape for {}",
2043            kind.name()
2044        )))
2045    }
2046}
2047
2048fn validate_id(label: &str, value: &str) -> Result<(), GenUiError> {
2049    if value.is_empty()
2050        || value.len() > 128
2051        || value.bytes().any(|byte| {
2052            !(byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b':'))
2053        })
2054    {
2055        Err(GenUiError::InvalidSurface(format!(
2056            "{label} ID {:?} is not a stable bounded identifier",
2057            value
2058        )))
2059    } else {
2060        Ok(())
2061    }
2062}
2063
2064fn validate_digest(label: &str, value: &str) -> Result<(), GenUiError> {
2065    if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
2066        Err(GenUiError::InvalidSurface(format!(
2067            "{label} digest is invalid"
2068        )))
2069    } else {
2070        Ok(())
2071    }
2072}
2073
2074fn is_bidi_format(character: char) -> bool {
2075    matches!(
2076        character,
2077        '\u{061c}'
2078            | '\u{200e}'
2079            | '\u{200f}'
2080            | '\u{200b}'
2081            | '\u{200c}'
2082            | '\u{200d}'
2083            | '\u{202a}'..='\u{202e}'
2084            | '\u{2060}'
2085            | '\u{2066}'..='\u{2069}'
2086            | '\u{feff}'
2087    )
2088}
2089
2090fn is_zero_width_format(character: char) -> bool {
2091    matches!(
2092        character,
2093        '\u{200b}' | '\u{200c}' | '\u{200d}' | '\u{2028}' | '\u{2029}' | '\u{2060}' | '\u{feff}'
2094    )
2095}
2096
2097/// Return the exact inert display representation bound into action identity.
2098/// Executable labels reject all controls, line/layout separators, and bidi
2099/// formatting characters rather than allowing the renderer and digest to
2100/// disagree.
2101fn normalize_action_label(label: &str) -> Result<String, GenUiError> {
2102    if label.chars().any(|character| {
2103        character.is_control()
2104            || is_bidi_format(character)
2105            || matches!(
2106                character,
2107                '\n' | '\r'
2108                    | '\t'
2109                    | '\u{200b}'
2110                    | '\u{200c}'
2111                    | '\u{200d}'
2112                    | '\u{2060}'
2113                    | '\u{feff}'
2114                    | '\u{2028}'
2115                    | '\u{2029}'
2116            )
2117    }) {
2118        return Err(GenUiError::InvalidSurface(
2119            "action label contains a control, newline, tab, or bidi formatting character"
2120                .to_owned(),
2121        ));
2122    }
2123    let display = sanitize_text(label);
2124    if display != label {
2125        return Err(GenUiError::InvalidSurface(
2126            "action label cannot be normalized safely".to_owned(),
2127        ));
2128    }
2129    Ok(display)
2130}
2131
2132fn validate_value_size(value: &Value) -> Result<(), GenUiError> {
2133    let bytes =
2134        serde_json::to_vec(value).map_err(|error| GenUiError::InvalidSurface(error.to_string()))?;
2135    if bytes.len() > DEFAULT_MAX_TEXT_BYTES {
2136        Err(GenUiError::InvalidSurface(
2137            "value exceeds bounded size".to_owned(),
2138        ))
2139    } else {
2140        Ok(())
2141    }
2142}
2143
2144fn digest_json<T: Serialize>(value: &T) -> String {
2145    let bytes = canonical_json_bytes(value).expect("typed GenUI values are serializable");
2146    sha256_hex(&bytes)
2147}
2148
2149pub(crate) fn digest_value(value: &Value) -> String {
2150    digest_json(value)
2151}
2152
2153/// Derive the GenUI authority identity from a trusted host snapshot. The
2154/// snapshot is supplied by `Agent`/the host transition, never by a model
2155/// action or a renderer caller. Keeping this derivation here gives discovery,
2156/// binding, and persistence one canonical digest format.
2157pub(crate) fn derive_host_state_identity(
2158    session_id: &str,
2159    principal: &str,
2160    host_snapshot: &Value,
2161) -> (String, String) {
2162    let digest = digest_json(&serde_json::json!({
2163        "schema": "falsegreen.agent.genui.host-state.v1",
2164        "session_id": session_id,
2165        "principal": principal,
2166        "snapshot": host_snapshot,
2167    }));
2168    let authorization_context = format!("agent-session:{session_id}:principal:{principal}");
2169    (digest, authorization_context)
2170}
2171
2172/// Derive the execution authority identity from the actual current workspace.
2173/// This is deliberately the same helper used when the trusted Agent advances
2174/// durable GenUI state and when the transport gate rechecks it.  No cached or
2175/// caller-provided workspace fingerprint participates in this identity.
2176pub fn derive_host_workspace_state_identity(
2177    session_id: &str,
2178    principal: &str,
2179    workspace_state: &WorkspaceState,
2180) -> (String, String) {
2181    derive_host_state_identity(
2182        session_id,
2183        principal,
2184        &serde_json::json!({"workspace": workspace_state}),
2185    )
2186}
2187
2188/// Canonical identity digest for an MCP input schema. This is public so the
2189/// discovery catalog and GenUI adapter cannot accidentally use different
2190/// serialization rules.
2191#[must_use]
2192pub fn mcp_schema_digest(schema: &Value) -> String {
2193    // MCP schema identity must remain total even for hostile Values assembled
2194    // by a caller rather than parsed from bounded input. The iterative encoder
2195    // never recurses, so a 5,000-level annotation cannot overflow the stack.
2196    match canonical_json_bytes_value_iterative(schema) {
2197        Ok(bytes) => sha256_hex(&bytes),
2198        Err(_) => sha256_hex(b"falsegreen.agent.genui.mcp-schema.invalid-v2"),
2199    }
2200}
2201
2202fn canonical_json_bytes_value_iterative(value: &Value) -> Result<Vec<u8>, serde_json::Error> {
2203    enum Task<'a> {
2204        Value(&'a Value),
2205        Key(&'a str),
2206        Raw(&'static [u8]),
2207    }
2208
2209    let mut output = Vec::new();
2210    let mut tasks = vec![Task::Value(value)];
2211    while let Some(task) = tasks.pop() {
2212        match task {
2213            Task::Raw(bytes) => output.extend_from_slice(bytes),
2214            Task::Key(key) => serde_json::to_writer(&mut output, key)?,
2215            Task::Value(value) => match value {
2216                Value::Null => output.extend_from_slice(b"null"),
2217                Value::Bool(value) => {
2218                    output.extend_from_slice(if *value { b"true" } else { b"false" })
2219                }
2220                Value::Number(value) => serde_json::to_writer(&mut output, value)?,
2221                Value::String(value) => serde_json::to_writer(&mut output, value)?,
2222                Value::Array(items) => {
2223                    output.push(b'[');
2224                    tasks.push(Task::Raw(b"]"));
2225                    for (index, item) in items.iter().enumerate().rev() {
2226                        if index + 1 < items.len() {
2227                            tasks.push(Task::Raw(b","));
2228                        }
2229                        tasks.push(Task::Value(item));
2230                    }
2231                }
2232                Value::Object(object) => {
2233                    let mut entries = object
2234                        .iter()
2235                        .map(|(key, value)| (key.as_str(), value))
2236                        .collect::<Vec<_>>();
2237                    entries.sort_unstable_by(|left, right| left.0.cmp(right.0));
2238                    output.push(b'{');
2239                    tasks.push(Task::Raw(b"}"));
2240                    for (index, (key, value)) in entries.iter().enumerate().rev() {
2241                        if index + 1 < entries.len() {
2242                            tasks.push(Task::Raw(b","));
2243                        }
2244                        tasks.push(Task::Value(value));
2245                        tasks.push(Task::Raw(b":"));
2246                        tasks.push(Task::Key(key));
2247                    }
2248                }
2249            },
2250        }
2251    }
2252    Ok(output)
2253}
2254
2255fn canonical_json_bytes<T: Serialize>(value: &T) -> Result<Vec<u8>, serde_json::Error> {
2256    let value = serde_json::to_value(value)?;
2257    serde_json::to_vec(&canonicalize_json(value))
2258}
2259
2260fn canonicalize_json(value: Value) -> Value {
2261    match value {
2262        Value::Object(object) => {
2263            let mut canonical = Map::new();
2264            for (key, value) in object {
2265                canonical.insert(key, canonicalize_json(value));
2266            }
2267            // serde_json::Map preserves insertion order by default. Rebuild
2268            // through a BTreeMap so identity is independent of input order.
2269            let mut sorted = BTreeMap::new();
2270            for (key, value) in canonical {
2271                sorted.insert(key, value);
2272            }
2273            Value::Object(sorted.into_iter().collect())
2274        }
2275        Value::Array(items) => Value::Array(items.into_iter().map(canonicalize_json).collect()),
2276        other => other,
2277    }
2278}
2279
2280fn sha256_hex(bytes: &[u8]) -> String {
2281    format!("{:x}", Sha256::digest(bytes))
2282}
2283
2284/// Remove ANSI/OSC/CSI and other terminal control bytes from model/tool text.
2285/// Newlines are retained as layout data; tabs and all other controls are
2286/// replaced with a visible space so terminal display width stays bounded. No
2287/// escape sequence is emitted by this code.
2288#[must_use]
2289pub fn sanitize_text(input: &str) -> String {
2290    let mut output = String::with_capacity(input.len());
2291    let mut chars = input.chars().peekable();
2292    while let Some(ch) = chars.next() {
2293        if ch == '\u{1b}' {
2294            if chars.peek() == Some(&'[') {
2295                chars.next();
2296                for next in chars.by_ref() {
2297                    if ('@'..='~').contains(&next) {
2298                        break;
2299                    }
2300                }
2301            } else if chars.peek() == Some(&']') {
2302                chars.next();
2303                let mut previous_escape = false;
2304                for next in chars.by_ref() {
2305                    if next == '\u{7}' {
2306                        break;
2307                    }
2308                    if previous_escape && next == '\\' {
2309                        break;
2310                    }
2311                    previous_escape = next == '\u{1b}';
2312                }
2313            } else if matches!(chars.peek(), Some('P' | '^' | '_')) {
2314                // DCS/PM/APC are string control sequences terminated by ST
2315                // (ESC \) or BEL. Drop the complete sequence, not merely
2316                // its introducer, so hostile payload text cannot surface.
2317                chars.next();
2318                let mut previous_escape = false;
2319                for next in chars.by_ref() {
2320                    if next == '\u{7}' {
2321                        break;
2322                    }
2323                    if previous_escape && next == '\\' {
2324                        break;
2325                    }
2326                    previous_escape = next == '\u{1b}';
2327                }
2328            } else {
2329                let _ = chars.next();
2330            }
2331        } else if ch == '\n' {
2332            output.push(ch);
2333        } else if ch.is_control() || is_bidi_format(ch) || is_zero_width_format(ch) {
2334            output.push(' ');
2335        } else {
2336            output.push(ch);
2337        }
2338    }
2339    output
2340}
2341
2342/// Sanitize metadata that is rendered inline with form fields or action
2343/// affordances. All line/layout controls, bidi formatting, zero-width
2344/// characters, and terminal controls become ordinary spaces; whitespace is
2345/// collapsed and trimmed so the result is always one inert display line.
2346#[must_use]
2347pub fn sanitize_single_line_metadata(input: &str) -> String {
2348    let sanitized = sanitize_text(input);
2349    let mut output = String::with_capacity(sanitized.len());
2350    let mut pending_space = false;
2351    for character in sanitized.chars() {
2352        if character.is_whitespace()
2353            || character.is_control()
2354            || is_bidi_format(character)
2355            || is_zero_width_format(character)
2356        {
2357            pending_space = true;
2358            continue;
2359        }
2360        if pending_space && !output.is_empty() {
2361            output.push(' ');
2362        }
2363        output.push(character);
2364        pending_space = false;
2365    }
2366    output
2367}
2368
2369/// Render using the exact capability set negotiated by the trusted host.
2370/// This low-level renderer is crate-private; production callers must use the
2371/// catalog-aware admission path below so degraded subtrees revoke authority.
2372#[allow(dead_code)]
2373pub(crate) fn render_surface_with_capabilities(
2374    surface: &Surface,
2375    capabilities: &NegotiatedCapabilities,
2376) -> Result<String, GenUiError> {
2377    let host = HostCapabilities {
2378        protocol: capabilities.protocol,
2379        supported_components: capabilities.supported_components.clone(),
2380        supported_actions: capabilities.supported_actions.clone(),
2381        unsupported_component_policy: capabilities.unsupported_component_policy,
2382        terminal_width: capabilities.terminal_width.max(1),
2383        ..HostCapabilities::default()
2384    };
2385    surface.validate_shape(&host)?;
2386    let mut lines = Vec::new();
2387    let has_unsupported_component =
2388        !unsupported_component_ids(&surface.root, &host.supported_components).is_empty();
2389    render_component(&surface.root, &host.supported_components, &mut lines);
2390    // A placeholder is display-only. Never put executable action affordances
2391    // beside a degraded component tree, and never display an action kind that
2392    // this negotiated host did not admit.
2393    if !has_unsupported_component {
2394        for action in &surface.actions {
2395            if !capabilities.supported_actions.contains(&action.kind) {
2396                continue;
2397            }
2398            let label = normalize_action_label(&action.label)
2399                .expect("validated action labels have a canonical display representation");
2400            lines.push(format!("[{}] {label}", action.id));
2401        }
2402    }
2403    Ok(lines
2404        .into_iter()
2405        .flat_map(|line| wrap_line(&line, host.terminal_width))
2406        .collect::<Vec<_>>()
2407        .join("\n"))
2408}
2409
2410/// Renderer/authority integration for callers that own the host catalog.
2411/// Admission revokes degraded-surface actions before producing display text.
2412#[allow(dead_code)]
2413pub(crate) fn render_surface_with_capabilities_and_catalog(
2414    surface: &Surface,
2415    capabilities: &NegotiatedCapabilities,
2416    catalog: &mut ActionCatalog,
2417) -> Result<String, GenUiError> {
2418    catalog.admit_rendered_surface(surface, capabilities)?;
2419    let host = HostCapabilities {
2420        protocol: capabilities.protocol,
2421        supported_components: capabilities.supported_components.clone(),
2422        supported_actions: capabilities.supported_actions.clone(),
2423        unsupported_component_policy: capabilities.unsupported_component_policy,
2424        terminal_width: capabilities.terminal_width.max(1),
2425        ..HostCapabilities::default()
2426    };
2427    surface.validate_shape(&host)?;
2428    let mut lines = Vec::new();
2429    render_component(&surface.root, &host.supported_components, &mut lines);
2430    for action in &surface.actions {
2431        if !capabilities.supported_actions.contains(&action.kind)
2432            || catalog.resolve(&action.id).is_none()
2433        {
2434            continue;
2435        }
2436        let label = normalize_action_label(&action.label)
2437            .expect("validated action labels have a canonical display representation");
2438        lines.push(format!("[{}] {label}", action.id));
2439    }
2440    Ok(lines
2441        .into_iter()
2442        .flat_map(|line| wrap_line(&line, host.terminal_width))
2443        .collect::<Vec<_>>()
2444        .join("\n"))
2445}
2446
2447/// Canonical renderer entry point for durable GenUI surfaces. Admission reads
2448/// current authority from the EventStore before any executable affordance is
2449/// included in the rendered output.
2450pub fn render_surface_with_capabilities_and_catalog_with_store(
2451    surface: &Surface,
2452    capabilities: &NegotiatedCapabilities,
2453    catalog: &mut ActionCatalog,
2454    store: &EventStore,
2455) -> Result<String, GenUiError> {
2456    catalog.admit_rendered_surface_with_store(surface, capabilities, store)?;
2457    let host = HostCapabilities {
2458        protocol: capabilities.protocol,
2459        supported_components: capabilities.supported_components.clone(),
2460        supported_actions: capabilities.supported_actions.clone(),
2461        unsupported_component_policy: capabilities.unsupported_component_policy,
2462        terminal_width: capabilities.terminal_width.max(1),
2463        ..HostCapabilities::default()
2464    };
2465    surface.validate_shape(&host)?;
2466    let mut lines = Vec::new();
2467    render_component(&surface.root, &host.supported_components, &mut lines);
2468    for action in &surface.actions {
2469        if !capabilities.supported_actions.contains(&action.kind)
2470            || catalog.resolve(&action.id).is_none()
2471        {
2472            continue;
2473        }
2474        let label = normalize_action_label(&action.label)
2475            .expect("validated action labels have a canonical display representation");
2476        lines.push(format!("[{}] {label}", action.id));
2477    }
2478    Ok(lines
2479        .into_iter()
2480        .flat_map(|line| wrap_line(&line, host.terminal_width))
2481        .collect::<Vec<_>>()
2482        .join("\n"))
2483}
2484
2485fn unsupported_component_ids(
2486    component: &Component,
2487    supported: &BTreeSet<String>,
2488) -> BTreeSet<String> {
2489    let mut output = BTreeSet::new();
2490    collect_unsupported_component_ids(component, supported, &mut output);
2491    output
2492}
2493
2494fn collect_unsupported_component_ids(
2495    component: &Component,
2496    supported: &BTreeSet<String>,
2497    output: &mut BTreeSet<String>,
2498) {
2499    if !supported.contains(component.kind.name()) {
2500        output.insert(component.id.clone());
2501    }
2502    match &component.kind {
2503        ComponentKind::Stack { children } => {
2504            for child in children {
2505                collect_unsupported_component_ids(child, supported, output);
2506            }
2507        }
2508        ComponentKind::Columns { columns } => {
2509            for child in columns.iter().flatten() {
2510                collect_unsupported_component_ids(child, supported, output);
2511            }
2512        }
2513        _ => {}
2514    }
2515}
2516
2517fn component_contains_id(component: &Component, ancestor: &str, target: &str) -> bool {
2518    if component.id == ancestor {
2519        return component.id == target || component_contains_descendant(component, target);
2520    }
2521    match &component.kind {
2522        ComponentKind::Stack { children } => children
2523            .iter()
2524            .any(|child| component_contains_id(child, ancestor, target)),
2525        ComponentKind::Columns { columns } => columns
2526            .iter()
2527            .flatten()
2528            .any(|child| component_contains_id(child, ancestor, target)),
2529        _ => false,
2530    }
2531}
2532
2533fn component_contains_descendant(component: &Component, target: &str) -> bool {
2534    match &component.kind {
2535        ComponentKind::Stack { children } => children
2536            .iter()
2537            .any(|child| child.id == target || component_contains_descendant(child, target)),
2538        ComponentKind::Columns { columns } => columns
2539            .iter()
2540            .flatten()
2541            .any(|child| child.id == target || component_contains_descendant(child, target)),
2542        _ => false,
2543    }
2544}
2545
2546#[must_use]
2547pub fn default_negotiated_capabilities(width: usize) -> NegotiatedCapabilities {
2548    let host = HostCapabilities {
2549        terminal_width: width.max(1),
2550        ..HostCapabilities::default()
2551    };
2552    negotiate(&[ProtocolVersion::current()], &host)
2553        .expect("current protocol must negotiate with the default host")
2554}
2555
2556fn render_component(component: &Component, supported: &BTreeSet<String>, lines: &mut Vec<String>) {
2557    if !supported.contains(component.kind.name()) {
2558        lines.push(format!(
2559            "[unsupported component: {}]",
2560            component.kind.name()
2561        ));
2562        return;
2563    }
2564    match &component.kind {
2565        ComponentKind::Text { text } | ComponentKind::Markdown { markdown: text } => {
2566            lines.extend(sanitize_text(text).lines().map(str::to_owned));
2567        }
2568        ComponentKind::Status { label, value, .. } => {
2569            lines.push(format!(
2570                "{}: {}",
2571                sanitize_text(label),
2572                sanitize_text(value)
2573            ));
2574        }
2575        ComponentKind::Progress {
2576            label,
2577            current,
2578            total,
2579        } => {
2580            let progress = total.map_or_else(
2581                || "indeterminate".to_owned(),
2582                |total| format!("{current}/{total}"),
2583            );
2584            lines.push(format!("{}: {progress}", sanitize_text(label)));
2585        }
2586        ComponentKind::Table { columns, rows } => {
2587            lines.push(
2588                columns
2589                    .iter()
2590                    .map(|column| sanitize_text(column))
2591                    .collect::<Vec<_>>()
2592                    .join(" | "),
2593            );
2594            for row in rows {
2595                lines.push(
2596                    row.iter()
2597                        .map(|cell| sanitize_text(cell))
2598                        .collect::<Vec<_>>()
2599                        .join(" | "),
2600                );
2601            }
2602        }
2603        ComponentKind::KeyValue { entries } => {
2604            for (key, value) in entries {
2605                lines.push(format!("{}: {}", sanitize_text(key), sanitize_text(value)));
2606            }
2607        }
2608        ComponentKind::Diff { before, after } => {
2609            lines.push(format!("- {}", sanitize_text(before)));
2610            lines.push(format!("+ {}", sanitize_text(after)));
2611        }
2612        ComponentKind::TestResults {
2613            passed,
2614            failed,
2615            skipped,
2616            details,
2617        } => {
2618            lines.push(format!(
2619                "tests: {passed} passed, {failed} failed, {skipped} skipped"
2620            ));
2621            lines.extend(
2622                details
2623                    .iter()
2624                    .map(|detail| format!("  {}", sanitize_text(detail))),
2625            );
2626        }
2627        ComponentKind::Form { fields } => {
2628            for field in fields {
2629                let value = field.value.as_ref().map_or_else(
2630                    || "".to_owned(),
2631                    |value| sanitize_single_line_metadata(&value.to_string()),
2632                );
2633                lines.push(format!(
2634                    "{}{}: {}",
2635                    if field.required { "*" } else { "" },
2636                    sanitize_text(&field.label),
2637                    value
2638                ));
2639            }
2640        }
2641        ComponentKind::Choice {
2642            label,
2643            options,
2644            selected,
2645        } => {
2646            lines.push(format!("{}:", sanitize_text(label)));
2647            for option in options {
2648                let marker = if selected.as_ref() == Some(&option.value) {
2649                    "*"
2650                } else {
2651                    " "
2652                };
2653                lines.push(format!("  [{marker}] {}", sanitize_text(&option.label)));
2654            }
2655        }
2656        ComponentKind::Evidence { title, items } => {
2657            lines.push(sanitize_text(title));
2658            for item in items {
2659                lines.push(format!(
2660                    "  {}: {}",
2661                    sanitize_text(&item.label),
2662                    sanitize_text(&item.value)
2663                ));
2664            }
2665        }
2666        ComponentKind::Timeline { entries } => {
2667            for entry in entries {
2668                lines.push(format!(
2669                    "{:?} {} — {}",
2670                    entry.state,
2671                    sanitize_text(&entry.label),
2672                    sanitize_text(&entry.detail)
2673                ));
2674            }
2675        }
2676        ComponentKind::Stack { children } => {
2677            for child in children {
2678                render_component(child, supported, lines);
2679            }
2680        }
2681        ComponentKind::Columns { columns } => {
2682            let mut rendered = Vec::new();
2683            for column in columns {
2684                let mut column_lines = Vec::new();
2685                for child in column {
2686                    render_component(child, supported, &mut column_lines);
2687                }
2688                rendered.push(column_lines);
2689            }
2690            let rows = rendered.iter().map(Vec::len).max().unwrap_or(0);
2691            for row in 0..rows {
2692                lines.push(
2693                    rendered
2694                        .iter()
2695                        .map(|column| column.get(row).cloned().unwrap_or_default())
2696                        .collect::<Vec<_>>()
2697                        .join(" | "),
2698                );
2699            }
2700        }
2701    }
2702}
2703
2704fn wrap_line(line: &str, width: usize) -> Vec<String> {
2705    if line.is_empty() {
2706        return vec![String::new()];
2707    }
2708    let width = width.max(1);
2709    let mut output = Vec::new();
2710    let mut current = String::new();
2711    let mut current_width = 0usize;
2712    for character in line.chars() {
2713        let character_width = UnicodeWidthChar::width(character).unwrap_or(0);
2714        if character_width > 0 && current_width + character_width > width {
2715            output.push(std::mem::take(&mut current));
2716            current_width = 0;
2717        }
2718        current.push(character);
2719        current_width = current_width.saturating_add(character_width);
2720    }
2721    if !current.is_empty() || output.is_empty() {
2722        output.push(current);
2723    }
2724    output
2725}
2726
2727/// The deliberately small, typed MCP schema language accepted by the V1
2728/// adapter. Raw JSON is parsed once into this representation and is never
2729/// reinterpreted by the renderer or submission path.
2730pub const MCP_SCHEMA_ADAPTER_ID: &str = "falsegreen.agent.genui.mcp-schema.v1";
2731pub const MCP_SCHEMA_MAX_BYTES: usize = 4 * 1024 * 1024;
2732pub const MCP_SCHEMA_MAX_FIELDS: usize = 256;
2733pub const MCP_SCHEMA_MAX_ENUM_VALUES: usize = DEFAULT_MAX_COLUMNS;
2734pub const MCP_SCHEMA_MAX_STRING_LENGTH: u64 = 1_000_000;
2735/// Supported G3 schemas are intentionally shallow: an object root contains
2736/// scalar property definitions and no recursive schema constructs. This bound
2737/// is global and is checked before any recursive serialization/canonicalization.
2738pub const MCP_SCHEMA_MAX_JSON_DEPTH: usize = 64;
2739/// Bound total JSON nodes so large annotation trees cannot amplify parsing or
2740/// digest work even when they stay within the depth bound. `examples` is
2741/// rejected separately, but this also covers unknown annotation structures.
2742pub const MCP_SCHEMA_MAX_JSON_NODES: usize = 16_384;
2743
2744#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2745pub enum McpSchemaDialect {
2746    Draft7,
2747    Draft202012,
2748}
2749
2750impl McpSchemaDialect {
2751    #[must_use]
2752    pub const fn uri(self) -> &'static str {
2753        match self {
2754            Self::Draft7 => "http://json-schema.org/draft-07/schema#",
2755            Self::Draft202012 => "https://json-schema.org/draft/2020-12/schema",
2756        }
2757    }
2758
2759    fn parse(value: &Value) -> Result<Self, GenUiError> {
2760        let uri = value
2761            .as_str()
2762            .ok_or_else(|| unsupported_schema("unsupported_dialect", "$schema must be a string"))?;
2763        match uri {
2764            "http://json-schema.org/draft-07/schema#" => Ok(Self::Draft7),
2765            "https://json-schema.org/draft/2020-12/schema" => Ok(Self::Draft202012),
2766            _ => Err(unsupported_schema(
2767                "unsupported_dialect",
2768                format!("unsupported JSON-Schema dialect {uri:?}"),
2769            )),
2770        }
2771    }
2772}
2773
2774#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2775pub enum McpSchemaType {
2776    String,
2777    Integer,
2778    Boolean,
2779}
2780
2781impl McpSchemaType {
2782    fn parse(name: &str, property: &str) -> Result<Self, GenUiError> {
2783        match name {
2784            "string" => Ok(Self::String),
2785            "integer" => Ok(Self::Integer),
2786            "boolean" => Ok(Self::Boolean),
2787            "number" => Err(unsupported_schema(
2788                "unsupported_type",
2789                format!(
2790                    "property {property:?} uses number; exact decimal semantics are not enabled"
2791                ),
2792            )),
2793            "array" => Err(unsupported_schema(
2794                "unsupported_nested_array",
2795                format!("property {property:?} uses an array"),
2796            )),
2797            "object" => Err(unsupported_schema(
2798                "unsupported_nested_object",
2799                format!("property {property:?} uses a nested object"),
2800            )),
2801            other => Err(unsupported_schema(
2802                "unsupported_type",
2803                format!("property {property:?} uses unsupported type {other:?}"),
2804            )),
2805        }
2806    }
2807
2808    fn field_type(self) -> FieldType {
2809        match self {
2810            Self::String => FieldType::String,
2811            Self::Integer => FieldType::Integer,
2812            Self::Boolean => FieldType::Boolean,
2813        }
2814    }
2815}
2816
2817#[derive(Debug, Clone, PartialEq, Eq)]
2818pub enum McpScalarValue {
2819    String(String),
2820    Integer(i64),
2821    UnsignedInteger(u64),
2822    Boolean(bool),
2823}
2824
2825impl McpScalarValue {
2826    fn parse(value: &Value, expected: McpSchemaType, property: &str) -> Result<Self, GenUiError> {
2827        let parsed = match expected {
2828            McpSchemaType::String => value.as_str().map(|value| Self::String(value.to_owned())),
2829            McpSchemaType::Integer => value
2830                .as_i64()
2831                .map(Self::Integer)
2832                .or_else(|| value.as_u64().map(Self::UnsignedInteger)),
2833            McpSchemaType::Boolean => value.as_bool().map(Self::Boolean),
2834        };
2835        parsed.ok_or_else(|| {
2836            unsupported_schema(
2837                "enum_type_mismatch",
2838                format!("property {property:?} has a value of the wrong type"),
2839            )
2840        })
2841    }
2842
2843    fn from_submission(value: &Value, expected: McpSchemaType) -> Option<Self> {
2844        match expected {
2845            McpSchemaType::String => value.as_str().map(|value| Self::String(value.to_owned())),
2846            McpSchemaType::Integer => value
2847                .as_i64()
2848                .map(Self::Integer)
2849                .or_else(|| value.as_u64().map(Self::UnsignedInteger)),
2850            McpSchemaType::Boolean => value.as_bool().map(Self::Boolean),
2851        }
2852    }
2853
2854    fn to_json(&self) -> Value {
2855        match self {
2856            Self::String(value) => Value::String(value.clone()),
2857            Self::Integer(value) => Value::Number((*value).into()),
2858            Self::UnsignedInteger(value) => Value::Number((*value).into()),
2859            Self::Boolean(value) => Value::Bool(*value),
2860        }
2861    }
2862
2863    fn display(&self) -> String {
2864        sanitize_single_line_metadata(&match self {
2865            Self::String(value) => value.clone(),
2866            Self::Integer(value) => value.to_string(),
2867            Self::UnsignedInteger(value) => value.to_string(),
2868            Self::Boolean(value) => value.to_string(),
2869        })
2870    }
2871
2872    fn integer(&self) -> Option<i128> {
2873        match self {
2874            Self::Integer(value) => Some(i128::from(*value)),
2875            Self::UnsignedInteger(value) => Some(i128::from(*value)),
2876            _ => None,
2877        }
2878    }
2879}
2880
2881#[derive(Debug, Clone, PartialEq, Eq)]
2882pub struct McpSchemaField {
2883    pub name: String,
2884    pub schema_type: McpSchemaType,
2885    pub required: bool,
2886    pub enum_values: Vec<McpScalarValue>,
2887    pub title: Option<String>,
2888    pub description: Option<String>,
2889    pub default: Option<McpScalarValue>,
2890    pub min_length: Option<u64>,
2891    pub max_length: Option<u64>,
2892    pub minimum: Option<i128>,
2893    pub maximum: Option<i128>,
2894}
2895
2896impl McpSchemaField {
2897    fn validate_value(&self, value: &Value) -> Result<(), GenUiError> {
2898        let parsed = McpScalarValue::from_submission(value, self.schema_type).ok_or_else(|| {
2899            GenUiError::InvalidMcpSubmission(format!("field {:?} has the wrong type", self.name))
2900        })?;
2901        if !self.enum_values.is_empty() && !self.enum_values.contains(&parsed) {
2902            return Err(GenUiError::InvalidMcpSubmission(format!(
2903                "field {:?} is outside its enum",
2904                self.name
2905            )));
2906        }
2907        if let Some(number) = parsed.integer() {
2908            if self.minimum.is_some_and(|minimum| number < minimum) {
2909                return Err(GenUiError::InvalidMcpSubmission(format!(
2910                    "field {:?} is below its minimum",
2911                    self.name
2912                )));
2913            }
2914            if self.maximum.is_some_and(|maximum| number > maximum) {
2915                return Err(GenUiError::InvalidMcpSubmission(format!(
2916                    "field {:?} exceeds its maximum",
2917                    self.name
2918                )));
2919            }
2920        }
2921        if let Some(text) = value.as_str() {
2922            if self
2923                .min_length
2924                .is_some_and(|minimum| text.chars().count() < minimum as usize)
2925            {
2926                return Err(GenUiError::InvalidMcpSubmission(format!(
2927                    "field {:?} is shorter than minLength",
2928                    self.name
2929                )));
2930            }
2931            if self
2932                .max_length
2933                .is_some_and(|maximum| text.chars().count() > maximum as usize)
2934            {
2935                return Err(GenUiError::InvalidMcpSubmission(format!(
2936                    "field {:?} is longer than maxLength",
2937                    self.name
2938                )));
2939            }
2940        }
2941        Ok(())
2942    }
2943
2944    fn validate_default(&self, value: &Value) -> Result<(), GenUiError> {
2945        McpScalarValue::parse(value, self.schema_type, &self.name).map_err(|_| {
2946            unsupported_schema(
2947                "malformed_default",
2948                format!("property {:?} default has the wrong type", self.name),
2949            )
2950        })?;
2951        self.validate_value(value).map_err(|error| match error {
2952            GenUiError::InvalidMcpSubmission(message) => {
2953                unsupported_schema("malformed_default", message)
2954            }
2955            other => other,
2956        })
2957    }
2958
2959    fn as_form_field(&self) -> FormField {
2960        let choices = self
2961            .enum_values
2962            .iter()
2963            .map(McpScalarValue::display)
2964            .collect::<Vec<_>>();
2965        let choice_values = self
2966            .enum_values
2967            .iter()
2968            .map(McpScalarValue::to_json)
2969            .collect::<Vec<_>>();
2970        let mut constraints = BTreeMap::new();
2971        if let Some(value) = self.min_length {
2972            constraints.insert("minLength".to_owned(), Value::Number(value.into()));
2973        }
2974        if let Some(value) = self.max_length {
2975            constraints.insert("maxLength".to_owned(), Value::Number(value.into()));
2976        }
2977        if let Some(value) = self.minimum.and_then(i128_to_json_number) {
2978            constraints.insert("minimum".to_owned(), Value::Number(value));
2979        }
2980        if let Some(value) = self.maximum.and_then(i128_to_json_number) {
2981            constraints.insert("maximum".to_owned(), Value::Number(value));
2982        }
2983        FormField {
2984            id: self.name.clone(),
2985            label: self
2986                .title
2987                .as_deref()
2988                .filter(|title| !title.is_empty())
2989                .unwrap_or(&self.name)
2990                .to_owned(),
2991            field_type: self.schema_type.field_type(),
2992            required: self.required,
2993            description: self.description.clone(),
2994            value: self.default.as_ref().map(McpScalarValue::to_json),
2995            choices,
2996            choice_values,
2997            constraints,
2998        }
2999    }
3000}
3001
3002#[derive(Debug, Clone, PartialEq, Eq)]
3003pub struct McpSchemaIr {
3004    pub adapter: &'static str,
3005    pub dialect: Option<McpSchemaDialect>,
3006    pub source_digest: String,
3007    pub additional_properties: bool,
3008    pub title: Option<String>,
3009    pub description: Option<String>,
3010    pub fields: Vec<McpSchemaField>,
3011}
3012
3013impl McpSchemaIr {
3014    /// Parse an MCP input schema into a typed, bounded representation. The
3015    /// source digest is the identity of the exact raw schema and is retained
3016    /// for the host binding; it is never recomputed from the form projection.
3017    pub fn parse(schema: &Value) -> Result<Self, GenUiError> {
3018        parse_mcp_schema(schema)
3019    }
3020
3021    #[must_use]
3022    pub fn source_digest(&self) -> &str {
3023        &self.source_digest
3024    }
3025
3026    #[must_use]
3027    pub fn dialect_uri(&self) -> Option<&'static str> {
3028        self.dialect.map(McpSchemaDialect::uri)
3029    }
3030
3031    #[must_use]
3032    pub fn form_fields(&self) -> Vec<FormField> {
3033        self.fields
3034            .iter()
3035            .map(McpSchemaField::as_form_field)
3036            .collect()
3037    }
3038
3039    /// Validate a JSON object without coercing values or applying defaults.
3040    /// An omitted optional field remains omitted; JSON null is not accepted
3041    /// because nullable unions are outside this exact V1 dialect subset.
3042    pub fn validate_submission(&self, arguments: &Value) -> Result<(), GenUiError> {
3043        let args = arguments.as_object().ok_or_else(|| {
3044            GenUiError::InvalidMcpSubmission("arguments must be an object".to_owned())
3045        })?;
3046        for field in &self.fields {
3047            if field.required && !args.contains_key(&field.name) {
3048                return Err(GenUiError::InvalidMcpSubmission(format!(
3049                    "required field {:?} is missing",
3050                    field.name
3051                )));
3052            }
3053        }
3054        for (name, value) in args {
3055            let Some(field) = self.fields.iter().find(|field| field.name == *name) else {
3056                return Err(GenUiError::InvalidMcpSubmission(
3057                    "unknown field is not permitted by the original schema".to_owned(),
3058                ));
3059            };
3060            field.validate_value(value)?;
3061        }
3062        Ok(())
3063    }
3064
3065    fn validate_form_projection(&self) -> Result<(), GenUiError> {
3066        let form = ComponentKind::Form {
3067            fields: self.form_fields(),
3068        };
3069        validate_kind(&form, DEFAULT_MAX_COLUMNS).map_err(|error| {
3070            unsupported_schema(
3071                "surface_limit",
3072                format!("adapted form cannot be constructed: {error}"),
3073            )
3074        })
3075    }
3076
3077    /// Reconstruct the exact object represented by submitted UI values. The
3078    /// caller supplies only explicitly submitted values, so defaults are not
3079    /// silently inserted and no string-to-number/boolean guessing occurs.
3080    pub fn reconstruct_payload(
3081        &self,
3082        values: &BTreeMap<String, Value>,
3083    ) -> Result<Value, GenUiError> {
3084        let arguments = Value::Object(
3085            values
3086                .iter()
3087                .map(|(name, value)| (name.clone(), value.clone()))
3088                .collect(),
3089        );
3090        self.validate_submission(&arguments)?;
3091        Ok(arguments)
3092    }
3093}
3094
3095/// Parse an MCP schema into the trusted normalized IR.
3096pub fn adapt_mcp_schema(schema: &Value) -> Result<McpSchemaIr, GenUiError> {
3097    McpSchemaIr::parse(schema)
3098}
3099
3100/// Reconstruct a payload through the same IR used by trusted form rendering.
3101pub fn reconstruct_mcp_payload(
3102    schema: &Value,
3103    values: &BTreeMap<String, Value>,
3104) -> Result<Value, GenUiError> {
3105    McpSchemaIr::parse(schema)?.reconstruct_payload(values)
3106}
3107
3108/// Construct a typed form surface from an MCP tool's original input schema.
3109/// The resulting action has only an opaque presentation ID; executable tool
3110/// and schema identity are retained privately by the host catalog.
3111pub fn mcp_tool_surface(
3112    surface_id: impl Into<String>,
3113    tool_name: impl Into<String>,
3114    schema: &Value,
3115    state_digest: impl Into<String>,
3116) -> Result<Surface, GenUiError> {
3117    let surface_id = surface_id.into();
3118    let tool_name = tool_name.into();
3119    validate_id("tool", &tool_name)?;
3120    let ir = McpSchemaIr::parse(schema)?;
3121    let root = Component {
3122        id: "mcp-form".to_owned(),
3123        kind: ComponentKind::Form {
3124            fields: ir.form_fields(),
3125        },
3126    };
3127    let mut surface = Surface::new(surface_id, root);
3128    surface.actions.push(Action {
3129        id: ActionCatalog::new_action_id(),
3130        label: "Submit".to_owned(),
3131        kind: ActionKind::McpTool,
3132        state_digest: state_digest.into(),
3133    });
3134    surface.validate_shape(&HostCapabilities::default())?;
3135    Ok(surface)
3136}
3137
3138pub fn validate_mcp_submission(schema: &Value, arguments: &Value) -> Result<(), GenUiError> {
3139    McpSchemaIr::parse(schema)?.validate_submission(arguments)
3140}
3141
3142fn validate_schema_json_bounds(schema: &Value) -> Result<(), GenUiError> {
3143    let mut pending = vec![(schema, 0usize)];
3144    let mut nodes = 0usize;
3145    while let Some((value, depth)) = pending.pop() {
3146        if depth > MCP_SCHEMA_MAX_JSON_DEPTH {
3147            return Err(unsupported_schema(
3148                "schema_depth_exceeded",
3149                format!(
3150                    "JSON nesting depth {depth} exceeds the global bound {MCP_SCHEMA_MAX_JSON_DEPTH}"
3151                ),
3152            ));
3153        }
3154        nodes = nodes.saturating_add(1);
3155        if nodes > MCP_SCHEMA_MAX_JSON_NODES {
3156            return Err(unsupported_schema(
3157                "schema_complexity_exceeded",
3158                format!("JSON node count exceeds the global bound {MCP_SCHEMA_MAX_JSON_NODES}"),
3159            ));
3160        }
3161        match value {
3162            Value::Array(items) => pending.extend(items.iter().map(|item| (item, depth + 1))),
3163            Value::Object(object) => pending.extend(object.values().map(|item| (item, depth + 1))),
3164            Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
3165        }
3166    }
3167    Ok(())
3168}
3169
3170fn parse_mcp_schema(schema: &Value) -> Result<McpSchemaIr, GenUiError> {
3171    validate_schema_json_bounds(schema)?;
3172    validate_schema_serialized_size(schema)?;
3173    let object = schema
3174        .as_object()
3175        .ok_or_else(|| unsupported_schema("unsupported_type", "input schema must be an object"))?;
3176    let allowed_root = [
3177        "type",
3178        "properties",
3179        "required",
3180        "additionalProperties",
3181        "title",
3182        "description",
3183        "default",
3184        "examples",
3185        "$schema",
3186        "$id",
3187    ];
3188    reject_unknown_schema_keywords(object, &allowed_root, "root")?;
3189    if object.get("type").and_then(Value::as_str) != Some("object") {
3190        return Err(unsupported_schema(
3191            "unsupported_type",
3192            "root schema must have type object",
3193        ));
3194    }
3195    match object.get("additionalProperties") {
3196        Some(Value::Bool(false)) => {}
3197        Some(Value::Bool(true)) => {
3198            return Err(unsupported_schema(
3199                "additional_properties",
3200                "additionalProperties:true is outside the FalseGreen schema subset",
3201            ));
3202        }
3203        Some(_) => {
3204            return Err(unsupported_schema(
3205                "additional_properties",
3206                "additionalProperties must be exactly false",
3207            ));
3208        }
3209        None => {
3210            return Err(unsupported_schema(
3211                "additional_properties",
3212                "additionalProperties must be explicitly false",
3213            ));
3214        }
3215    }
3216    let dialect = object
3217        .get("$schema")
3218        .map(McpSchemaDialect::parse)
3219        .transpose()?;
3220    let title = parse_metadata(object, "title", "root")?;
3221    let description = parse_metadata(object, "description", "root")?;
3222    if object.contains_key("$id") {
3223        return Err(unsupported_schema(
3224            "unsupported_keyword",
3225            "$id is not supported by the G3 adapter",
3226        ));
3227    }
3228    if object.contains_key("examples") {
3229        return Err(unsupported_schema(
3230            "unsupported_annotation",
3231            "root examples are not supported by the G3 adapter",
3232        ));
3233    }
3234    let required = parse_required_fields(object)?;
3235    let properties = match object.get("properties") {
3236        Some(Value::Object(properties)) => properties,
3237        Some(_) => {
3238            return Err(unsupported_schema(
3239                "malformed_schema",
3240                "properties must be an object",
3241            ));
3242        }
3243        None => &Map::new(),
3244    };
3245    if properties.len() > MCP_SCHEMA_MAX_FIELDS {
3246        return Err(unsupported_schema(
3247            "schema_too_large",
3248            format!("property count exceeds {MCP_SCHEMA_MAX_FIELDS}"),
3249        ));
3250    }
3251    for name in &required {
3252        if !properties.contains_key(name) {
3253            return Err(unsupported_schema(
3254                "required_unknown_field",
3255                format!("required field {name:?} is not declared in properties"),
3256            ));
3257        }
3258    }
3259    let mut fields = Vec::with_capacity(properties.len());
3260    for (name, property) in properties {
3261        if validate_id("field", name).is_err() {
3262            return Err(unsupported_schema(
3263                "unsupported_property_name",
3264                format!("property name {name:?} cannot be represented as a trusted field ID"),
3265            ));
3266        }
3267        let property = property.as_object().ok_or_else(|| {
3268            unsupported_schema(
3269                "malformed_schema",
3270                format!("property {name:?} is not an object"),
3271            )
3272        })?;
3273        fields.push(parse_schema_field(name, property, required.contains(name))?);
3274    }
3275    let ir = McpSchemaIr {
3276        adapter: MCP_SCHEMA_ADAPTER_ID,
3277        dialect,
3278        source_digest: mcp_schema_digest(schema),
3279        additional_properties: false,
3280        title,
3281        description,
3282        fields,
3283    };
3284    if let Some(default) = object.get("default") {
3285        let default = default.as_object().ok_or_else(|| {
3286            unsupported_schema("malformed_default", "root default must be an object")
3287        })?;
3288        let default = Value::Object(default.clone());
3289        ir.validate_submission(&default).map_err(|error| {
3290            unsupported_schema(
3291                "malformed_default",
3292                format!("root default does not satisfy the exact payload validator: {error}"),
3293            )
3294        })?;
3295    }
3296    ir.validate_form_projection()?;
3297    Ok(ir)
3298}
3299
3300fn validate_schema_serialized_size(schema: &Value) -> Result<(), GenUiError> {
3301    let mut writer = SchemaSizeWriter {
3302        bytes: 0,
3303        exceeded: false,
3304    };
3305    if let Err(error) = serde_json::to_writer(&mut writer, schema)
3306        && !writer.exceeded
3307    {
3308        return Err(unsupported_schema("malformed_schema", error.to_string()));
3309    }
3310    if writer.exceeded {
3311        return Err(unsupported_schema(
3312            "schema_too_large",
3313            format!("schema exceeds the {MCP_SCHEMA_MAX_BYTES} byte adapter bound"),
3314        ));
3315    }
3316    Ok(())
3317}
3318
3319struct SchemaSizeWriter {
3320    bytes: usize,
3321    exceeded: bool,
3322}
3323
3324impl Write for SchemaSizeWriter {
3325    fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
3326        let next = self.bytes.saturating_add(buffer.len());
3327        if next > MCP_SCHEMA_MAX_BYTES {
3328            self.exceeded = true;
3329            return Err(io::Error::new(
3330                io::ErrorKind::WriteZero,
3331                "MCP schema exceeds bounded size",
3332            ));
3333        }
3334        self.bytes = next;
3335        Ok(buffer.len())
3336    }
3337
3338    fn flush(&mut self) -> io::Result<()> {
3339        Ok(())
3340    }
3341}
3342
3343fn parse_schema_field(
3344    name: &str,
3345    property: &Map<String, Value>,
3346    required: bool,
3347) -> Result<McpSchemaField, GenUiError> {
3348    let allowed_property = [
3349        "type",
3350        "enum",
3351        "minimum",
3352        "maximum",
3353        "minLength",
3354        "maxLength",
3355        "pattern",
3356        "title",
3357        "description",
3358        "default",
3359        "examples",
3360    ];
3361    let type_name = property
3362        .get("type")
3363        .and_then(Value::as_str)
3364        .ok_or_else(|| {
3365            unsupported_schema("malformed_schema", format!("property {name:?} has no type"))
3366        })?;
3367    let schema_type = McpSchemaType::parse(type_name, name)?;
3368    reject_unknown_schema_keywords(property, &allowed_property, name)?;
3369    if property.contains_key("pattern") {
3370        return Err(unsupported_schema(
3371            "unsupported_keyword",
3372            format!("property {name:?} uses unsupported pattern"),
3373        ));
3374    }
3375    let allows_lengths = schema_type == McpSchemaType::String;
3376    if property
3377        .keys()
3378        .any(|keyword| matches!(keyword.as_str(), "minLength" | "maxLength") && !allows_lengths)
3379    {
3380        return Err(unsupported_schema(
3381            "wrong_type_keyword",
3382            format!("string length keyword on non-string property {name:?}"),
3383        ));
3384    }
3385    let allows_bounds = schema_type == McpSchemaType::Integer;
3386    if property
3387        .keys()
3388        .any(|keyword| matches!(keyword.as_str(), "minimum" | "maximum") && !allows_bounds)
3389    {
3390        return Err(unsupported_schema(
3391            "wrong_type_keyword",
3392            format!("numeric bound on non-integer property {name:?}"),
3393        ));
3394    }
3395    let min_length = parse_length(property, "minLength", name, allows_lengths)?;
3396    let max_length = parse_length(property, "maxLength", name, allows_lengths)?;
3397    if min_length
3398        .zip(max_length)
3399        .is_some_and(|(minimum, maximum)| minimum > maximum)
3400    {
3401        return Err(unsupported_schema(
3402            "malformed_bound",
3403            format!("property {name:?} minLength exceeds maxLength"),
3404        ));
3405    }
3406    let minimum = parse_integer_bound(property, "minimum", name, allows_bounds)?;
3407    let maximum = parse_integer_bound(property, "maximum", name, allows_bounds)?;
3408    if minimum
3409        .zip(maximum)
3410        .is_some_and(|(minimum, maximum)| minimum > maximum)
3411    {
3412        return Err(unsupported_schema(
3413            "malformed_bound",
3414            format!("property {name:?} minimum exceeds maximum"),
3415        ));
3416    }
3417    let enum_values = parse_enum(property, schema_type, name)?;
3418    if property.contains_key("examples") {
3419        return Err(unsupported_schema(
3420            "unsupported_annotation",
3421            format!("property {name:?} examples are not supported by the G3 adapter"),
3422        ));
3423    }
3424    let field = McpSchemaField {
3425        name: name.to_owned(),
3426        schema_type,
3427        required,
3428        enum_values,
3429        title: parse_metadata(property, "title", name)?,
3430        description: parse_metadata(property, "description", name)?,
3431        default: property
3432            .get("default")
3433            .map(|value| {
3434                McpScalarValue::parse(value, schema_type, name).map_err(|_| {
3435                    unsupported_schema(
3436                        "malformed_default",
3437                        format!("property {name:?} default has the wrong type"),
3438                    )
3439                })
3440            })
3441            .transpose()?,
3442        min_length,
3443        max_length,
3444        minimum,
3445        maximum,
3446    };
3447    if let Some(default) = property.get("default") {
3448        field.validate_default(default)?;
3449    }
3450    Ok(field)
3451}
3452
3453fn parse_enum(
3454    property: &Map<String, Value>,
3455    schema_type: McpSchemaType,
3456    name: &str,
3457) -> Result<Vec<McpScalarValue>, GenUiError> {
3458    let Some(values) = property.get("enum") else {
3459        return Ok(Vec::new());
3460    };
3461    let values = values.as_array().ok_or_else(|| {
3462        unsupported_schema(
3463            "malformed_enum",
3464            format!("property {name:?} enum must be an array"),
3465        )
3466    })?;
3467    if values.is_empty() {
3468        return Err(unsupported_schema(
3469            "malformed_enum",
3470            format!("property {name:?} enum must not be empty"),
3471        ));
3472    }
3473    if values.len() > MCP_SCHEMA_MAX_ENUM_VALUES {
3474        return Err(unsupported_schema(
3475            "schema_too_large",
3476            format!("property {name:?} enum exceeds {MCP_SCHEMA_MAX_ENUM_VALUES} values"),
3477        ));
3478    }
3479    let mut parsed = Vec::with_capacity(values.len());
3480    let mut display_values = BTreeSet::new();
3481    for value in values {
3482        let scalar = McpScalarValue::parse(value, schema_type, name)?;
3483        if parsed.contains(&scalar) {
3484            return Err(unsupported_schema(
3485                "duplicate_enum",
3486                format!("property {name:?} enum contains duplicate values"),
3487            ));
3488        }
3489        if !display_values.insert(scalar.display()) {
3490            return Err(unsupported_schema(
3491                "ambiguous_enum",
3492                format!("property {name:?} enum values have the same safe display label"),
3493            ));
3494        }
3495        parsed.push(scalar);
3496    }
3497    Ok(parsed)
3498}
3499
3500fn parse_length(
3501    property: &Map<String, Value>,
3502    keyword: &str,
3503    name: &str,
3504    applicable: bool,
3505) -> Result<Option<u64>, GenUiError> {
3506    let Some(value) = property.get(keyword) else {
3507        return Ok(None);
3508    };
3509    let length = value.as_u64().ok_or_else(|| {
3510        unsupported_schema(
3511            "malformed_bound",
3512            format!("property {name:?} {keyword} must be a non-negative integer"),
3513        )
3514    })?;
3515    if !applicable || length > MCP_SCHEMA_MAX_STRING_LENGTH {
3516        return Err(unsupported_schema(
3517            "wrong_type_keyword",
3518            format!("property {name:?} {keyword} is outside the exact adapter limits"),
3519        ));
3520    }
3521    Ok(Some(length))
3522}
3523
3524fn parse_integer_bound(
3525    property: &Map<String, Value>,
3526    keyword: &str,
3527    name: &str,
3528    applicable: bool,
3529) -> Result<Option<i128>, GenUiError> {
3530    let Some(value) = property.get(keyword) else {
3531        return Ok(None);
3532    };
3533    if !applicable {
3534        return Err(unsupported_schema(
3535            "wrong_type_keyword",
3536            format!("numeric bound on non-integer property {name:?}"),
3537        ));
3538    }
3539    let number = value
3540        .as_i64()
3541        .map(i128::from)
3542        .or_else(|| value.as_u64().map(i128::from));
3543    number
3544        .ok_or_else(|| {
3545            unsupported_schema(
3546                "malformed_bound",
3547                format!("property {name:?} {keyword} must be an exact integer"),
3548            )
3549        })
3550        .map(Some)
3551}
3552
3553fn parse_metadata(
3554    object: &Map<String, Value>,
3555    key: &str,
3556    location: &str,
3557) -> Result<Option<String>, GenUiError> {
3558    let Some(value) = object.get(key) else {
3559        return Ok(None);
3560    };
3561    let value = value.as_str().ok_or_else(|| {
3562        unsupported_schema(
3563            "malformed_metadata",
3564            format!("{key} at {location} must be a string"),
3565        )
3566    })?;
3567    if value.len() > DEFAULT_MAX_TEXT_BYTES {
3568        return Err(unsupported_schema(
3569            "schema_too_large",
3570            format!("{key} at {location} exceeds the presentation text limit"),
3571        ));
3572    }
3573    Ok(Some(sanitize_single_line_metadata(value)))
3574}
3575
3576fn parse_required_fields(object: &Map<String, Value>) -> Result<BTreeSet<String>, GenUiError> {
3577    let Some(required) = object.get("required") else {
3578        return Ok(BTreeSet::new());
3579    };
3580    let values = required.as_array().ok_or_else(|| {
3581        unsupported_schema("malformed_required", "required must be an array of strings")
3582    })?;
3583    if values.len() > MCP_SCHEMA_MAX_FIELDS {
3584        return Err(unsupported_schema(
3585            "schema_too_large",
3586            format!("required count exceeds {MCP_SCHEMA_MAX_FIELDS}"),
3587        ));
3588    }
3589    let mut output = BTreeSet::new();
3590    for value in values {
3591        let name = value.as_str().ok_or_else(|| {
3592            unsupported_schema("malformed_required", "required contains a non-string entry")
3593        })?;
3594        if name.is_empty() {
3595            return Err(unsupported_schema(
3596                "malformed_required",
3597                "required contains an empty property name",
3598            ));
3599        }
3600        if !output.insert(name.to_owned()) {
3601            return Err(unsupported_schema(
3602                "duplicate_required",
3603                "required contains a duplicate entry",
3604            ));
3605        }
3606    }
3607    Ok(output)
3608}
3609
3610fn reject_unknown_schema_keywords(
3611    object: &Map<String, Value>,
3612    allowed: &[&str],
3613    location: &str,
3614) -> Result<(), GenUiError> {
3615    if let Some(keyword) = object
3616        .keys()
3617        .find(|keyword| !allowed.contains(&keyword.as_str()))
3618    {
3619        return Err(unsupported_schema(
3620            "unsupported_keyword",
3621            format!("unsupported JSON-Schema keyword {keyword:?} at {location}"),
3622        ));
3623    }
3624    Ok(())
3625}
3626
3627fn unsupported_schema(reason: &str, message: impl Into<String>) -> GenUiError {
3628    GenUiError::UnsupportedMcpSchema(format!("{reason}: {}", message.into()))
3629}
3630
3631fn i128_to_json_number(value: i128) -> Option<serde_json::Number> {
3632    i64::try_from(value)
3633        .map(serde_json::Number::from)
3634        .ok()
3635        .or_else(|| u64::try_from(value).map(serde_json::Number::from).ok())
3636}
3637
3638/// A conservative projection of canonical Agent/FalseGreen state. It shows
3639/// only supplied observations and never manufactures an authority verdict.
3640pub fn state_surface(surface_id: impl Into<String>, state: &BTreeMap<String, String>) -> Surface {
3641    let entries = state.clone();
3642    Surface::new(
3643        surface_id,
3644        Component {
3645            id: "state-root".to_owned(),
3646            kind: ComponentKind::KeyValue { entries },
3647        },
3648    )
3649}
3650
3651#[must_use]
3652pub fn session_surface(session: &crate::session::Session) -> Surface {
3653    let mut entries = BTreeMap::new();
3654    entries.insert("session".to_owned(), session.id.clone());
3655    entries.insert("state".to_owned(), format!("{:?}", session.state));
3656    entries.insert("goal".to_owned(), session.goal.clone());
3657    entries.insert("model_turns".to_owned(), session.model_turns.to_string());
3658    entries.insert("tool_calls".to_owned(), session.tool_calls.to_string());
3659    entries.insert(
3660        "repair_cycles".to_owned(),
3661        session.repair_cycles.to_string(),
3662    );
3663    state_surface("agent-session", &entries)
3664}
3665
3666#[must_use]
3667pub fn falsegreen_result_surface(result: &crate::falsegreen::FalseGreenResult) -> Surface {
3668    let mut entries = BTreeMap::new();
3669    entries.insert(
3670        "verification".to_owned(),
3671        format!("{:?}", result.verification),
3672    );
3673    entries.insert(
3674        "verification_status".to_owned(),
3675        result.verification_status.clone(),
3676    );
3677    entries.insert(
3678        "candidate_sha256".to_owned(),
3679        result.candidate_sha256.clone(),
3680    );
3681    entries.insert(
3682        "completion_authority".to_owned(),
3683        format!("{:?}", result.completion_authority),
3684    );
3685    let evidence = vec![EvidenceItem {
3686        label: "canonical evidence".to_owned(),
3687        value: sanitize_text(&result.evidence.to_string()),
3688    }];
3689    Surface::new(
3690        "falsegreen-verification",
3691        Component {
3692            id: "verification".to_owned(),
3693            kind: ComponentKind::Stack {
3694                children: vec![
3695                    Component {
3696                        id: "facts".to_owned(),
3697                        kind: ComponentKind::KeyValue { entries },
3698                    },
3699                    Component {
3700                        id: "evidence".to_owned(),
3701                        kind: ComponentKind::Evidence {
3702                            title: "Evidence (diagnostic projection)".to_owned(),
3703                            items: evidence,
3704                        },
3705                    },
3706                ],
3707            },
3708        },
3709    )
3710}
3711
3712#[must_use]
3713pub fn run_outcome_surface(outcome: &crate::agent::RunOutcome) -> Surface {
3714    let mut entries = BTreeMap::new();
3715    entries.insert("session".to_owned(), outcome.session_id.clone());
3716    entries.insert("state".to_owned(), format!("{:?}", outcome.state));
3717    entries.insert("model_turns".to_owned(), outcome.model_turns.to_string());
3718    entries.insert("tool_calls".to_owned(), outcome.tool_calls.to_string());
3719    entries.insert(
3720        "repair_cycles".to_owned(),
3721        outcome.repair_cycles.to_string(),
3722    );
3723    if let Some(result) = &outcome.falsegreen_result {
3724        entries.insert(
3725            "verification".to_owned(),
3726            format!("{:?}", result.verification),
3727        );
3728        entries.insert(
3729            "candidate_sha256".to_owned(),
3730            result.candidate_sha256.clone(),
3731        );
3732        entries.insert(
3733            "completion_authority".to_owned(),
3734            format!("{:?}", result.completion_authority),
3735        );
3736    }
3737    state_surface("agent-run", &entries)
3738}
3739#[cfg(test)]
3740mod v3_tests {
3741    use super::*;
3742    use serde_json::json;
3743
3744    fn establish_state(
3745        catalog: &mut ActionCatalog,
3746        store: &mut EventStore,
3747        session_id: &str,
3748        principal: &str,
3749        digest: &str,
3750        authorization_context: &str,
3751    ) {
3752        let generation = store
3753            .advance_genui_current_state(session_id, principal, digest, authorization_context)
3754            .expect("trusted state");
3755        catalog
3756            .set_current_state(
3757                session_id,
3758                principal,
3759                generation,
3760                digest,
3761                authorization_context,
3762            )
3763            .expect("catalog state");
3764    }
3765
3766    fn bound_surface(kind: ActionKind) -> (Surface, ActionCatalog) {
3767        let mut surface = Surface::new("surface", Component::text("root", "ok"));
3768        surface.actions.push(Action {
3769            id: ActionCatalog::new_action_id(),
3770            label: "Run tool".to_owned(),
3771            kind,
3772            state_digest: "state-v1".to_owned(),
3773        });
3774        let digest = surface.digest().expect("surface digest");
3775        let action = &surface.actions[0];
3776        let mut catalog = ActionCatalog::default();
3777        catalog
3778            .bind_mcp_action_with_context(
3779                action,
3780                &surface.id,
3781                &digest,
3782                "session-1",
3783                "principal-1",
3784                "default",
3785                1,
3786                &action.state_digest,
3787                "provider_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
3788                "server",
3789                "mcp__server__tool__aaaaaaaaaaaaaaaa",
3790                &"b".repeat(64),
3791                1,
3792                &sha256_hex("falsegreen.tool-policy.v1|consequential|1".as_bytes()),
3793                kind == ActionKind::Consequential,
3794                ActionSourceType::Mcp,
3795            )
3796            .expect("host binding");
3797        catalog
3798            .set_remote_tool_name(action.id.as_str(), "remote-tool")
3799            .expect("remote identity");
3800        (surface, catalog)
3801    }
3802
3803    #[test]
3804    fn model_action_has_no_executable_authority_and_catalog_is_exact() {
3805        let (surface, mut catalog) = bound_surface(ActionKind::Consequential);
3806        catalog
3807            .set_current_state("session-1", "principal-1", 1, "state-v1", "default")
3808            .expect("trusted state");
3809        let encoded = serde_json::to_value(&surface.actions[0]).expect("serialize");
3810        assert!(encoded.get("binding").is_none());
3811        assert!(
3812            serde_json::from_value::<Action>(json!({
3813                "id": surface.actions[0].id,
3814                "label": "Run tool",
3815                "kind": "consequential",
3816                "state_digest": "state-v1",
3817                "callback": "shell"
3818            }))
3819            .is_err()
3820        );
3821        catalog
3822            .validate_surface(&surface, &HostCapabilities::default())
3823            .expect("admission");
3824        let mut relabeled = surface.clone();
3825        relabeled.actions[0].label = "Different".to_owned();
3826        assert!(
3827            catalog
3828                .validate_surface(&relabeled, &HostCapabilities::default())
3829                .is_err()
3830        );
3831        let mut rebound = surface.clone();
3832        rebound.actions[0].kind = ActionKind::McpTool;
3833        assert!(
3834            catalog
3835                .validate_surface(&rebound, &HostCapabilities::default())
3836                .is_err()
3837        );
3838    }
3839
3840    #[test]
3841    fn confirmation_is_host_event_and_replay_is_durable_lifecycle() {
3842        let (surface, mut catalog) = bound_surface(ActionKind::Consequential);
3843        let action_id = surface.actions[0].id.clone();
3844        let payload = json!({"mode": "safe"});
3845        let mut store = EventStore::open_memory().expect("store");
3846        establish_state(
3847            &mut catalog,
3848            &mut store,
3849            "session-1",
3850            "principal-1",
3851            "state-v1",
3852            "default",
3853        );
3854        catalog
3855            .present_action(&mut store, "session-1", &action_id)
3856            .expect("present");
3857        let confirmation = catalog
3858            .request_confirmation(&mut store, "session-1", &action_id, &payload)
3859            .expect("request");
3860        catalog
3861            .append_confirmed_after_live_validation(&mut store, &confirmation)
3862            .expect("confirm");
3863        let identity = catalog
3864            .identity_for(&action_id, &payload)
3865            .expect("identity");
3866        store
3867            .start_genui_action("session-1", &action_id, &identity, true)
3868            .expect("start");
3869        assert!(matches!(
3870            store.start_genui_action("session-1", &action_id, &identity, true),
3871            Err(EventError::GenUiActionAlreadyStarted(_))
3872        ));
3873        let events = store.events("session-1").expect("events");
3874        assert!(
3875            events
3876                .iter()
3877                .any(|event| event.kind == EventKind::GenUiActionConfirmed)
3878        );
3879        assert!(
3880            events
3881                .iter()
3882                .any(|event| event.kind == EventKind::GenUiActionExecutionStarted)
3883        );
3884    }
3885
3886    #[test]
3887    fn restart_reconciles_unknown_external_outcome_without_retry() {
3888        let (surface, mut catalog) = bound_surface(ActionKind::Consequential);
3889        let action_id = surface.actions[0].id.clone();
3890        let payload = json!({"mode": "safe"});
3891        let mut store = EventStore::open_memory().expect("store");
3892        establish_state(
3893            &mut catalog,
3894            &mut store,
3895            "session-1",
3896            "principal-1",
3897            "state-v1",
3898            "default",
3899        );
3900        catalog
3901            .present_action(&mut store, "session-1", &action_id)
3902            .expect("present");
3903        let confirmation = catalog
3904            .request_confirmation(&mut store, "session-1", &action_id, &payload)
3905            .expect("request");
3906        catalog
3907            .append_confirmed_after_live_validation(&mut store, &confirmation)
3908            .expect("confirm");
3909        let identity = catalog
3910            .identity_for(&action_id, &payload)
3911            .expect("identity");
3912        store
3913            .start_genui_action("session-1", &action_id, &identity, true)
3914            .expect("start");
3915        assert_eq!(
3916            store
3917                .reconcile_genui_actions("session-1")
3918                .expect("reconcile"),
3919            1
3920        );
3921        assert!(matches!(
3922            store.start_genui_action("session-1", &action_id, &identity, true),
3923            Err(EventError::GenUiActionOutcomeUnknown(_))
3924        ));
3925    }
3926
3927    #[test]
3928    fn persisted_lifecycle_handles_crash_windows_without_replay() {
3929        let (surface, mut catalog) = bound_surface(ActionKind::Consequential);
3930        let action_id = surface.actions[0].id.clone();
3931        let payload = json!({"mode": "safe"});
3932        let directory = tempfile::tempdir().expect("temporary event store");
3933        let path = directory.path().join("events.db");
3934
3935        // Crash A: confirmation is durable, but execution has not started;
3936        // reopening permits the action to proceed.
3937        {
3938            let mut store = EventStore::open(&path).expect("open");
3939            establish_state(
3940                &mut catalog,
3941                &mut store,
3942                "session-1",
3943                "principal-1",
3944                "state-v1",
3945                "default",
3946            );
3947            catalog
3948                .present_action(&mut store, "session-1", &action_id)
3949                .expect("present");
3950            let confirmation = catalog
3951                .request_confirmation(&mut store, "session-1", &action_id, &payload)
3952                .expect("request");
3953            catalog
3954                .append_confirmed_after_live_validation(&mut store, &confirmation)
3955                .expect("confirm");
3956        }
3957        let mut store = EventStore::open(&path).expect("reopen");
3958        let identity = catalog
3959            .identity_for(&action_id, &payload)
3960            .expect("identity");
3961        store
3962            .start_genui_action("session-1", &action_id, &identity, true)
3963            .expect("retry after pre-start crash");
3964        drop(store);
3965
3966        // Crash B: a committed start with no durable external result becomes
3967        // unknown and is never blindly retried.
3968        let mut store = EventStore::open(&path).expect("reopen after start");
3969        assert_eq!(
3970            store
3971                .reconcile_genui_actions("session-1")
3972                .expect("reconcile"),
3973            1
3974        );
3975        assert!(matches!(
3976            store.start_genui_action("session-1", &action_id, &identity, true),
3977            Err(EventError::GenUiActionOutcomeUnknown(_))
3978        ));
3979
3980        // Crash C: once completion is durable, activation remains single-use
3981        // even after reopening the existing Agent database.
3982        let completed_path = directory.path().join("completed.db");
3983        {
3984            let mut completed = EventStore::open(&completed_path).expect("completed store");
3985            establish_state(
3986                &mut catalog,
3987                &mut completed,
3988                "session-1",
3989                "principal-1",
3990                "state-v1",
3991                "default",
3992            );
3993            catalog
3994                .present_action(&mut completed, "session-1", &action_id)
3995                .expect("present");
3996            let confirmation = catalog
3997                .request_confirmation(&mut completed, "session-1", &action_id, &payload)
3998                .expect("request");
3999            catalog
4000                .append_confirmed_after_live_validation(&mut completed, &confirmation)
4001                .expect("confirm");
4002            completed
4003                .start_genui_action("session-1", &action_id, &identity, true)
4004                .expect("start");
4005            completed
4006                .complete_genui_action("session-1", &action_id, &identity, true, &"c".repeat(64))
4007                .expect("complete");
4008        }
4009        let mut completed = EventStore::open(&completed_path).expect("reopen completed store");
4010        assert!(matches!(
4011            completed.start_genui_action("session-1", &action_id, &identity, true),
4012            Err(EventError::GenUiActionAlreadyStarted(_))
4013        ));
4014    }
4015
4016    #[test]
4017    fn schema_subset_fails_closed_for_unsupported_constructs() {
4018        for schema in [
4019            json!({"type":"object","properties":{},"additionalProperties":true}),
4020            json!({"type":"object","properties":{}}),
4021        ] {
4022            assert!(matches!(
4023                validate_mcp_submission(&schema, &json!({})),
4024                Err(GenUiError::UnsupportedMcpSchema(_))
4025            ));
4026        }
4027        let exact = json!({
4028            "type":"object",
4029            "properties":{"x":{"type":"string"}},
4030            "additionalProperties":false
4031        });
4032        assert!(validate_mcp_submission(&exact, &json!({"x":"ok"})).is_ok());
4033        assert!(matches!(
4034            validate_mcp_submission(&exact, &json!({"x":"ok","extra":true})),
4035            Err(GenUiError::InvalidMcpSubmission(_))
4036        ));
4037        let patterned =
4038            json!({"type":"object","properties":{"x":{"type":"string","pattern":"^[A-Z]+$"}}});
4039        assert!(matches!(
4040            validate_mcp_submission(&patterned, &json!({"x":"OK"})),
4041            Err(GenUiError::UnsupportedMcpSchema(_))
4042        ));
4043        let schema_valued = json!({"type":"object","properties":{"x":{"type":"string"}},"additionalProperties":{"type":"string"}});
4044        assert!(matches!(
4045            validate_mcp_submission(&schema_valued, &json!({"x":"ok","y":"ok"})),
4046            Err(GenUiError::UnsupportedMcpSchema(_))
4047        ));
4048        let integer = json!({"type":"object","properties":{"x":{"type":"integer"}},"additionalProperties":false});
4049        assert!(validate_mcp_submission(&integer, &json!({"x":1})).is_ok());
4050        assert!(validate_mcp_submission(&integer, &json!({"x":1.0})).is_err());
4051        let duplicate_enum =
4052            json!({"type":"object","properties":{"x":{"type":"string","enum":["a","a"]}}});
4053        assert!(matches!(
4054            mcp_tool_surface(
4055                "surface",
4056                "mcp__server__tool__aaaaaaaaaaaaaaaa",
4057                &duplicate_enum,
4058                "state"
4059            ),
4060            Err(GenUiError::UnsupportedMcpSchema(_))
4061        ));
4062    }
4063
4064    #[test]
4065    fn labels_reject_terminal_spoofing_and_width_is_display_cell_based() {
4066        let mut surface = Surface::new("surface", Component::text("root", "界e\u{301}"));
4067        surface.actions.push(Action {
4068            id: ActionCatalog::new_action_id(),
4069            label: "bad\u{202e}label".to_owned(),
4070            kind: ActionKind::McpTool,
4071            state_digest: "state".to_owned(),
4072        });
4073        assert!(surface.validate().is_err());
4074        let negotiated = default_negotiated_capabilities(4);
4075        let safe = Surface::new("safe", Component::text("root", "界e\u{301}"));
4076        let rendered = render_surface_with_capabilities(&safe, &negotiated).expect("render");
4077        use unicode_width::UnicodeWidthStr;
4078        assert!(rendered.lines().all(|line| line.width() <= 4));
4079    }
4080
4081    #[test]
4082    fn negotiated_component_policy_and_shape_bounds_remain_intact() {
4083        let surface = Surface::new("surface", Component::text("root", "ok"));
4084        let mut host = HostCapabilities::default();
4085        host.supported_components.remove("text");
4086        host.unsupported_component_policy = UnsupportedComponentPolicy::Reject;
4087        assert!(matches!(
4088            surface.validate_with(&host),
4089            Err(GenUiError::UnsupportedComponent { .. })
4090        ));
4091        host.unsupported_component_policy = UnsupportedComponentPolicy::Placeholder;
4092        surface.validate_with(&host).expect("placeholder policy");
4093        let oversized = Surface::new(
4094            "large",
4095            Component::text("root", "x".repeat(DEFAULT_MAX_TEXT_BYTES + 1)),
4096        );
4097        assert!(oversized.validate().is_err());
4098    }
4099
4100    #[test]
4101    fn terminal_control_sequences_are_inert_in_general_text() {
4102        let value = sanitize_text(
4103            "\u{1b}[31mred\u{1b}[0m \u{1b}]8;;https://evil.example\u{7}link\u{1b}]8;;\u{7} \u{1b}Psecret\u{1b}\\tail",
4104        );
4105        assert_eq!(value, "red link tail");
4106        assert!(!value.contains('\u{1b}'));
4107    }
4108
4109    #[test]
4110    fn protocol_negotiation_rejects_unknown_major_versions() {
4111        let host = HostCapabilities::default();
4112        assert!(matches!(
4113            negotiate(&[ProtocolVersion { major: 2, minor: 0 }], &host),
4114            Err(GenUiError::UnsupportedProtocol { .. })
4115        ));
4116    }
4117}
4118
4119#[cfg(test)]
4120mod v4_tests {
4121    use super::*;
4122    use serde_json::json;
4123
4124    fn bound() -> (Surface, ActionCatalog) {
4125        let mut surface = Surface::new("v4-surface", Component::text("root", "ok"));
4126        surface.actions.push(Action {
4127            id: ActionCatalog::new_action_id(),
4128            label: "Run".to_owned(),
4129            kind: ActionKind::Consequential,
4130            state_digest: "state-v4".to_owned(),
4131        });
4132        let digest = surface.digest().expect("surface digest");
4133        let action = &surface.actions[0];
4134        let mut catalog = ActionCatalog::default();
4135        catalog
4136            .bind_mcp_action_with_context(
4137                action,
4138                &surface.id,
4139                &digest,
4140                "session-v4",
4141                "principal-v4",
4142                "default",
4143                1,
4144                &action.state_digest,
4145                "provider_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
4146                "server-v4",
4147                "mcp__server__tool__aaaaaaaaaaaaaaaa",
4148                &"a".repeat(64),
4149                1,
4150                &sha256_hex("falsegreen.tool-policy.v1|consequential|1".as_bytes()),
4151                true,
4152                ActionSourceType::Mcp,
4153            )
4154            .expect("bind");
4155        catalog
4156            .set_remote_tool_name(action.id.as_str(), "remote-tool")
4157            .expect("remote identity");
4158        (surface, catalog)
4159    }
4160
4161    #[test]
4162    fn lifecycle_rejects_out_of_order_and_terminal_resurrection() {
4163        let (surface, mut catalog) = bound();
4164        let id = &surface.actions[0].id;
4165        let payload = json!({"x": "y"});
4166        let identity = catalog.identity_for(id, &payload).expect("identity");
4167        let mut store = EventStore::open_memory().expect("store");
4168        let generation = store
4169            .advance_genui_current_state("session-v4", "principal-v4", "state-v4", "default")
4170            .expect("trusted state");
4171        catalog
4172            .set_current_state(
4173                "session-v4",
4174                "principal-v4",
4175                generation,
4176                "state-v4",
4177                "default",
4178            )
4179            .expect("catalog state");
4180        assert!(
4181            store
4182                .append("session-v4", EventKind::GenUiActionConfirmed, &identity)
4183                .is_err()
4184        );
4185        catalog
4186            .present_action(&mut store, "session-v4", id)
4187            .expect("present");
4188        let confirmation = catalog
4189            .request_confirmation(&mut store, "session-v4", id, &payload)
4190            .expect("request");
4191        catalog
4192            .append_confirmed_after_live_validation(&mut store, &confirmation)
4193            .expect("confirm");
4194        store
4195            .start_genui_action("session-v4", id, &identity, true)
4196            .expect("start");
4197        store
4198            .complete_genui_action("session-v4", id, &identity, true, &"d".repeat(64))
4199            .expect("terminal");
4200        assert!(
4201            catalog
4202                .request_confirmation(&mut store, "session-v4", id, &payload)
4203                .is_err()
4204        );
4205        assert!(
4206            store
4207                .start_genui_action("session-v4", id, &identity, true)
4208                .is_err()
4209        );
4210    }
4211
4212    #[test]
4213    fn generic_event_append_cannot_write_genui_namespace() {
4214        let (_, catalog) = bound();
4215        let identity = catalog
4216            .identity_for(catalog.bindings.keys().next().expect("action"), &json!({}))
4217            .expect("identity");
4218        let mut store = EventStore::open_memory().expect("store");
4219        assert!(matches!(
4220            store.append("session-v4", EventKind::GenUiActionPresented, &identity),
4221            Err(EventError::GenUiEventMustUseLifecycle)
4222        ));
4223    }
4224
4225    #[test]
4226    fn host_state_and_renderer_admission_stale_actions() {
4227        let (surface, mut catalog) = bound();
4228        catalog
4229            .set_current_state("session-v4", "principal-v4", 2, "state-v5", "default")
4230            .expect("state update");
4231        assert!(
4232            catalog
4233                .validate_surface(&surface, &HostCapabilities::default())
4234                .is_err()
4235        );
4236
4237        let mut safe_surface = surface.clone();
4238        safe_surface.actions[0].state_digest = "state-v5".to_owned();
4239        let digest = safe_surface.digest().expect("digest");
4240        catalog.remove(&safe_surface.actions[0].id);
4241        catalog
4242            .bind_mcp_action_with_context(
4243                &safe_surface.actions[0],
4244                &safe_surface.id,
4245                &digest,
4246                "session-v4",
4247                "principal-v4",
4248                "default",
4249                2,
4250                "state-v5",
4251                "provider_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
4252                "server-v4",
4253                "mcp__server__tool__aaaaaaaaaaaaaaaa",
4254                &"a".repeat(64),
4255                1,
4256                &sha256_hex("falsegreen.tool-policy.v1|consequential|1".as_bytes()),
4257                true,
4258                ActionSourceType::Mcp,
4259            )
4260            .expect("rebind");
4261        let mut caps = default_negotiated_capabilities(80);
4262        caps.supported_components.remove("text");
4263        let rendered =
4264            render_surface_with_capabilities_and_catalog(&safe_surface, &caps, &mut catalog)
4265                .expect("placeholder render");
4266        assert!(rendered.contains("unsupported component"));
4267        assert!(!rendered.contains("gui_action_"));
4268        assert!(catalog.resolve(&safe_surface.actions[0].id).is_none());
4269    }
4270
4271    #[test]
4272    fn catalog_mutations_are_transactional_and_bump_once() {
4273        let mut store = EventStore::open_memory().expect("store");
4274        let generation = store
4275            .advance_genui_current_state("tx-session", "tx-principal", "state", "auth")
4276            .expect("durable state");
4277        let action = Action {
4278            id: ActionCatalog::new_action_id(),
4279            label: "Read".to_owned(),
4280            kind: ActionKind::McpTool,
4281            state_digest: "state".to_owned(),
4282        };
4283        let surface = Surface::new("tx-surface", Component::text("root", "ok"));
4284        let surface_digest = surface.digest().expect("surface digest");
4285        let schema_digest = "a".repeat(64);
4286        let mut catalog = ActionCatalog::default();
4287        let initial_generation = catalog.generation();
4288        catalog
4289            .bind_mcp_action_with_remote(
4290                &store,
4291                &action,
4292                &surface.id,
4293                &surface_digest,
4294                "tx-session",
4295                "tx-principal",
4296                "provider",
4297                "server",
4298                "exposed",
4299                "remote",
4300                &schema_digest,
4301                false,
4302            )
4303            .expect("successful transactional bind");
4304        assert_eq!(catalog.generation(), initial_generation + 1);
4305        let committed_digest = catalog.digest();
4306        assert!(catalog.resolve(&action.id).is_some());
4307        catalog
4308            .bind_action_owner(&action.id, "owner-one")
4309            .expect("first owner");
4310        let owner_generation = catalog.generation();
4311        let owner_digest = catalog.digest();
4312        assert!(catalog.bind_action_owner(&action.id, "owner-two").is_err());
4313        assert_eq!(catalog.generation(), owner_generation);
4314        assert_eq!(catalog.digest(), owner_digest);
4315
4316        let before_failed_bind = catalog.digest();
4317        let before_failed_generation = catalog.generation();
4318        assert!(
4319            catalog
4320                .bind_mcp_action_with_remote(
4321                    &store,
4322                    &action,
4323                    &surface.id,
4324                    &surface_digest,
4325                    "tx-session",
4326                    "tx-principal",
4327                    "provider",
4328                    "server",
4329                    "exposed",
4330                    "",
4331                    &schema_digest,
4332                    false,
4333                )
4334                .is_err()
4335        );
4336        assert_eq!(catalog.generation(), before_failed_generation);
4337        assert_eq!(catalog.digest(), before_failed_bind);
4338
4339        let stale = Action {
4340            id: ActionCatalog::new_action_id(),
4341            label: "Stale".to_owned(),
4342            kind: ActionKind::McpTool,
4343            state_digest: "stale-state".to_owned(),
4344        };
4345        let stale_digest = catalog.digest();
4346        let stale_generation = catalog.generation();
4347        assert!(
4348            catalog
4349                .bind_mcp_action_with_remote(
4350                    &store,
4351                    &stale,
4352                    &surface.id,
4353                    &surface_digest,
4354                    "tx-session",
4355                    "tx-principal",
4356                    "provider",
4357                    "server",
4358                    "exposed",
4359                    "remote",
4360                    &schema_digest,
4361                    false,
4362                )
4363                .is_err()
4364        );
4365        assert_eq!(catalog.generation(), stale_generation);
4366        assert_eq!(catalog.digest(), stale_digest);
4367        assert!(catalog.resolve(&stale.id).is_none());
4368
4369        let invalid = Action {
4370            id: ActionCatalog::new_action_id(),
4371            label: "Local".to_owned(),
4372            kind: ActionKind::Navigation,
4373            state_digest: "state".to_owned(),
4374        };
4375        let invalid_digest = catalog.digest();
4376        let invalid_generation = catalog.generation();
4377        assert!(
4378            catalog
4379                .bind_mcp_action_with_context(
4380                    &invalid,
4381                    &surface.id,
4382                    &surface_digest,
4383                    "tx-session",
4384                    "tx-principal",
4385                    "auth",
4386                    generation,
4387                    "state",
4388                    "provider",
4389                    "server",
4390                    "exposed",
4391                    &schema_digest,
4392                    1,
4393                    &"b".repeat(64),
4394                    false,
4395                    ActionSourceType::Mcp,
4396                )
4397                .is_err()
4398        );
4399        assert_eq!(catalog.generation(), invalid_generation);
4400        assert_eq!(catalog.digest(), invalid_digest);
4401        assert_ne!(committed_digest, owner_digest);
4402    }
4403
4404    #[test]
4405    fn public_binding_requires_existing_durable_state_and_cannot_seed_authority() {
4406        let mut surface = Surface::new("safe-bind", Component::text("root", "ok"));
4407        surface.actions.push(Action {
4408            id: ActionCatalog::new_action_id(),
4409            label: "Run".to_owned(),
4410            kind: ActionKind::Consequential,
4411            state_digest: "attacker".to_owned(),
4412        });
4413        let digest = surface.digest().expect("surface digest");
4414        let mut catalog = ActionCatalog::default();
4415        let store = EventStore::open_memory().expect("store");
4416        let result = catalog.bind_mcp_action_with_remote(
4417            &store,
4418            &surface.actions[0],
4419            &surface.id,
4420            &digest,
4421            "safe-session",
4422            "safe-principal",
4423            "provider_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
4424            "server",
4425            "mcp__server__tool__aaaaaaaaaaaaaaaa",
4426            "remote-tool",
4427            &"b".repeat(64),
4428            true,
4429        );
4430        assert!(result.is_err());
4431        assert!(catalog.resolve(&surface.actions[0].id).is_none());
4432        assert!(
4433            store
4434                .genui_current_state("safe-session", "safe-principal")
4435                .expect("state query")
4436                .is_none()
4437        );
4438    }
4439
4440    #[test]
4441    fn store_renderer_fails_closed_for_deleted_or_advanced_state() {
4442        let (surface, mut catalog) = bound();
4443        let directory = tempfile::tempdir().expect("store directory");
4444        let path = directory.path().join("events.db");
4445        let mut store = EventStore::open(&path).expect("store");
4446        let generation = store
4447            .advance_genui_current_state("session-v4", "principal-v4", "state-v4", "default")
4448            .expect("state");
4449        catalog
4450            .set_current_state(
4451                "session-v4",
4452                "principal-v4",
4453                generation,
4454                "state-v4",
4455                "default",
4456            )
4457            .expect("catalog state");
4458        let capabilities = default_negotiated_capabilities(80);
4459        let rendered = render_surface_with_capabilities_and_catalog_with_store(
4460            &surface,
4461            &capabilities,
4462            &mut catalog,
4463            &store,
4464        )
4465        .expect("valid render");
4466        assert!(rendered.contains(&surface.actions[0].id));
4467
4468        store
4469            .advance_genui_current_state("session-v4", "principal-v4", "state-v5", "default")
4470            .expect("state advance");
4471        let mut advanced_catalog = catalog.clone();
4472        let rendered = render_surface_with_capabilities_and_catalog_with_store(
4473            &surface,
4474            &capabilities,
4475            &mut advanced_catalog,
4476            &store,
4477        )
4478        .expect("stale state is a valid presentation");
4479        assert!(!rendered.contains(&surface.actions[0].id));
4480        assert!(advanced_catalog.resolve(&surface.actions[0].id).is_none());
4481
4482        let path = store.path().expect("path").to_owned();
4483        drop(store);
4484        rusqlite::Connection::open(&path)
4485            .expect("raw connection")
4486            .execute(
4487                "DELETE FROM genui_current_states WHERE session_id = ?1 AND principal = ?2",
4488                rusqlite::params!["session-v4", "principal-v4"],
4489            )
4490            .expect("delete state");
4491        let store = EventStore::open(&path).expect("reopen");
4492        let rendered = render_surface_with_capabilities_and_catalog_with_store(
4493            &surface,
4494            &capabilities,
4495            &mut catalog,
4496            &store,
4497        )
4498        .expect("missing state is a valid presentation");
4499        assert!(!rendered.contains(&surface.actions[0].id));
4500        assert!(catalog.resolve(&surface.actions[0].id).is_none());
4501    }
4502
4503    #[test]
4504    fn store_renderer_preserves_valid_sibling_when_other_state_is_missing() {
4505        let mut surface = Surface::new(
4506            "mixed-store",
4507            Component {
4508                id: "root".to_owned(),
4509                kind: ComponentKind::Stack {
4510                    children: vec![
4511                        Component::text("valid", "valid"),
4512                        Component::text("stale", "stale"),
4513                    ],
4514                },
4515            },
4516        );
4517        surface.actions = vec![
4518            Action {
4519                id: "gui_action_store_valid".to_owned(),
4520                label: "Valid".to_owned(),
4521                kind: ActionKind::McpTool,
4522                state_digest: "state-v4".to_owned(),
4523            },
4524            Action {
4525                id: "gui_action_store_stale".to_owned(),
4526                label: "Stale".to_owned(),
4527                kind: ActionKind::McpTool,
4528                state_digest: "state-v4".to_owned(),
4529            },
4530        ];
4531        let digest = surface.digest().expect("surface digest");
4532        let policy = sha256_hex("falsegreen.tool-policy.v1|read_only|1".as_bytes());
4533        let mut catalog = ActionCatalog::default();
4534        for (action, session_id, principal) in [
4535            (
4536                &surface.actions[0],
4537                "mixed-valid-session",
4538                "mixed-valid-principal",
4539            ),
4540            (
4541                &surface.actions[1],
4542                "mixed-stale-session",
4543                "mixed-stale-principal",
4544            ),
4545        ] {
4546            catalog
4547                .bind_mcp_action_with_context(
4548                    action,
4549                    &surface.id,
4550                    &digest,
4551                    session_id,
4552                    principal,
4553                    "default",
4554                    1,
4555                    "state-v4",
4556                    "provider_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
4557                    "server",
4558                    "mcp__server__tool__aaaaaaaaaaaaaaaa",
4559                    &"a".repeat(64),
4560                    1,
4561                    &policy,
4562                    false,
4563                    ActionSourceType::Mcp,
4564                )
4565                .expect("binding");
4566        }
4567        let directory = tempfile::tempdir().expect("store directory");
4568        let path = directory.path().join("events.db");
4569        let mut store = EventStore::open(&path).expect("store");
4570        let generation = store
4571            .advance_genui_current_state(
4572                "mixed-valid-session",
4573                "mixed-valid-principal",
4574                "state-v4",
4575                "default",
4576            )
4577            .expect("valid durable state");
4578        catalog
4579            .set_current_state(
4580                "mixed-valid-session",
4581                "mixed-valid-principal",
4582                generation,
4583                "state-v4",
4584                "default",
4585            )
4586            .expect("valid catalog state");
4587        let rendered = render_surface_with_capabilities_and_catalog_with_store(
4588            &surface,
4589            &default_negotiated_capabilities(80),
4590            &mut catalog,
4591            &store,
4592        )
4593        .expect("mixed render");
4594        assert!(rendered.contains("gui_action_store_valid"));
4595        assert!(!rendered.contains("gui_action_store_stale"));
4596        assert!(catalog.resolve("gui_action_store_valid").is_some());
4597        assert!(catalog.resolve("gui_action_store_stale").is_none());
4598    }
4599
4600    #[test]
4601    fn store_renderer_validates_exact_incoming_action_and_preserves_valid_siblings() {
4602        let mut surface = Surface::new(
4603            "exact-render",
4604            Component {
4605                id: "root".to_owned(),
4606                kind: ComponentKind::Stack {
4607                    children: vec![Component::text("one", "one"), Component::text("two", "two")],
4608                },
4609            },
4610        );
4611        surface.actions = vec![
4612            Action {
4613                id: "gui_action_exact_one".to_owned(),
4614                label: "One".to_owned(),
4615                kind: ActionKind::McpTool,
4616                state_digest: "state-exact".to_owned(),
4617            },
4618            Action {
4619                id: "gui_action_exact_two".to_owned(),
4620                label: "Two".to_owned(),
4621                kind: ActionKind::McpTool,
4622                state_digest: "state-exact".to_owned(),
4623            },
4624        ];
4625        let digest = surface.digest().expect("structural digest");
4626        let policy = sha256_hex("falsegreen.tool-policy.v1|read_only|1".as_bytes());
4627        let mut catalog = ActionCatalog::default();
4628        for action in &surface.actions {
4629            catalog
4630                .bind_mcp_action_with_context(
4631                    action,
4632                    &surface.id,
4633                    &digest,
4634                    "exact-session",
4635                    "exact-principal",
4636                    "default",
4637                    1,
4638                    "state-exact",
4639                    "provider_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
4640                    "server",
4641                    "mcp__server__tool__aaaaaaaaaaaaaaaa",
4642                    &"a".repeat(64),
4643                    1,
4644                    &policy,
4645                    false,
4646                    ActionSourceType::Mcp,
4647                )
4648                .expect("binding");
4649            catalog
4650                .set_remote_tool_name(&action.id, "remote-tool")
4651                .expect("remote identity");
4652        }
4653        let mut store = EventStore::open_memory().expect("store");
4654        let generation = store
4655            .advance_genui_current_state(
4656                "exact-session",
4657                "exact-principal",
4658                "state-exact",
4659                "default",
4660            )
4661            .expect("state");
4662        catalog
4663            .set_current_state(
4664                "exact-session",
4665                "exact-principal",
4666                generation,
4667                "state-exact",
4668                "default",
4669            )
4670            .expect("catalog state");
4671        let caps = default_negotiated_capabilities(80);
4672        let valid = render_surface_with_capabilities_and_catalog_with_store(
4673            &surface,
4674            &caps,
4675            &mut catalog.clone(),
4676            &store,
4677        )
4678        .expect("valid render");
4679        assert!(valid.contains("gui_action_exact_one"));
4680        assert!(valid.contains("gui_action_exact_two"));
4681
4682        let mut mutated = surface.clone();
4683        mutated.actions[0].label = "Totally Safe".to_owned();
4684        let mut mutated_catalog = catalog.clone();
4685        let rendered = render_surface_with_capabilities_and_catalog_with_store(
4686            &mutated,
4687            &caps,
4688            &mut mutated_catalog,
4689            &store,
4690        )
4691        .expect("mutated render");
4692        assert!(!rendered.contains("gui_action_exact_one"));
4693        assert!(rendered.contains("gui_action_exact_two"));
4694
4695        let mut revoked_catalog = catalog;
4696        revoked_catalog.remove("gui_action_exact_one");
4697        let rendered = render_surface_with_capabilities_and_catalog_with_store(
4698            &surface,
4699            &caps,
4700            &mut revoked_catalog,
4701            &store,
4702        )
4703        .expect("revoked render");
4704        assert!(!rendered.contains("gui_action_exact_one"));
4705    }
4706
4707    #[test]
4708    fn dialect_and_numeric_subset_are_exact() {
4709        for dialect in [
4710            "urn:attacker-draft-07-marker",
4711            "draft-07-but-not-really",
4712            "https://json-schema.org/draft/2019-09/schema",
4713        ] {
4714            assert!(
4715                validate_mcp_submission(&json!({"$schema": dialect, "type": "object"}), &json!({}))
4716                    .is_err()
4717            );
4718        }
4719        assert!(validate_mcp_submission(
4720            &json!({"$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "additionalProperties": false}),
4721            &json!({})
4722        )
4723        .is_ok());
4724        assert!(
4725            validate_mcp_submission(
4726                &json!({"type": "object", "properties": {"x": {"type": "number"}}}),
4727                &json!({"x": 1.2})
4728            )
4729            .is_err()
4730        );
4731    }
4732
4733    #[test]
4734    fn schema_keyword_applicability_fails_closed() {
4735        for schema in [
4736            json!({"type":"object", "properties":{"x":{"type":"string","minimum":1}}}),
4737            json!({"type":"object", "properties":{"x":{"type":"boolean","minimum":1}}}),
4738            json!({"type":"object", "properties":{"x":{"type":"integer","minLength":1}}}),
4739            json!({"type":"object", "properties":{"x":{"type":"boolean","minLength":1}}}),
4740            json!({"type":"object", "properties":{"x":{"type":"integer","enum":[true]}}}),
4741        ] {
4742            assert!(validate_mcp_submission(&schema, &json!({})).is_err());
4743        }
4744        let valid = json!({
4745            "$schema":"https://json-schema.org/draft/2020-12/schema",
4746            "type":"object",
4747            "properties":{
4748                "name":{"type":"string","minLength":1},
4749                "count":{"type":"integer","minimum":0},
4750                "enabled":{"type":"boolean"}
4751            },
4752            "additionalProperties":false
4753        });
4754        assert!(
4755            validate_mcp_submission(&valid, &json!({"name":"ok","count":1,"enabled":true})).is_ok()
4756        );
4757    }
4758
4759    #[test]
4760    fn schema_bounds_are_iterative_and_reject_deep_annotations_before_digest() {
4761        let mut just_below = json!("leaf");
4762        for _ in 0..(MCP_SCHEMA_MAX_JSON_DEPTH - 1) {
4763            just_below = Value::Array(vec![just_below]);
4764        }
4765        assert!(validate_schema_json_bounds(&just_below).is_ok());
4766
4767        let mut at_limit = json!("leaf");
4768        for _ in 0..MCP_SCHEMA_MAX_JSON_DEPTH {
4769            at_limit = Value::Array(vec![at_limit]);
4770        }
4771        assert!(validate_schema_json_bounds(&at_limit).is_ok());
4772
4773        let mut over_limit = json!("leaf");
4774        for _ in 0..=MCP_SCHEMA_MAX_JSON_DEPTH {
4775            over_limit = Value::Array(vec![over_limit]);
4776        }
4777        let error = validate_schema_json_bounds(&over_limit).expect_err("depth limit");
4778        assert!(error.to_string().contains("schema_depth_exceeded"));
4779
4780        for nesting in [100usize, 1_000, 5_000] {
4781            let mut annotation = json!("attacker");
4782            for _ in 0..nesting {
4783                annotation = Value::Array(vec![annotation]);
4784            }
4785            let mut schema_object = Map::new();
4786            schema_object.insert("type".to_owned(), Value::String("object".to_owned()));
4787            schema_object.insert("additionalProperties".to_owned(), Value::Bool(false));
4788            schema_object.insert("examples".to_owned(), annotation);
4789            let schema = Value::Object(schema_object);
4790            let error = adapt_mcp_schema(&schema).expect_err("deep annotation must fail closed");
4791            assert!(
4792                error.to_string().contains("schema_depth_exceeded"),
4793                "nesting {nesting}: {error}"
4794            );
4795            // The public digest path is independently stack-safe for the same
4796            // hostile value, even though adaptation rejects it.
4797            assert_eq!(mcp_schema_digest(&schema).len(), 64);
4798            // serde_json::Value itself has a recursive destructor for deeply
4799            // nested arrays. Leak this synthetic attacker value after the
4800            // adapter returns so the probe measures adapter safety rather than
4801            // an unrelated caller-side destructor limitation.
4802            std::mem::forget(schema);
4803        }
4804    }
4805
4806    #[test]
4807    fn examples_and_id_are_explicitly_unsupported() {
4808        let examples = json!({
4809            "type": "object",
4810            "additionalProperties": false,
4811            "examples": [{"x": "nested"}]
4812        });
4813        let error = adapt_mcp_schema(&examples).expect_err("examples policy");
4814        assert!(error.to_string().contains("unsupported_annotation"));
4815
4816        let property_examples = json!({
4817            "type": "object",
4818            "additionalProperties": false,
4819            "properties": {"x": {"type": "string", "examples": ["ok"]}}
4820        });
4821        let error = adapt_mcp_schema(&property_examples).expect_err("property examples policy");
4822        assert!(error.to_string().contains("unsupported_annotation"));
4823
4824        for identifier in ["not a URI", "\u{1b}[31murn:attacker", "urn:ok"] {
4825            let schema = json!({
4826                "type": "object",
4827                "additionalProperties": false,
4828                "$id": identifier
4829            });
4830            let error = adapt_mcp_schema(&schema).expect_err("$id policy");
4831            assert!(error.to_string().contains("unsupported_keyword"));
4832        }
4833    }
4834
4835    #[test]
4836    fn root_default_uses_exact_payload_validation() {
4837        let base = json!({
4838            "type": "object",
4839            "properties": {
4840                "x": {"type": "integer", "minimum": 1, "maximum": 3},
4841                "mode": {"type": "string", "enum": ["safe", "fast"]}
4842            },
4843            "required": ["x"],
4844            "additionalProperties": false
4845        });
4846        for default in [
4847            json!({}),
4848            json!({"x": 1, "extra": true}),
4849            json!({"x": "1"}),
4850            json!({"x": 9}),
4851            json!({"x": 1, "mode": "unsafe"}),
4852        ] {
4853            let mut schema = base.clone();
4854            schema["default"] = default;
4855            let error = adapt_mcp_schema(&schema).expect_err("invalid root default");
4856            assert!(error.to_string().contains("malformed_default"));
4857        }
4858
4859        let mut valid = base;
4860        valid["default"] = json!({"x": 2});
4861        assert!(adapt_mcp_schema(&valid).is_ok());
4862    }
4863
4864    #[test]
4865    fn metadata_is_single_line_and_support_matches_surface_limits() {
4866        let fake = "line1\n[gui_action_fake] line2\r\n\t\u{202e}\u{200b}";
4867        let safe = sanitize_single_line_metadata(fake);
4868        assert_eq!(safe, "line1 [gui_action_fake] line2");
4869        assert!(!safe.contains('\n'));
4870        assert!(!safe.contains('\r'));
4871        assert!(!safe.contains('\t'));
4872
4873        let mut supported_properties = Map::new();
4874        let mut enum_values = Vec::new();
4875        for index in 0..MCP_SCHEMA_MAX_ENUM_VALUES {
4876            enum_values.push(json!(format!("choice-{index}")));
4877        }
4878        supported_properties.insert(
4879            "choice".to_owned(),
4880            json!({"type": "string", "enum": enum_values}),
4881        );
4882        let supported = json!({
4883            "type": "object",
4884            "properties": supported_properties,
4885            "additionalProperties": false
4886        });
4887        let ir = adapt_mcp_schema(&supported).expect("just-under enum limit supported");
4888        assert_eq!(ir.fields[0].enum_values.len(), MCP_SCHEMA_MAX_ENUM_VALUES);
4889        assert!(
4890            mcp_tool_surface(
4891                "supported",
4892                "mcp__fixture__tool__aaaaaaaaaaaaaaaa",
4893                &supported,
4894                "state"
4895            )
4896            .is_ok()
4897        );
4898
4899        let mut over_values = Vec::new();
4900        for index in 0..=MCP_SCHEMA_MAX_ENUM_VALUES {
4901            over_values.push(json!(format!("choice-{index}")));
4902        }
4903        let refused = json!({
4904            "type": "object",
4905            "properties": {"choice": {"type": "string", "enum": over_values}},
4906            "additionalProperties": false
4907        });
4908        let error = adapt_mcp_schema(&refused).expect_err("over enum limit refused");
4909        assert!(error.to_string().contains("schema_too_large"));
4910        assert!(
4911            mcp_tool_surface(
4912                "refused",
4913                "mcp__fixture__tool__aaaaaaaaaaaaaaaa",
4914                &refused,
4915                "state"
4916            )
4917            .is_err()
4918        );
4919
4920        let collision = json!({
4921            "type": "object",
4922            "properties": {"choice": {"type": "string", "enum": ["a\nb", "a b"]}},
4923            "additionalProperties": false
4924        });
4925        let error = adapt_mcp_schema(&collision).expect_err("sanitized enum collision");
4926        assert!(error.to_string().contains("ambiguous_enum"));
4927    }
4928
4929    #[test]
4930    fn renderer_revokes_only_unsupported_subtree_actions() {
4931        let mut surface = Surface::new(
4932            "mixed",
4933            Component {
4934                id: "root".to_owned(),
4935                kind: ComponentKind::Stack {
4936                    children: vec![
4937                        Component::text("supported", "supported"),
4938                        Component {
4939                            id: "unsupported".to_owned(),
4940                            kind: ComponentKind::Markdown {
4941                                markdown: "degraded".to_owned(),
4942                            },
4943                        },
4944                    ],
4945                },
4946            },
4947        );
4948        surface.actions = vec![
4949            Action {
4950                id: "gui_action_supported".to_owned(),
4951                label: "Supported".to_owned(),
4952                kind: ActionKind::Consequential,
4953                state_digest: "state".to_owned(),
4954            },
4955            Action {
4956                id: "gui_action_unsupported".to_owned(),
4957                label: "Unsupported".to_owned(),
4958                kind: ActionKind::Consequential,
4959                state_digest: "state".to_owned(),
4960            },
4961        ];
4962        let digest = surface.digest().expect("surface digest");
4963        let mut catalog = ActionCatalog::default();
4964        for action in &surface.actions {
4965            catalog
4966                .bind_mcp_action_with_context(
4967                    action,
4968                    &surface.id,
4969                    &digest,
4970                    "session-mixed",
4971                    "principal-mixed",
4972                    "default",
4973                    1,
4974                    &action.state_digest,
4975                    "provider_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
4976                    "server-mixed",
4977                    "mcp__server__tool__aaaaaaaaaaaaaaaa",
4978                    &"a".repeat(64),
4979                    1,
4980                    &sha256_hex("falsegreen.tool-policy.v1|consequential|1".as_bytes()),
4981                    true,
4982                    ActionSourceType::Mcp,
4983                )
4984                .expect("bind");
4985            catalog
4986                .set_remote_tool_name(action.id.as_str(), "remote-tool")
4987                .expect("remote identity");
4988        }
4989        catalog
4990            .set_current_state("session-mixed", "principal-mixed", 1, "state", "default")
4991            .expect("trusted state");
4992        catalog
4993            .bind_action_owner("gui_action_supported", "supported")
4994            .expect("supported owner");
4995        catalog
4996            .bind_action_owner("gui_action_unsupported", "unsupported")
4997            .expect("unsupported owner");
4998        let mut capabilities = default_negotiated_capabilities(80);
4999        capabilities.supported_components.remove("markdown");
5000        let rendered =
5001            render_surface_with_capabilities_and_catalog(&surface, &capabilities, &mut catalog)
5002                .expect("placeholder render");
5003        assert!(rendered.contains("gui_action_supported"));
5004        assert!(!rendered.contains("gui_action_unsupported"));
5005        assert!(catalog.resolve("gui_action_supported").is_some());
5006        assert!(catalog.resolve("gui_action_unsupported").is_none());
5007    }
5008
5009    #[test]
5010    fn schema_ir_preserves_typed_values_and_reconstructs_without_coercion() {
5011        let schema = json!({
5012            "$schema": "https://json-schema.org/draft/2020-12/schema",
5013            "type": "object",
5014            "title": "Input\u{1b}[31m",
5015            "description": "description\u{202e}marker",
5016            "properties": {
5017                "mode": {
5018                    "type": "string",
5019                    "enum": ["safe", "fast"],
5020                    "default": "safe",
5021                    "title": "Mode\u{200b}",
5022                    "description": "Choose a mode"
5023                },
5024                "count": {
5025                    "type": "integer",
5026                    "minimum": 1,
5027                    "maximum": 9,
5028                    "enum": [1, 3, 9],
5029                    "default": 3
5030                },
5031                "enabled": {
5032                    "type": "boolean",
5033                    "enum": [true, false]
5034                },
5035                "note": {
5036                    "type": "string",
5037                    "minLength": 1,
5038                    "maxLength": 20
5039                }
5040            },
5041            "required": ["mode", "count", "enabled"],
5042            "additionalProperties": false
5043        });
5044        let ir = adapt_mcp_schema(&schema).expect("exact schema IR");
5045        assert_eq!(ir.adapter, MCP_SCHEMA_ADAPTER_ID);
5046        assert_eq!(
5047            ir.dialect_uri(),
5048            Some("https://json-schema.org/draft/2020-12/schema")
5049        );
5050        assert_eq!(ir.source_digest(), mcp_schema_digest(&schema));
5051        assert_eq!(ir.title.as_deref(), Some("Input"));
5052        assert_eq!(ir.description.as_deref(), Some("description marker"));
5053        assert_eq!(ir.fields.len(), 4);
5054        let form = ir.form_fields();
5055        let count = ir
5056            .fields
5057            .iter()
5058            .find(|field| field.name == "count")
5059            .unwrap();
5060        assert_eq!(
5061            count.enum_values,
5062            vec![
5063                McpScalarValue::Integer(1),
5064                McpScalarValue::Integer(3),
5065                McpScalarValue::Integer(9)
5066            ]
5067        );
5068        let count_form = form.iter().find(|field| field.id == "count").unwrap();
5069        assert_eq!(count_form.choice_values, vec![json!(1), json!(3), json!(9)]);
5070        let enabled_form = form.iter().find(|field| field.id == "enabled").unwrap();
5071        assert_eq!(enabled_form.choice_values, vec![json!(true), json!(false)]);
5072        let mode_form = form.iter().find(|field| field.id == "mode").unwrap();
5073        assert_eq!(mode_form.label, "Mode");
5074        assert_eq!(mode_form.description.as_deref(), Some("Choose a mode"));
5075
5076        let values = BTreeMap::from([
5077            ("mode".to_owned(), json!("fast")),
5078            ("count".to_owned(), json!(9)),
5079            ("enabled".to_owned(), json!(true)),
5080        ]);
5081        let payload = reconstruct_mcp_payload(&schema, &values).expect("payload");
5082        assert_eq!(
5083            payload,
5084            json!({"mode": "fast", "count": 9, "enabled": true})
5085        );
5086        assert!(
5087            reconstruct_mcp_payload(
5088                &schema,
5089                &BTreeMap::from([
5090                    ("mode".to_owned(), json!("fast")),
5091                    ("count".to_owned(), json!("9")),
5092                    ("enabled".to_owned(), json!(true)),
5093                ])
5094            )
5095            .is_err()
5096        );
5097        assert!(
5098            reconstruct_mcp_payload(
5099                &schema,
5100                &BTreeMap::from([
5101                    ("mode".to_owned(), json!("safe")),
5102                    ("count".to_owned(), json!(3)),
5103                    ("enabled".to_owned(), json!(true)),
5104                    ("note".to_owned(), Value::Null),
5105                ])
5106            )
5107            .is_err()
5108        );
5109    }
5110
5111    #[test]
5112    fn schema_ir_rejects_unsupported_constructs_with_reason_codes() {
5113        let cases = [
5114            (
5115                json!({"type":"object","additionalProperties":false,"unknown":true}),
5116                "unsupported_keyword",
5117            ),
5118            (
5119                json!({"$schema":"https://json-schema.org/draft/2019-09/schema","type":"object","additionalProperties":false}),
5120                "unsupported_dialect",
5121            ),
5122            (
5123                json!({"type":"object","properties":{"n":{"type":"number"}},"additionalProperties":false}),
5124                "unsupported_type",
5125            ),
5126            (
5127                json!({"type":"object","properties":{"x":{"type":"string","enum":["a","a"]}},"additionalProperties":false}),
5128                "duplicate_enum",
5129            ),
5130            (
5131                json!({"type":"object","properties":{"x":{"type":"string","enum":[1]}},"additionalProperties":false}),
5132                "enum_type_mismatch",
5133            ),
5134            (
5135                json!({"type":"object","properties":{"x":{"type":"integer","minimum":1.5}},"additionalProperties":false}),
5136                "malformed_bound",
5137            ),
5138            (
5139                serde_json::from_str::<Value>(r#"{"type":"object","properties":{"x":{"type":"integer","minimum":18446744073709551616}},"additionalProperties":false}"#).expect("huge bound JSON"),
5140                "malformed_bound",
5141            ),
5142            (
5143                json!({"type":"object","properties":{"x":{"type":"string","default":false}},"additionalProperties":false}),
5144                "malformed_default",
5145            ),
5146            (
5147                json!({"type":"object","properties":{"x":{"type":"object","properties":{}}},"additionalProperties":false}),
5148                "unsupported_nested_object",
5149            ),
5150            (
5151                json!({"type":"object","properties":{"x":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}),
5152                "unsupported_nested_array",
5153            ),
5154        ];
5155        for (schema, reason) in cases {
5156            let error = adapt_mcp_schema(&schema).expect_err("schema must fail closed");
5157            assert!(
5158                error.to_string().contains(reason),
5159                "{reason} missing from {error}"
5160            );
5161        }
5162
5163        let mut too_many_fields = Map::new();
5164        for index in 0..=MCP_SCHEMA_MAX_FIELDS {
5165            too_many_fields.insert(format!("field_{index}"), json!({"type": "string"}));
5166        }
5167        let too_many_fields = json!({
5168            "type": "object",
5169            "properties": too_many_fields,
5170            "additionalProperties": false
5171        });
5172        let error = adapt_mcp_schema(&too_many_fields).expect_err("field limit");
5173        assert!(error.to_string().contains("schema_too_large"));
5174
5175        let too_large = json!({
5176            "type": "object",
5177            "description": "x".repeat(MCP_SCHEMA_MAX_BYTES),
5178            "additionalProperties": false
5179        });
5180        let error = adapt_mcp_schema(&too_large).expect_err("byte limit");
5181        assert!(error.to_string().contains("schema_too_large"));
5182    }
5183
5184    #[test]
5185    fn schema_digest_changes_for_presentation_and_semantic_drift() {
5186        let first = json!({
5187            "type":"object",
5188            "properties":{"mode":{"type":"string","enum":["safe"]}},
5189            "additionalProperties":false
5190        });
5191        let mut second = first.clone();
5192        second["properties"]["mode"]["enum"] = json!(["unsafe"]);
5193        assert_ne!(mcp_schema_digest(&first), mcp_schema_digest(&second));
5194        let mut presentation = first.clone();
5195        presentation["properties"]["mode"]["title"] = json!("Mode");
5196        assert_ne!(mcp_schema_digest(&first), mcp_schema_digest(&presentation));
5197        let first_ir = adapt_mcp_schema(&first).expect("first IR");
5198        let second_ir = adapt_mcp_schema(&second).expect("second IR");
5199        assert_ne!(first_ir.source_digest(), second_ir.source_digest());
5200        let mut identity_only = first.clone();
5201        identity_only["$id"] = json!("urn:falsegreen:g3:alternate");
5202        let identity_error = adapt_mcp_schema(&identity_only).expect_err("$id is unsupported");
5203        assert!(identity_error.to_string().contains("unsupported_keyword"));
5204        assert!(
5205            first_ir
5206                .form_fields()
5207                .iter()
5208                .all(|field| !field.label.contains('\n'))
5209        );
5210    }
5211
5212    #[test]
5213    fn schema_adapter_performance_shapes_are_bounded() {
5214        use std::time::{Duration, Instant};
5215
5216        for (label, count) in [
5217            ("small", 5usize),
5218            ("medium", 25),
5219            ("large", 100),
5220            ("maximum", MCP_SCHEMA_MAX_FIELDS),
5221        ] {
5222            let mut properties = Map::new();
5223            for index in 0..count {
5224                let name = format!("field_{index}");
5225                let property = match index % 3 {
5226                    0 => json!({"type": "string", "minLength": 0, "maxLength": 64}),
5227                    1 => json!({"type": "integer", "minimum": 0, "maximum": 100}),
5228                    _ => json!({"type": "boolean"}),
5229                };
5230                properties.insert(name, property);
5231            }
5232            let schema = json!({
5233                "type": "object",
5234                "properties": properties,
5235                "additionalProperties": false
5236            });
5237            let started = Instant::now();
5238            let ir = adapt_mcp_schema(&schema).expect("performance schema IR");
5239            let adaptation = started.elapsed();
5240            let surface = mcp_tool_surface(
5241                "performance",
5242                "mcp__fixture__tool__aaaaaaaaaaaaaaaa",
5243                &schema,
5244                "state",
5245            )
5246            .expect("performance surface");
5247            let started = Instant::now();
5248            let rendered =
5249                render_surface_with_capabilities(&surface, &default_negotiated_capabilities(120))
5250                    .expect("performance render");
5251            let rendering = started.elapsed();
5252            assert_eq!(ir.fields.len(), count);
5253            assert!(surface.validate_shape(&HostCapabilities::default()).is_ok());
5254            assert!(!rendered.is_empty());
5255            eprintln!(
5256                "G3 schema performance {label}: adapt={adaptation:?}, render_surface={rendering:?}"
5257            );
5258        }
5259
5260        let mut annotation = json!("attacker");
5261        for _ in 0..5_000 {
5262            annotation = Value::Array(vec![annotation]);
5263        }
5264        let mut deep_object = Map::new();
5265        deep_object.insert("type".to_owned(), Value::String("object".to_owned()));
5266        deep_object.insert("additionalProperties".to_owned(), Value::Bool(false));
5267        deep_object.insert("examples".to_owned(), annotation);
5268        let deep_schema = Value::Object(deep_object);
5269        let started = Instant::now();
5270        let error = adapt_mcp_schema(&deep_schema).expect_err("deep attacker schema");
5271        let rejection = started.elapsed();
5272        assert!(error.to_string().contains("schema_depth_exceeded"));
5273        assert!(
5274            rejection < Duration::from_secs(1),
5275            "deep-schema rejection took {rejection:?}"
5276        );
5277        std::mem::forget(deep_schema);
5278    }
5279}