Skip to main content

uptrakit_surfaces/
protocol.rs

1use serde::{Deserialize, Serialize};
2use serde_json::{Map, Value};
3use std::collections::{HashMap, HashSet};
4use thiserror::Error;
5use uptrakit_shared_macros::wire_safe_enum;
6use uuid::Uuid;
7
8use crate::{
9    Capability, CapabilitySet, DataSourceDescriptor, DataSourceKind, FrameworkGeneration,
10    FrameworkGenerationRange, InteractionDescriptor, InteractionHttpMethod, InteractionId,
11    InteractionKind, InteractionTransport, ProviderKind, RESERVED_PARAM_KEYS, SchemaContract,
12    Scope, SlotValidationError, SurfaceDescriptor, SurfaceId, SurfaceNode, SurfaceSlotDef,
13    Targeting, validate_slot_id, validate_surface_identifier,
14};
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct SurfaceRegistration {
18    pub provider: ProviderIdentity,
19    pub framework_generation: FrameworkGeneration,
20    pub capabilities: CapabilitySet,
21    pub effective_tenant_binding: EffectiveTenantBinding,
22    #[serde(default, skip_serializing_if = "Vec::is_empty")]
23    pub surfaces: Vec<RegisteredSurface>,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub encryption_metadata: Option<ProviderEncryptionMetadata>,
26}
27
28impl SurfaceRegistration {
29    /// Validates a registration payload against policy and contract rules.
30    ///
31    /// # Errors
32    /// Returns [`SurfaceRegistrationError`] when any validation check fails,
33    /// including unsupported framework generation, missing required
34    /// capabilities, invalid or duplicate identifiers, slot violations,
35    /// provider-kind mismatches, invalid interaction/data-source declarations,
36    /// or broken cross-reference links in the surface graph.
37    pub fn validate_against(
38        &self,
39        policy: &SurfaceRegistrationPolicy,
40    ) -> Result<(), SurfaceRegistrationError> {
41        validate_supported_generation(self, policy)?;
42        validate_required_registration_capabilities(self, policy)?;
43        validate_registered_surfaces(self)?;
44        Ok(())
45    }
46}
47
48fn validate_supported_generation(
49    registration: &SurfaceRegistration,
50    policy: &SurfaceRegistrationPolicy,
51) -> Result<(), SurfaceRegistrationError> {
52    if policy
53        .supported_generation
54        .includes(registration.framework_generation)
55    {
56        return Ok(());
57    }
58
59    Err(registration_error(
60        SurfaceRegistrationErrorCode::UnsupportedGeneration,
61        format!(
62            "framework generation {}.{} is outside supported range {}.{}..={}.{}",
63            registration.framework_generation.major,
64            registration.framework_generation.minor,
65            policy.supported_generation.min.major,
66            policy.supported_generation.min.minor,
67            policy.supported_generation.max.major,
68            policy.supported_generation.max.minor,
69        ),
70    ))
71}
72
73fn validate_required_registration_capabilities(
74    registration: &SurfaceRegistration,
75    policy: &SurfaceRegistrationPolicy,
76) -> Result<(), SurfaceRegistrationError> {
77    if registration
78        .capabilities
79        .contains_all(&policy.required_capabilities)
80    {
81        return Ok(());
82    }
83
84    Err(missing_capability(
85        "registration is missing one or more required capabilities",
86    ))
87}
88
89fn validate_registered_surfaces(
90    registration: &SurfaceRegistration,
91) -> Result<(), SurfaceRegistrationError> {
92    RegisteredSurfacesValidator::new(registration).validate()
93}
94
95struct RegisteredSurfacesValidator<'a> {
96    provider_kind: ProviderKind,
97    capabilities: &'a CapabilitySet,
98    surfaces: &'a [RegisteredSurface],
99    surface_ids: HashSet<&'a str>,
100    single_entry_slots: HashSet<&'static str>,
101}
102
103impl<'a> RegisteredSurfacesValidator<'a> {
104    fn new(registration: &'a SurfaceRegistration) -> Self {
105        Self {
106            provider_kind: registration.provider.provider_kind,
107            capabilities: &registration.capabilities,
108            surfaces: &registration.surfaces,
109            surface_ids: HashSet::new(),
110            single_entry_slots: HashSet::new(),
111        }
112    }
113
114    fn validate(mut self) -> Result<(), SurfaceRegistrationError> {
115        for surface in self.surfaces {
116            self.validate_surface(surface)?;
117        }
118
119        Ok(())
120    }
121
122    fn validate_surface(
123        &mut self,
124        surface: &'a RegisteredSurface,
125    ) -> Result<(), SurfaceRegistrationError> {
126        validate_surface_descriptor_rules(
127            surface,
128            self.provider_kind,
129            self.capabilities,
130            &mut self.surface_ids,
131            &mut self.single_entry_slots,
132        )?;
133
134        let interaction_methods = validate_surface_interaction_rules(surface)?;
135        validate_workflow_step_references(
136            &surface.descriptor.surface_id,
137            &surface.interactions,
138            &interaction_methods,
139        )?;
140
141        let data_source_ids = validate_surface_data_source_rules(surface, &interaction_methods)?;
142        validate_root_node_references(
143            &surface.descriptor.surface_id,
144            &surface.descriptor.root_node,
145            &interaction_methods,
146            &surface.interactions,
147            &data_source_ids,
148        )
149    }
150}
151
152fn validate_surface_descriptor_rules<'a>(
153    surface: &'a RegisteredSurface,
154    registration_provider_kind: ProviderKind,
155    capabilities: &CapabilitySet,
156    surface_ids: &mut HashSet<&'a str>,
157    single_entry_slots: &mut HashSet<&'static str>,
158) -> Result<(), SurfaceRegistrationError> {
159    validate_unique_surface_id(surface, surface_ids)?;
160    validate_surface_provider_kind(surface, registration_provider_kind)?;
161    let slot_def = validate_surface_slot(surface)?;
162    validate_single_entry_slot_occupancy(slot_def, single_entry_slots)?;
163    validate_surface_priority_range(surface, slot_def)?;
164    validate_surface_required_capabilities(surface, capabilities)?;
165    validate_surface_usage_capabilities(
166        &surface.descriptor.surface_id,
167        capabilities,
168        &surface.descriptor.root_node,
169        &surface.descriptor.targeting,
170        &surface.interactions,
171        &surface.data_sources,
172    )
173}
174
175fn validate_unique_surface_id<'a>(
176    surface: &'a RegisteredSurface,
177    surface_ids: &mut HashSet<&'a str>,
178) -> Result<(), SurfaceRegistrationError> {
179    if surface_ids.insert(surface.descriptor.surface_id.as_str()) {
180        return Ok(());
181    }
182
183    Err(invalid_contract(format!(
184        "duplicate surface_id `{}` within registration batch",
185        surface.descriptor.surface_id
186    )))
187}
188
189fn validate_surface_provider_kind(
190    surface: &RegisteredSurface,
191    registration_provider_kind: ProviderKind,
192) -> Result<(), SurfaceRegistrationError> {
193    if surface.descriptor.provider_kind == registration_provider_kind {
194        return Ok(());
195    }
196
197    Err(invalid_contract(format!(
198        "surface `{}` provider_kind does not match registration provider_kind",
199        surface.descriptor.surface_id
200    )))
201}
202
203fn validate_surface_slot(
204    surface: &RegisteredSurface,
205) -> Result<&'static SurfaceSlotDef, SurfaceRegistrationError> {
206    validate_slot_id(&surface.descriptor.slot).map_err(map_slot_validation_error)
207}
208
209fn validate_single_entry_slot_occupancy(
210    slot_def: &'static SurfaceSlotDef,
211    single_entry_slots: &mut HashSet<&'static str>,
212) -> Result<(), SurfaceRegistrationError> {
213    if slot_def.multi_entry || single_entry_slots.insert(slot_def.id) {
214        return Ok(());
215    }
216
217    Err(invalid_contract(format!(
218        "slot `{}` is single-entry and cannot accept multiple surfaces in one registration batch",
219        slot_def.id
220    )))
221}
222
223fn validate_surface_priority_range(
224    surface: &RegisteredSurface,
225    slot_def: &'static SurfaceSlotDef,
226) -> Result<(), SurfaceRegistrationError> {
227    if surface.descriptor.provider_kind == ProviderKind::BuiltIn {
228        return Ok(());
229    }
230
231    if surface.descriptor.priority >= slot_def.provider_priority_min
232        && surface.descriptor.priority <= slot_def.provider_priority_max
233    {
234        return Ok(());
235    }
236
237    Err(invalid_contract(format!(
238        "surface `{}` priority {} is outside slot `{}` provider range {}..={}",
239        surface.descriptor.surface_id,
240        surface.descriptor.priority,
241        slot_def.id,
242        slot_def.provider_priority_min,
243        slot_def.provider_priority_max
244    )))
245}
246
247fn validate_surface_required_capabilities(
248    surface: &RegisteredSurface,
249    capabilities: &CapabilitySet,
250) -> Result<(), SurfaceRegistrationError> {
251    if capabilities.contains_all(&surface.descriptor.required_capabilities) {
252        return Ok(());
253    }
254
255    Err(missing_capability(format!(
256        "surface `{}` requires capabilities not advertised by registration",
257        surface.descriptor.surface_id
258    )))
259}
260
261fn validate_surface_interaction_rules(
262    surface: &RegisteredSurface,
263) -> Result<HashMap<&str, Vec<InteractionHttpMethod>>, SurfaceRegistrationError> {
264    let mut interaction_methods: HashMap<&str, Vec<InteractionHttpMethod>> = HashMap::new();
265    for interaction in &surface.interactions {
266        validate_unique_interaction_id(surface, interaction, &mut interaction_methods)?;
267        validate_interaction_method(surface, interaction)?;
268        validate_interaction_params(surface, interaction)?;
269        validate_interaction_provider_rules(surface, interaction)?;
270        validate_descriptor_gated_provider_invocable(surface, interaction)?;
271    }
272
273    Ok(interaction_methods)
274}
275
276/// Re-keyed on `(interaction_id, effective_http_method)` (REST method model,
277/// spec B1): a single `interaction_id` may legitimately register under
278/// multiple HTTP methods within one surface. This map doubles as the
279/// reference-resolution index consumed by `resolve_interaction_reference`
280/// and `require_pair_reference`.
281fn validate_unique_interaction_id<'a>(
282    surface: &'a RegisteredSurface,
283    interaction: &'a InteractionDescriptor,
284    interaction_methods: &mut HashMap<&'a str, Vec<InteractionHttpMethod>>,
285) -> Result<(), SurfaceRegistrationError> {
286    let method = interaction.effective_http_method();
287    let methods = interaction_methods
288        .entry(interaction.interaction_id.as_str())
289        .or_default();
290    if methods.contains(&method) {
291        return Err(invalid_contract(format!(
292            "duplicate interaction `{}` [{}] within surface `{}`",
293            interaction.interaction_id, method, surface.descriptor.surface_id
294        )));
295    }
296    methods.push(method);
297    Ok(())
298}
299
300/// Kind/method matrix (spec B1): `Other(_)` declared methods are always
301/// rejected; `DataLoad` must not declare PUT/DELETE; `Workflow` must declare
302/// POST; every other kind must not declare GET.
303fn validate_interaction_method(
304    surface: &RegisteredSurface,
305    interaction: &InteractionDescriptor,
306) -> Result<(), SurfaceRegistrationError> {
307    let id = &interaction.interaction_id;
308    let surface_id = &surface.descriptor.surface_id;
309    if matches!(interaction.http_method, InteractionHttpMethod::Other(_)) {
310        return Err(invalid_contract(format!(
311            "interaction `{id}` in surface `{surface_id}` declares an unknown http_method"
312        )));
313    }
314    match interaction.kind {
315        // POST is indistinguishable from an omitted field (serde default) and
316        // normalizes to GET; only PUT/DELETE are observably-wrong declarations.
317        InteractionKind::DataLoad => {
318            if matches!(
319                interaction.http_method,
320                InteractionHttpMethod::Put | InteractionHttpMethod::Delete
321            ) {
322                return Err(invalid_contract(format!(
323                    "data-load interaction `{id}` in surface `{surface_id}` must use GET"
324                )));
325            }
326        }
327        InteractionKind::Workflow => {
328            if interaction.http_method != InteractionHttpMethod::Post {
329                return Err(invalid_contract(format!(
330                    "workflow interaction `{id}` in surface `{surface_id}` must use POST"
331                )));
332            }
333        }
334        // Navigate is deliberately in this catch-all: spec B1 assigns it no
335        // method (it is never HTTP-dispatched), so it keeps the declared-method
336        // rule like FormSubmit/MutationAction/ConfirmableAction.
337        _ => {
338            if interaction.http_method == InteractionHttpMethod::Get {
339                return Err(invalid_contract(format!(
340                    "interaction `{id}` in surface `{surface_id}` is not a data-load and cannot use GET"
341                )));
342            }
343        }
344    }
345    Ok(())
346}
347
348/// Params rules (spec §4 rule 1): reserved key collisions, duplicate keys,
349/// non-scalar `DataLoad` param schemas (GET query strings carry scalars
350/// only), and non-empty `sensitive_fields` on `DataLoad` (GET params travel
351/// in query strings, never a request body).
352fn validate_interaction_params(
353    surface: &RegisteredSurface,
354    interaction: &InteractionDescriptor,
355) -> Result<(), SurfaceRegistrationError> {
356    let id = &interaction.interaction_id;
357    let surface_id = &surface.descriptor.surface_id;
358    let mut seen = HashSet::new();
359    for field in &interaction.params {
360        if RESERVED_PARAM_KEYS.contains(&field.key.as_str()) {
361            return Err(invalid_contract(format!(
362                "interaction `{id}` in surface `{surface_id}` declares reserved param key `{}`",
363                field.key
364            )));
365        }
366        if !seen.insert(field.key.as_str()) {
367            return Err(invalid_contract(format!(
368                "interaction `{id}` in surface `{surface_id}` declares duplicate param key `{}`",
369                field.key
370            )));
371        }
372        if interaction.kind == InteractionKind::DataLoad
373            && !matches!(
374                field.schema,
375                SchemaContract::String
376                    | SchemaContract::Integer
377                    | SchemaContract::Number
378                    | SchemaContract::Boolean
379            )
380        {
381            return Err(invalid_contract(format!(
382                "data-load interaction `{id}` in surface `{surface_id}` param `{}` must be a scalar schema",
383                field.key
384            )));
385        }
386    }
387    if interaction.kind == InteractionKind::DataLoad && !interaction.sensitive_fields.is_empty() {
388        return Err(invalid_contract(format!(
389            "data-load interaction `{id}` in surface `{surface_id}` must not declare sensitive_fields \
390             (GET params travel in query strings)"
391        )));
392    }
393    Ok(())
394}
395
396fn validate_interaction_provider_rules(
397    surface: &RegisteredSurface,
398    interaction: &InteractionDescriptor,
399) -> Result<(), SurfaceRegistrationError> {
400    interaction
401        .validate_for_provider(surface.descriptor.provider_kind)
402        .map_err(|err| invalid_contract(err.to_string()))
403}
404
405/// Descriptor-level companion to
406/// [`InteractionDescriptor::validate_for_provider`], which sees only the
407/// interaction and so cannot check the surface it belongs to: a `Service`
408/// provider may not set `provider_invocable` on an interaction whose home
409/// surface descriptor is action-gated.
410///
411/// Presence-only read of the wire string `descriptor.required_action` is
412/// sound at admission: the registry parses it to a typed `Action` right
413/// after contract validation and rejects unparseable values, so a
414/// present-but-invalid gate never reaches the runtime path. The
415/// `provider_kind` consulted here is trustworthy only via a two-hop pin:
416/// `validate_surface_provider_kind` pins descriptor.provider_kind to the
417/// registration's provider_kind, and the registry's
418/// `validate_registration_basics` pins that to the trusted connection
419/// source_kind. Do not simplify either hop away.
420fn validate_descriptor_gated_provider_invocable(
421    surface: &RegisteredSurface,
422    interaction: &InteractionDescriptor,
423) -> Result<(), SurfaceRegistrationError> {
424    if interaction.provider_invocable
425        && surface.descriptor.provider_kind == ProviderKind::Service
426        && surface.descriptor.required_action.is_some()
427    {
428        return Err(invalid_contract(format!(
429            "interaction `{}` in surface `{}` sets provider_invocable under an \
430             action-gated surface descriptor — not allowed for service-registered surfaces",
431            interaction.interaction_id, surface.descriptor.surface_id
432        )));
433    }
434    Ok(())
435}
436
437fn validate_surface_data_source_rules<'a>(
438    surface: &'a RegisteredSurface,
439    interaction_methods: &HashMap<&str, Vec<InteractionHttpMethod>>,
440) -> Result<HashSet<&'a str>, SurfaceRegistrationError> {
441    let mut data_source_ids: HashSet<&str> = HashSet::new();
442    for data_source in &surface.data_sources {
443        validate_unique_data_source_id(surface, data_source, &mut data_source_ids)?;
444        validate_data_source_provider_rules(surface, data_source)?;
445        validate_data_source_reference_rules(surface, data_source, interaction_methods)?;
446    }
447
448    Ok(data_source_ids)
449}
450
451/// `DataSourceKind::ProviderQuery.operation_id` must resolve to a same-surface
452/// interaction registered under GET (NEW rule — previously unvalidated).
453fn validate_data_source_reference_rules(
454    surface: &RegisteredSurface,
455    data_source: &DataSourceDescriptor,
456    interaction_methods: &HashMap<&str, Vec<InteractionHttpMethod>>,
457) -> Result<(), SurfaceRegistrationError> {
458    if let DataSourceKind::ProviderQuery { operation_id } = &data_source.kind {
459        require_pair_reference(
460            interaction_methods,
461            operation_id,
462            InteractionHttpMethod::Get,
463            || {
464                format!(
465                    "surface `{}` data source `{}` references unknown provider_query operation_id `{}`",
466                    surface.descriptor.surface_id, data_source.data_source_id, operation_id
467                )
468            },
469        )?;
470    }
471    Ok(())
472}
473
474fn validate_unique_data_source_id<'a>(
475    surface: &'a RegisteredSurface,
476    data_source: &'a DataSourceDescriptor,
477    data_source_ids: &mut HashSet<&'a str>,
478) -> Result<(), SurfaceRegistrationError> {
479    if data_source_ids.insert(data_source.data_source_id.as_str()) {
480        return Ok(());
481    }
482
483    Err(invalid_contract(format!(
484        "duplicate data_source_id `{}` within surface `{}`",
485        data_source.data_source_id, surface.descriptor.surface_id
486    )))
487}
488
489fn validate_data_source_provider_rules(
490    surface: &RegisteredSurface,
491    data_source: &DataSourceDescriptor,
492) -> Result<(), SurfaceRegistrationError> {
493    data_source
494        .validate_for_provider(surface.descriptor.provider_kind)
495        .map_err(|err| invalid_contract(err.to_string()))
496}
497
498fn map_slot_validation_error(err: SlotValidationError) -> SurfaceRegistrationError {
499    let code = match err {
500        SlotValidationError::UnknownSlot(_) => SurfaceRegistrationErrorCode::InvalidSlot,
501        SlotValidationError::InvalidIdentifier(_) => SurfaceRegistrationErrorCode::InvalidContract,
502    };
503
504    registration_error(code, err.to_string())
505}
506
507fn registration_error(
508    code: SurfaceRegistrationErrorCode,
509    message: impl Into<String>,
510) -> SurfaceRegistrationError {
511    SurfaceRegistrationError::new(code, message.into())
512}
513
514fn invalid_contract(message: impl Into<String>) -> SurfaceRegistrationError {
515    registration_error(SurfaceRegistrationErrorCode::InvalidContract, message)
516}
517
518fn missing_capability(message: impl Into<String>) -> SurfaceRegistrationError {
519    registration_error(SurfaceRegistrationErrorCode::MissingCapability, message)
520}
521
522fn validate_surface_usage_capabilities(
523    surface_id: &SurfaceId,
524    capabilities: &CapabilitySet,
525    root_node: &SurfaceNode,
526    targeting: &Targeting,
527    interactions: &[InteractionDescriptor],
528    data_sources: &[DataSourceDescriptor],
529) -> Result<(), SurfaceRegistrationError> {
530    validate_node_capabilities(surface_id, capabilities, root_node)?;
531
532    let targeting_capability = match targeting {
533        Targeting::Universal => Capability::UniversalTargeting,
534        Targeting::Targeted => Capability::TargetedTargeting,
535    };
536    require_capability(
537        capabilities,
538        targeting_capability,
539        surface_id,
540        "targeting mode",
541    )?;
542
543    for interaction in interactions {
544        let kind_capability = match interaction.kind {
545            InteractionKind::MutationAction => Capability::MutationAction,
546            InteractionKind::FormSubmit => Capability::FormSubmit,
547            InteractionKind::Workflow => Capability::Workflow,
548            InteractionKind::Navigate => Capability::Navigate,
549            InteractionKind::DataLoad => Capability::DataLoad,
550            InteractionKind::ConfirmableAction => Capability::ConfirmableAction,
551        };
552        require_capability(
553            capabilities,
554            kind_capability,
555            surface_id,
556            "interaction kind",
557        )?;
558
559        if matches!(
560            &interaction.transport,
561            InteractionTransport::ProviderProxied
562        ) {
563            require_capability(
564                capabilities,
565                Capability::ProviderInitiatedActions,
566                surface_id,
567                "interaction transport",
568            )?;
569        }
570
571        if !interaction.sensitive_fields.is_empty() {
572            require_capability(
573                capabilities,
574                Capability::SensitiveFields,
575                surface_id,
576                "sensitive fields",
577            )?;
578        }
579    }
580
581    for data_source in data_sources {
582        let kind_capability = match &data_source.kind {
583            DataSourceKind::Static { .. } => Capability::StaticDataSource,
584            DataSourceKind::ControllerQuery { .. } => Capability::ControllerQueryDataSource,
585            DataSourceKind::ProviderQuery { .. } => Capability::ProviderQueryDataSource,
586        };
587        require_capability(
588            capabilities,
589            kind_capability,
590            surface_id,
591            "data source kind",
592        )?;
593    }
594
595    Ok(())
596}
597
598fn validate_node_capabilities(
599    surface_id: &SurfaceId,
600    capabilities: &CapabilitySet,
601    node: &SurfaceNode,
602) -> Result<(), SurfaceRegistrationError> {
603    let node_capability = match node {
604        SurfaceNode::Section { children, .. } => {
605            for child in children {
606                validate_node_capabilities(surface_id, capabilities, child)?;
607            }
608            Capability::SectionNode
609        }
610        SurfaceNode::TextBlock { .. } => Capability::TextBlockNode,
611        SurfaceNode::KeyValue { .. } => Capability::KeyValueNode,
612        SurfaceNode::Table { .. } => Capability::TableNode,
613        SurfaceNode::Form { .. } => Capability::FormNode,
614        SurfaceNode::ActionBar { .. } => Capability::ActionBarNode,
615        SurfaceNode::Tabs { tabs } => {
616            for tab in tabs {
617                validate_node_capabilities(surface_id, capabilities, &tab.root)?;
618            }
619            Capability::TabsNode
620        }
621        SurfaceNode::Callout { .. } => Capability::CalloutNode,
622        SurfaceNode::EmptyState { .. } => Capability::EmptyStateNode,
623        SurfaceNode::ModalTrigger { modal_nodes, .. } => {
624            for child in modal_nodes {
625                validate_node_capabilities(surface_id, capabilities, child)?;
626            }
627            Capability::ModalTriggerNode
628        }
629        SurfaceNode::WorkflowTrigger { step_nodes, .. } => {
630            for child in step_nodes {
631                validate_node_capabilities(surface_id, capabilities, child)?;
632            }
633            Capability::WorkflowTriggerNode
634        }
635    };
636
637    require_capability(capabilities, node_capability, surface_id, "root_node kind")
638}
639
640fn require_capability(
641    capabilities: &CapabilitySet,
642    required: Capability,
643    surface_id: &SurfaceId,
644    usage: &str,
645) -> Result<(), SurfaceRegistrationError> {
646    if capabilities.0.contains(&required) {
647        return Ok(());
648    }
649
650    Err(missing_capability(format!(
651        "surface `{}` uses {} that requires capability `{}`",
652        surface_id,
653        usage,
654        serde_json::to_string(&required)
655            .unwrap_or_else(|_| "\"unknown\"".to_owned())
656            .trim_matches('"')
657    )))
658}
659
660fn validate_root_node_references(
661    surface_id: &SurfaceId,
662    node: &SurfaceNode,
663    interaction_methods: &HashMap<&str, Vec<InteractionHttpMethod>>,
664    interactions: &[InteractionDescriptor],
665    data_source_ids: &HashSet<&str>,
666) -> Result<(), SurfaceRegistrationError> {
667    RootNodeReferenceValidator::new(
668        surface_id,
669        interaction_methods,
670        interactions,
671        data_source_ids,
672    )
673    .validate(node)
674}
675
676struct RootNodeReferenceValidator<'a> {
677    surface_id: &'a SurfaceId,
678    interaction_methods: &'a HashMap<&'a str, Vec<InteractionHttpMethod>>,
679    interactions: &'a [InteractionDescriptor],
680    data_source_ids: &'a HashSet<&'a str>,
681}
682
683impl<'a> RootNodeReferenceValidator<'a> {
684    fn new(
685        surface_id: &'a SurfaceId,
686        interaction_methods: &'a HashMap<&'a str, Vec<InteractionHttpMethod>>,
687        interactions: &'a [InteractionDescriptor],
688        data_source_ids: &'a HashSet<&'a str>,
689    ) -> Self {
690        Self {
691            surface_id,
692            interaction_methods,
693            interactions,
694            data_source_ids,
695        }
696    }
697
698    fn validate(&self, node: &SurfaceNode) -> Result<(), SurfaceRegistrationError> {
699        match node {
700            // header_action_ids are kind-gated in registry.rs, not resolved here (out of scope).
701            SurfaceNode::Section { children, .. } => self.validate_children(children),
702            SurfaceNode::TextBlock { .. } => Ok(()),
703            SurfaceNode::KeyValue { data_source_id } => {
704                self.require_data_source_reference(data_source_id.as_str())
705            }
706            SurfaceNode::Table {
707                data_source_id,
708                row_actions,
709                ..
710            } => {
711                self.require_data_source_reference(data_source_id.as_str())?;
712                self.validate_table_row_actions(row_actions)
713            }
714            SurfaceNode::Form {
715                interaction_id,
716                http_method,
717            } => {
718                self.require_root_interaction_reference(
719                    interaction_id.as_str(),
720                    http_method.as_ref(),
721                )?;
722                self.require_form_pre_load_reference(interaction_id.as_str(), http_method.as_ref())
723            }
724            SurfaceNode::ActionBar { action_ids } => self.validate_action_bar(action_ids),
725            SurfaceNode::Tabs { tabs } => self.validate_tabs(tabs),
726            SurfaceNode::Callout { .. } | SurfaceNode::EmptyState { .. } => Ok(()),
727            SurfaceNode::ModalTrigger {
728                interaction_id,
729                http_method,
730                modal_nodes,
731            } => {
732                self.require_root_interaction_reference(
733                    interaction_id.as_str(),
734                    http_method.as_ref(),
735                )?;
736                self.validate_children(modal_nodes)
737            }
738            SurfaceNode::WorkflowTrigger {
739                interaction_id,
740                step_nodes,
741            } => {
742                self.require_root_workflow_trigger_reference(interaction_id.as_str())?;
743                self.validate_children(step_nodes)
744            }
745        }
746    }
747
748    fn validate_children(&self, nodes: &[SurfaceNode]) -> Result<(), SurfaceRegistrationError> {
749        for child in nodes {
750            self.validate(child)?;
751        }
752        Ok(())
753    }
754
755    fn validate_table_row_actions(
756        &self,
757        row_actions: &[crate::SurfaceTableRowAction],
758    ) -> Result<(), SurfaceRegistrationError> {
759        for row_action in row_actions {
760            resolve_interaction_reference(
761                self.interaction_methods,
762                row_action.interaction_id.as_str(),
763                row_action.http_method.as_ref(),
764                || {
765                    format!(
766                        "surface `{}` table references unknown row-action interaction_id `{}`",
767                        self.surface_id, row_action.interaction_id
768                    )
769                },
770            )?;
771        }
772        Ok(())
773    }
774
775    fn validate_action_bar(
776        &self,
777        action_ids: &[crate::ActionRef],
778    ) -> Result<(), SurfaceRegistrationError> {
779        for action_ref in action_ids {
780            resolve_interaction_reference(
781                self.interaction_methods,
782                action_ref.interaction_id().as_str(),
783                action_ref.http_method(),
784                || {
785                    format!(
786                        "surface `{}` root_node references unknown interaction_id `{}`",
787                        self.surface_id,
788                        action_ref.interaction_id()
789                    )
790                },
791            )?;
792        }
793        Ok(())
794    }
795
796    fn validate_tabs(&self, tabs: &[crate::SurfaceTab]) -> Result<(), SurfaceRegistrationError> {
797        let mut tab_ids: HashSet<&str> = HashSet::new();
798
799        for tab in tabs {
800            validate_surface_identifier(tab.id.as_str()).map_err(|err| {
801                invalid_contract(format!(
802                    "surface `{}` root_node contains invalid tab id `{}`: {}",
803                    self.surface_id, tab.id, err
804                ))
805            })?;
806
807            if !tab_ids.insert(tab.id.as_str()) {
808                return Err(invalid_contract(format!(
809                    "surface `{}` root_node contains duplicate tab id `{}` within one tabs node",
810                    self.surface_id, tab.id
811                )));
812            }
813
814            self.validate(&tab.root)?;
815        }
816
817        Ok(())
818    }
819
820    fn require_root_interaction_reference(
821        &self,
822        interaction_id: &str,
823        declared_method: Option<&InteractionHttpMethod>,
824    ) -> Result<(), SurfaceRegistrationError> {
825        resolve_interaction_reference(
826            self.interaction_methods,
827            interaction_id,
828            declared_method,
829            || {
830                format!(
831                    "surface `{}` root_node references unknown interaction_id `{}`",
832                    self.surface_id, interaction_id
833                )
834            },
835        )
836    }
837
838    fn require_root_workflow_trigger_reference(
839        &self,
840        interaction_id: &str,
841    ) -> Result<(), SurfaceRegistrationError> {
842        require_pair_reference(
843            self.interaction_methods,
844            interaction_id,
845            InteractionHttpMethod::Post,
846            || {
847                format!(
848                    "surface `{}` root_node references unknown interaction_id `{}`",
849                    self.surface_id, interaction_id
850                )
851            },
852        )
853    }
854
855    /// Root-level `Form` `form_ui.pre_load_interaction_id` (NEW — previously
856    /// unvalidated; workflow-step forms already had this check). Looks up the
857    /// target interaction by `(id, declared_method)` to find its `form_ui`
858    /// since `SurfaceNode::Form` itself carries no `form_ui` field.
859    fn require_form_pre_load_reference(
860        &self,
861        interaction_id: &str,
862        declared_method: Option<&InteractionHttpMethod>,
863    ) -> Result<(), SurfaceRegistrationError> {
864        // `declared_method: None` matches the first interaction with this id
865        // regardless of method, i.e. first-match-wins. That's only safe
866        // because `require_root_interaction_reference` runs first (in the
867        // caller chain) and already rejects an ambiguous reference — an id
868        // with `declared_method: None` that resolves to more than one
869        // registered method. Do not reorder validation so this lookup runs
870        // before that check: it would silently pick an arbitrary method's
871        // `form_ui` instead of erroring on the ambiguity.
872        let Some(target) = self.interactions.iter().find(|interaction| {
873            interaction.interaction_id.as_str() == interaction_id
874                && declared_method
875                    .map(|method| interaction.effective_http_method() == *method)
876                    .unwrap_or(true)
877        }) else {
878            // Unknown/ambiguous id already reported by require_root_interaction_reference.
879            return Ok(());
880        };
881        let Some(pre_load_interaction_id) = target
882            .form_ui
883            .as_ref()
884            .and_then(|form_ui| form_ui.pre_load_interaction_id.as_ref())
885        else {
886            return Ok(());
887        };
888
889        require_pair_reference(
890            self.interaction_methods,
891            pre_load_interaction_id.as_str(),
892            InteractionHttpMethod::Get,
893            || {
894                format!(
895                    "surface `{}` root_node form `{}` references unknown pre_load_interaction_id `{}`",
896                    self.surface_id, interaction_id, pre_load_interaction_id
897                )
898            },
899        )
900    }
901
902    fn require_data_source_reference(
903        &self,
904        data_source_id: &str,
905    ) -> Result<(), SurfaceRegistrationError> {
906        ensure_known_reference(self.data_source_ids, data_source_id, || {
907            format!(
908                "surface `{}` root_node references unknown data_source_id `{}`",
909                self.surface_id, data_source_id
910            )
911        })
912    }
913}
914
915fn validate_workflow_step_references(
916    surface_id: &SurfaceId,
917    interactions: &[InteractionDescriptor],
918    interaction_methods: &HashMap<&str, Vec<InteractionHttpMethod>>,
919) -> Result<(), SurfaceRegistrationError> {
920    WorkflowStepReferenceValidator::new(surface_id, interaction_methods).validate(interactions)
921}
922
923struct WorkflowStepReferenceValidator<'a> {
924    surface_id: &'a SurfaceId,
925    interaction_methods: &'a HashMap<&'a str, Vec<InteractionHttpMethod>>,
926}
927
928impl<'a> WorkflowStepReferenceValidator<'a> {
929    fn new(
930        surface_id: &'a SurfaceId,
931        interaction_methods: &'a HashMap<&'a str, Vec<InteractionHttpMethod>>,
932    ) -> Self {
933        Self {
934            surface_id,
935            interaction_methods,
936        }
937    }
938
939    fn validate(
940        &self,
941        interactions: &[InteractionDescriptor],
942    ) -> Result<(), SurfaceRegistrationError> {
943        for interaction in interactions {
944            if interaction.kind != InteractionKind::Workflow {
945                continue;
946            }
947            self.validate_workflow_interaction_steps(interaction)?;
948        }
949
950        Ok(())
951    }
952
953    fn validate_workflow_interaction_steps(
954        &self,
955        interaction: &InteractionDescriptor,
956    ) -> Result<(), SurfaceRegistrationError> {
957        for step in &interaction.workflow_steps {
958            if let Some(submit_interaction_id) = &step.submit_interaction_id {
959                require_pair_reference(
960                    self.interaction_methods,
961                    submit_interaction_id.as_str(),
962                    InteractionHttpMethod::Post,
963                    || {
964                        format!(
965                            "surface `{}` workflow interaction `{}` references unknown submit_interaction_id `{}` in step `{}`",
966                            self.surface_id,
967                            interaction.interaction_id,
968                            submit_interaction_id,
969                            step.step_id
970                        )
971                    },
972                )?;
973            }
974
975            if let Some(form_ui) = &step.form_ui
976                && let Some(pre_load_interaction_id) = &form_ui.pre_load_interaction_id
977            {
978                require_pair_reference(
979                    self.interaction_methods,
980                    pre_load_interaction_id.as_str(),
981                    InteractionHttpMethod::Get,
982                    || {
983                        format!(
984                            "surface `{}` workflow interaction `{}` references unknown pre_load_interaction_id `{}` in step `{}`",
985                            self.surface_id,
986                            interaction.interaction_id,
987                            pre_load_interaction_id,
988                            step.step_id
989                        )
990                    },
991                )?;
992            }
993        }
994
995        Ok(())
996    }
997}
998
999fn ensure_known_reference(
1000    known_ids: &HashSet<&str>,
1001    reference_id: &str,
1002    error_message: impl FnOnce() -> String,
1003) -> Result<(), SurfaceRegistrationError> {
1004    if known_ids.contains(reference_id) {
1005        return Ok(());
1006    }
1007
1008    Err(invalid_contract(error_message()))
1009}
1010
1011/// Resolves a bare/explicit interaction reference against the `(id, method)`
1012/// index (REST method model, spec §2a). A `declared_method` (explicit
1013/// `(id, method)` reference) must be registered exactly; a bare reference
1014/// (`declared_method: None`) resolves only when the id registers under
1015/// exactly one method — no POST fallback, fail-closed on ambiguity.
1016fn resolve_interaction_reference(
1017    interaction_methods: &HashMap<&str, Vec<InteractionHttpMethod>>,
1018    reference_id: &str,
1019    declared_method: Option<&InteractionHttpMethod>,
1020    error_context: impl FnOnce() -> String,
1021) -> Result<(), SurfaceRegistrationError> {
1022    let Some(methods) = interaction_methods.get(reference_id) else {
1023        return Err(invalid_contract(error_context()));
1024    };
1025    match declared_method {
1026        Some(method) if !methods.contains(method) => Err(invalid_contract(format!(
1027            "{} — `{reference_id}` is not registered under method `{method}`",
1028            error_context()
1029        ))),
1030        // No Post fallback: a bare reference to a multi-method ID must fail
1031        // closed (a default would silently resolve a delete-intent reference
1032        // to a registered create sibling).
1033        None if methods.len() > 1 => Err(invalid_contract(format!(
1034            "{} — `{reference_id}` is registered under multiple methods; declare http_method — ambiguous",
1035            error_context()
1036        ))),
1037        _ => Ok(()),
1038    }
1039}
1040
1041/// Resolves a reference that must land under one exact required method
1042/// (workflow triggers/submits are POST-fixed; pre-load/`ProviderQuery`
1043/// sources are GET-fixed) — used where the node/field carries no explicit
1044/// `http_method` of its own to disambiguate with.
1045fn require_pair_reference(
1046    interaction_methods: &HashMap<&str, Vec<InteractionHttpMethod>>,
1047    reference_id: &str,
1048    required: InteractionHttpMethod,
1049    error_context: impl FnOnce() -> String,
1050) -> Result<(), SurfaceRegistrationError> {
1051    match interaction_methods.get(reference_id) {
1052        Some(methods) if methods.contains(&required) => Ok(()),
1053        _ => Err(invalid_contract(format!(
1054            "{} — `{reference_id}` must be registered under method `{required}`",
1055            error_context()
1056        ))),
1057    }
1058}
1059
1060#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1061pub struct ProviderIdentity {
1062    pub provider_id: String,
1063    pub provider_kind: ProviderKind,
1064    pub provider_namespace: String,
1065}
1066
1067#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1068pub struct EffectiveTenantBinding {
1069    pub scope: Scope,
1070    #[serde(default, skip_serializing_if = "Option::is_none")]
1071    pub tenant_id: Option<String>,
1072}
1073
1074#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1075pub struct ProviderEncryptionMetadata {
1076    pub key_id: String,
1077    pub algorithm: ProviderEncryptionAlgorithm,
1078    pub public_key: String,
1079}
1080
1081wire_safe_enum! {
1082    /// Encryption algorithm used for ECIES sealed-box parameter encryption.
1083    #[derive(Debug, Clone, PartialEq, Eq)]
1084    pub enum ProviderEncryptionAlgorithm {
1085        EciesP256 => "ecies_p256",
1086    }
1087    parse_error = ParseProviderEncryptionAlgorithmError("invalid provider encryption algorithm");
1088}
1089
1090impl ProviderEncryptionAlgorithm {
1091    /// All known (non-`Other`) variants. Used in tests for exhaustive iteration
1092    /// (`strum::EnumIter` is incompatible with the `Other(String)` tuple variant).
1093    pub const KNOWN_VARIANTS: &'static [ProviderEncryptionAlgorithm] =
1094        &[ProviderEncryptionAlgorithm::EciesP256];
1095}
1096
1097#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1098pub struct RegisteredSurface {
1099    pub descriptor: SurfaceDescriptor,
1100    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1101    pub interactions: Vec<InteractionDescriptor>,
1102    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1103    pub data_sources: Vec<DataSourceDescriptor>,
1104}
1105
1106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1107pub struct SurfaceRegistrationPolicy {
1108    pub supported_generation: FrameworkGenerationRange,
1109    pub required_capabilities: CapabilitySet,
1110}
1111
1112#[derive(Debug, Clone, PartialEq, Eq, Error)]
1113#[error("{code:?}: {message}")]
1114pub struct SurfaceRegistrationError {
1115    pub code: SurfaceRegistrationErrorCode,
1116    pub message: String,
1117}
1118
1119impl SurfaceRegistrationError {
1120    pub fn new(code: SurfaceRegistrationErrorCode, message: String) -> Self {
1121        Self { code, message }
1122    }
1123}
1124
1125#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1126#[serde(rename_all = "snake_case")]
1127pub enum SurfaceRegistrationErrorCode {
1128    UnsupportedGeneration,
1129    MissingCapability,
1130    InvalidSlot,
1131    InvalidContract,
1132}
1133
1134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1135pub struct SurfaceActionRequest {
1136    pub request_id: Uuid,
1137    pub tenant_id: String,
1138    pub surface_id: SurfaceId,
1139    pub interaction_id: InteractionId,
1140    /// Dispatch method (REST method model). Old controllers never set it;
1141    /// old services drop it — default POST both ways.
1142    #[serde(default)]
1143    pub method: InteractionHttpMethod,
1144    pub idempotency_key: String,
1145    #[serde(default, skip_serializing_if = "Option::is_none")]
1146    pub target_provider_id: Option<String>,
1147    pub caller_origin: CallerOrigin,
1148    #[serde(default, skip_serializing_if = "Map::is_empty")]
1149    pub params: Map<String, Value>,
1150    #[serde(default, skip_serializing_if = "Option::is_none")]
1151    pub encrypted_sensitive_params: Option<EncryptedSensitiveParams>,
1152}
1153
1154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1155#[serde(rename_all = "snake_case", tag = "kind")]
1156pub enum CallerOrigin {
1157    UserSession { user_id: String, session_id: String },
1158    BuiltInSystem { principal: String },
1159    Provider { provider_id: String },
1160}
1161
1162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1163pub struct EncryptedSensitiveParams {
1164    pub key_id: String,
1165    pub algorithm: ProviderEncryptionAlgorithm,
1166    pub ciphertext_b64: String,
1167}
1168
1169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1170pub struct SurfaceActionCancel {
1171    pub request_id: Uuid,
1172    pub target_provider_id: String,
1173    pub reason: SurfaceActionCancelReason,
1174}
1175
1176#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1177#[serde(rename_all = "snake_case")]
1178pub enum SurfaceActionCancelReason {
1179    Timeout,
1180    RequestCancelled,
1181    ProviderDisconnected,
1182}
1183
1184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1185pub struct SurfaceActionResponse {
1186    pub request_id: Uuid,
1187    pub success: bool,
1188    #[serde(default, skip_serializing_if = "Option::is_none")]
1189    pub result: Option<Value>,
1190    #[serde(default, skip_serializing_if = "Option::is_none")]
1191    pub error: Option<SurfaceActionError>,
1192}
1193
1194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1195pub struct SurfaceActionError {
1196    pub code: SurfaceActionErrorCode,
1197    pub message: String,
1198    #[serde(default, skip_serializing_if = "Option::is_none")]
1199    pub details: Option<Value>,
1200}
1201
1202#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1203#[serde(rename_all = "snake_case")]
1204pub enum SurfaceActionErrorCode {
1205    PermissionDenied,
1206    InvalidRequest,
1207    SchemaValidationFailed,
1208    UnsupportedCapability,
1209    ProviderUnavailable,
1210    Timeout,
1211    DuplicateRequest,
1212    InternalError,
1213}
1214
1215#[cfg(test)]
1216mod tests {
1217    use super::*;
1218
1219    #[test]
1220    fn surface_action_request_without_method_deserializes_to_post() {
1221        // Old-peer wire shape (no `method` key).
1222        let json = serde_json::json!({
1223            "request_id": "018f0000-0000-7000-8000-000000000000",
1224            "tenant_id": "018f0000-0000-7000-8000-000000000001",
1225            "surface_id": "test.surface",
1226            "interaction_id": "save",
1227            "idempotency_key": "k1",
1228            "caller_origin": { "kind": "built_in_system", "principal": "test" }
1229        });
1230        let request: SurfaceActionRequest = serde_json::from_value(json).expect("old-peer shape");
1231        assert_eq!(request.method, InteractionHttpMethod::Post);
1232    }
1233}