Skip to main content

uptrakit_surfaces/
interaction.rs

1use serde::{Deserialize, Serialize};
2use thiserror::Error;
3use uptrakit_shared_macros::wire_safe_enum;
4
5use crate::{FormUiDescriptor, InteractionId, ParamFieldDescriptor, ProviderKind, SchemaContract};
6
7pub const MIN_INTERACTION_TIMEOUT_SECONDS: u16 = 1;
8pub const MAX_INTERACTION_TIMEOUT_SECONDS: u16 = 300;
9
10#[non_exhaustive]
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum InteractionKind {
14    MutationAction,
15    FormSubmit,
16    Workflow,
17    Navigate,
18    DataLoad,
19    ConfirmableAction,
20}
21
22#[non_exhaustive]
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case", tag = "mode")]
25pub enum InteractionTransport {
26    ControllerLocal,
27    ProviderProxied,
28}
29
30wire_safe_enum! {
31    /// HTTP method a surface interaction is dispatched with (REST method model, B1).
32    #[derive(Debug, Clone, PartialEq, Eq, Default)]
33    pub enum InteractionHttpMethod {
34        Get => "get",
35        // `#[serde(default)]` on `InteractionDescriptor::http_method` requires
36        // `InteractionHttpMethod: Default`; `#[default]` marks the variant the
37        // derive picks (the macro forwards per-variant attributes verbatim).
38        #[default]
39        Post => "post",
40        Put => "put",
41        Delete => "delete",
42    }
43    parse_error = ParseInteractionHttpMethodError("invalid interaction http method");
44}
45
46/// Known variants for exhaustive test iteration (mirrors the
47/// `ProviderEncryptionAlgorithm` precedent in `protocol.rs`).
48pub const KNOWN_INTERACTION_HTTP_METHODS: &[InteractionHttpMethod] = &[
49    InteractionHttpMethod::Get,
50    InteractionHttpMethod::Post,
51    InteractionHttpMethod::Put,
52    InteractionHttpMethod::Delete,
53];
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct WorkflowStepDescriptor {
57    pub step_id: String,
58    pub label: String,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub form_ui: Option<FormUiDescriptor>,
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub submit_interaction_id: Option<InteractionId>,
63    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
64    pub render_previous_response: bool,
65    pub input_schema: SchemaContract,
66    pub result_schema: SchemaContract,
67}
68
69#[non_exhaustive]
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct InteractionDescriptor {
72    pub interaction_id: InteractionId,
73    pub kind: InteractionKind,
74    /// HTTP method this interaction is dispatched with. Wire default is POST
75    /// (providers that predate the method model omit the field); DataLoads
76    /// normalize to GET at admission regardless of the declared value.
77    #[serde(default)]
78    pub http_method: InteractionHttpMethod,
79    pub label: String,
80    /// Canonical action string (`resource:verb`) required to view/use this
81    /// interaction; parsed to `Action` at admission.
82    #[serde(
83        default,
84        alias = "required_permission",
85        skip_serializing_if = "Option::is_none"
86    )]
87    pub required_action: Option<String>,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub input_schema: Option<SchemaContract>,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub result_schema: Option<SchemaContract>,
92    #[serde(default, skip_serializing_if = "Vec::is_empty")]
93    pub sensitive_fields: Vec<String>,
94    /// Opt-in per-field param declarations (GET query typing + body validation).
95    #[serde(default, skip_serializing_if = "Vec::is_empty")]
96    pub params: Vec<ParamFieldDescriptor>,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub timeout_seconds: Option<u16>,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub confirmation: Option<InteractionConfirmation>,
101    pub transport: InteractionTransport,
102    #[serde(default, skip_serializing_if = "Vec::is_empty")]
103    pub workflow_steps: Vec<WorkflowStepDescriptor>,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub form_ui: Option<FormUiDescriptor>,
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub icon: Option<String>,
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub submit_label: Option<String>,
110    /// Allows same-tenant provider-origin (service-initiated) invocation of
111    /// this interaction even when `required_action` is set. Fail-closed:
112    /// absent on the wire deserializes to `false`. Honored only for
113    /// `Plugin`/`BuiltIn`-registered interactions — see `validate_for_provider`.
114    /// For `Service`-kind providers the flag is additionally rejected at
115    /// surface-level admission when the home surface descriptor carries
116    /// `required_action` (enforced in `protocol.rs`'s registration
117    /// validation, not here).
118    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
119    pub provider_invocable: bool,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct InteractionConfirmation {
124    pub title: String,
125    pub message: String,
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub confirm_label: Option<String>,
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub cancel_label: Option<String>,
130    pub severity: ConfirmationSeverity,
131}
132
133#[non_exhaustive]
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(rename_all = "snake_case")]
136pub enum ConfirmationSeverity {
137    Info,
138    Warning,
139    Danger,
140}
141
142#[non_exhaustive]
143#[derive(Debug, Clone, PartialEq, Eq, Error)]
144pub enum InteractionValidationError {
145    #[error(
146        "interaction `{interaction_id}` timeout must be between {MIN_INTERACTION_TIMEOUT_SECONDS} and {MAX_INTERACTION_TIMEOUT_SECONDS} seconds"
147    )]
148    TimeoutOutOfRange { interaction_id: InteractionId },
149    #[error("workflow interaction `{interaction_id}` must declare at least one workflow step")]
150    WorkflowMissingSteps { interaction_id: InteractionId },
151    #[error("confirmable interaction `{interaction_id}` must include confirmation metadata")]
152    ConfirmableActionMissingConfirmation { interaction_id: InteractionId },
153    #[error("interaction `{interaction_id}` must include a non-empty human-authored label")]
154    BlankLabel { interaction_id: InteractionId },
155    #[error(
156        "workflow step `{step_id}` in interaction `{interaction_id}` must include a non-empty human-authored label"
157    )]
158    BlankWorkflowStepLabel {
159        interaction_id: InteractionId,
160        step_id: String,
161    },
162    #[error("interaction `{interaction_id}` has invalid icon: {reason}")]
163    IconInvalid {
164        interaction_id: InteractionId,
165        reason: crate::IconNameError,
166    },
167    #[error("interaction `{interaction_id}` has invalid submit_label: {reason}")]
168    SubmitLabelInvalid {
169        interaction_id: InteractionId,
170        reason: String,
171    },
172    #[error(
173        "interaction `{interaction_id}` sets provider_invocable with a required_action — not allowed for service-registered surfaces"
174    )]
175    ProviderInvocableForbiddenForServiceProviders { interaction_id: InteractionId },
176}
177
178impl InteractionDescriptor {
179    /// Creates a new `InteractionDescriptor` with all optional fields set to their defaults.
180    pub fn new(
181        interaction_id: InteractionId,
182        kind: InteractionKind,
183        label: impl Into<String>,
184        transport: InteractionTransport,
185    ) -> Self {
186        Self {
187            interaction_id,
188            kind,
189            http_method: InteractionHttpMethod::default(),
190            label: label.into(),
191            transport,
192            required_action: None,
193            input_schema: None,
194            result_schema: None,
195            sensitive_fields: vec![],
196            params: Vec::new(),
197            timeout_seconds: None,
198            confirmation: None,
199            workflow_steps: vec![],
200            form_ui: None,
201            icon: None,
202            submit_label: None,
203            provider_invocable: false,
204        }
205    }
206
207    /// Declare a non-default dispatch method (PUT/DELETE mutations).
208    #[must_use]
209    pub fn with_http_method(mut self, http_method: InteractionHttpMethod) -> Self {
210        self.http_method = http_method;
211        self
212    }
213
214    /// Declare per-field param descriptors (GET query typing + body validation).
215    #[must_use]
216    pub fn with_params(mut self, params: Vec<ParamFieldDescriptor>) -> Self {
217        self.params = params;
218        self
219    }
220
221    /// The method the framework dispatches with: DataLoads are GET-only (B1);
222    /// any other kind uses the declared method.
223    pub fn effective_http_method(&self) -> InteractionHttpMethod {
224        if self.kind == InteractionKind::DataLoad {
225            InteractionHttpMethod::Get
226        } else {
227            self.http_method.clone()
228        }
229    }
230
231    /// Validates provider-specific interaction contract rules.
232    ///
233    /// # Errors
234    /// Returns [`InteractionValidationError::TimeoutOutOfRange`] when
235    /// `timeout_seconds` falls outside
236    /// [`MIN_INTERACTION_TIMEOUT_SECONDS`]..=[`MAX_INTERACTION_TIMEOUT_SECONDS`].
237    /// Returns [`InteractionValidationError::WorkflowMissingSteps`] when
238    /// a workflow interaction declares no steps.
239    /// Returns
240    /// [`InteractionValidationError::ConfirmableActionMissingConfirmation`]
241    /// when a confirmable interaction omits confirmation metadata.
242    /// Returns [`InteractionValidationError::BlankLabel`] when `label` is
243    /// empty or whitespace-only.
244    /// Returns [`InteractionValidationError::BlankWorkflowStepLabel`] when
245    /// any workflow step has an empty or whitespace-only label.
246    /// Returns [`InteractionValidationError::IconInvalid`] when `icon` is
247    /// `Some` but fails kebab-case validation.
248    /// Returns
249    /// [`InteractionValidationError::ProviderInvocableForbiddenForServiceProviders`]
250    /// when a `Service` provider sets `provider_invocable` on an interaction
251    /// that also declares `required_action`. The descriptor-level companion
252    /// rule (rejecting `provider_invocable` when the *surface descriptor*
253    /// carries `required_action`) lives in
254    /// `protocol.rs::validate_descriptor_gated_provider_invocable`, because
255    /// this method never sees the surface descriptor.
256    pub fn validate_for_provider(
257        &self,
258        provider_kind: ProviderKind,
259    ) -> Result<(), InteractionValidationError> {
260        if self.provider_invocable
261            && self.required_action.is_some()
262            && provider_kind == ProviderKind::Service
263        {
264            return Err(
265                InteractionValidationError::ProviderInvocableForbiddenForServiceProviders {
266                    interaction_id: self.interaction_id.clone(),
267                },
268            );
269        }
270
271        if let Some(timeout_seconds) = self.timeout_seconds
272            && !(MIN_INTERACTION_TIMEOUT_SECONDS..=MAX_INTERACTION_TIMEOUT_SECONDS)
273                .contains(&timeout_seconds)
274        {
275            return Err(InteractionValidationError::TimeoutOutOfRange {
276                interaction_id: self.interaction_id.clone(),
277            });
278        }
279
280        if self.kind == InteractionKind::Workflow && self.workflow_steps.is_empty() {
281            return Err(InteractionValidationError::WorkflowMissingSteps {
282                interaction_id: self.interaction_id.clone(),
283            });
284        }
285
286        if self.kind == InteractionKind::ConfirmableAction && self.confirmation.is_none() {
287            return Err(
288                InteractionValidationError::ConfirmableActionMissingConfirmation {
289                    interaction_id: self.interaction_id.clone(),
290                },
291            );
292        }
293
294        if self.label.trim().is_empty() {
295            return Err(InteractionValidationError::BlankLabel {
296                interaction_id: self.interaction_id.clone(),
297            });
298        }
299
300        for step in &self.workflow_steps {
301            if step.label.trim().is_empty() {
302                return Err(InteractionValidationError::BlankWorkflowStepLabel {
303                    interaction_id: self.interaction_id.clone(),
304                    step_id: step.step_id.clone(),
305                });
306            }
307        }
308
309        if let Some(icon) = &self.icon {
310            crate::validate_icon_name(icon).map_err(|reason| {
311                InteractionValidationError::IconInvalid {
312                    interaction_id: self.interaction_id.clone(),
313                    reason,
314                }
315            })?;
316        }
317
318        if let Some(submit_label) = &self.submit_label {
319            if submit_label.trim().is_empty() {
320                return Err(InteractionValidationError::SubmitLabelInvalid {
321                    interaction_id: self.interaction_id.clone(),
322                    reason: "must not be empty".to_string(),
323                });
324            }
325            if submit_label.len() > 50 {
326                return Err(InteractionValidationError::SubmitLabelInvalid {
327                    interaction_id: self.interaction_id.clone(),
328                    reason: format!("exceeds max 50 characters ({} given)", submit_label.len()),
329                });
330            }
331        }
332
333        Ok(())
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    #[test]
342    fn validate_for_provider_accepts_kebab_icon() {
343        let descriptor = InteractionDescriptor {
344            icon: Some("trash-2".to_string()),
345            ..InteractionDescriptor::new(
346                InteractionId::new("act").unwrap(),
347                InteractionKind::MutationAction,
348                "Action",
349                InteractionTransport::ControllerLocal,
350            )
351        };
352        descriptor
353            .validate_for_provider(ProviderKind::Plugin)
354            .unwrap();
355    }
356
357    #[test]
358    fn validate_for_provider_rejects_pascal_icon() {
359        let mut descriptor = InteractionDescriptor {
360            icon: Some("Trash2".to_string()),
361            ..InteractionDescriptor::new(
362                InteractionId::new("act").unwrap(),
363                InteractionKind::MutationAction,
364                "Action",
365                InteractionTransport::ControllerLocal,
366            )
367        };
368        let err = descriptor
369            .validate_for_provider(ProviderKind::Plugin)
370            .unwrap_err();
371        assert!(matches!(
372            err,
373            InteractionValidationError::IconInvalid { .. }
374        ));
375
376        descriptor.icon = Some(String::new());
377        let err = descriptor
378            .validate_for_provider(ProviderKind::Plugin)
379            .unwrap_err();
380        assert!(matches!(
381            err,
382            InteractionValidationError::IconInvalid { .. }
383        ));
384    }
385
386    #[test]
387    fn validate_for_provider_accepts_missing_icon() {
388        let descriptor = InteractionDescriptor::new(
389            InteractionId::new("act").unwrap(),
390            InteractionKind::MutationAction,
391            "Action",
392            InteractionTransport::ControllerLocal,
393        );
394        descriptor
395            .validate_for_provider(ProviderKind::Plugin)
396            .unwrap();
397    }
398
399    #[test]
400    fn validate_for_provider_rejects_empty_submit_label() {
401        let descriptor = InteractionDescriptor {
402            submit_label: Some("   ".to_string()),
403            ..InteractionDescriptor::new(
404                InteractionId::new("act").unwrap(),
405                InteractionKind::FormSubmit,
406                "Save Settings",
407                InteractionTransport::ProviderProxied,
408            )
409        };
410        let err = descriptor
411            .validate_for_provider(ProviderKind::Plugin)
412            .unwrap_err();
413        assert!(matches!(
414            err,
415            InteractionValidationError::SubmitLabelInvalid { .. }
416        ));
417    }
418
419    #[test]
420    fn validate_for_provider_rejects_submit_label_exceeding_50_chars() {
421        let descriptor = InteractionDescriptor {
422            submit_label: Some("a".repeat(51)),
423            ..InteractionDescriptor::new(
424                InteractionId::new("act").unwrap(),
425                InteractionKind::FormSubmit,
426                "Save",
427                InteractionTransport::ProviderProxied,
428            )
429        };
430        let err = descriptor
431            .validate_for_provider(ProviderKind::Plugin)
432            .unwrap_err();
433        assert!(matches!(
434            err,
435            InteractionValidationError::SubmitLabelInvalid { .. }
436        ));
437    }
438
439    #[test]
440    fn validate_for_provider_accepts_valid_submit_label() {
441        let descriptor = InteractionDescriptor {
442            submit_label: Some("Connect".to_string()),
443            ..InteractionDescriptor::new(
444                InteractionId::new("act").unwrap(),
445                InteractionKind::FormSubmit,
446                "Save",
447                InteractionTransport::ProviderProxied,
448            )
449        };
450        descriptor
451            .validate_for_provider(ProviderKind::Plugin)
452            .unwrap();
453    }
454
455    #[test]
456    fn provider_invocable_defaults_false_when_absent_on_wire() {
457        let json = serde_json::json!({
458            "interaction_id": "act",
459            "kind": "data_load",
460            "label": "Act",
461            "transport": { "mode": "controller_local" }
462        });
463        let descriptor: InteractionDescriptor =
464            serde_json::from_value(json).expect("deserialize without provider_invocable");
465        assert!(!descriptor.provider_invocable);
466        // Round-trip: default false is not serialized (skip_serializing_if).
467        let value = serde_json::to_value(&descriptor).unwrap();
468        assert!(value.get("provider_invocable").is_none());
469    }
470
471    #[test]
472    fn validate_for_provider_rejects_provider_invocable_permissioned_service_interaction() {
473        let mut descriptor = InteractionDescriptor::new(
474            InteractionId::new("act").unwrap(),
475            InteractionKind::DataLoad,
476            "Act",
477            InteractionTransport::ProviderProxied,
478        );
479        descriptor.required_action = Some("update_hosts".to_string());
480        descriptor.provider_invocable = true;
481        let result = descriptor.validate_for_provider(ProviderKind::Service);
482        assert!(matches!(
483            result,
484            Err(InteractionValidationError::ProviderInvocableForbiddenForServiceProviders { .. })
485        ));
486    }
487
488    #[test]
489    fn http_method_defaults_to_post_when_absent_on_wire() {
490        // Old-peer shape: descriptor JSON without http_method.
491        let json = serde_json::json!({
492            "interaction_id": "save",
493            "kind": "mutation_action",
494            "label": "Save",
495            "transport": { "mode": "controller_local" }
496        });
497        let descriptor: InteractionDescriptor = serde_json::from_value(json).expect("deserialize");
498        assert_eq!(descriptor.http_method, InteractionHttpMethod::Post);
499    }
500
501    #[test]
502    fn effective_http_method_normalizes_dataload_to_get() {
503        let json = serde_json::json!({
504            "interaction_id": "list",
505            "kind": "data_load",
506            "label": "List",
507            "transport": { "mode": "provider_proxied" }
508        });
509        let descriptor: InteractionDescriptor = serde_json::from_value(json).expect("deserialize");
510        assert_eq!(descriptor.http_method, InteractionHttpMethod::Post); // raw wire default
511        assert_eq!(
512            descriptor.effective_http_method(),
513            InteractionHttpMethod::Get
514        );
515    }
516
517    #[cfg(feature = "schema")]
518    mod schema_tests {
519        use super::*;
520
521        fn assert_open_string_schema<T: schemars::JsonSchema>(known: &[&str]) {
522            let schema = schemars::schema_for!(T);
523            let value = serde_json::to_value(&schema).expect("schema to JSON");
524            assert_eq!(value["type"], "string");
525            assert!(
526                value.get("enum").is_none(),
527                "must be an open string schema, found closed enum list: {value}"
528            );
529            let desc = value["description"].as_str().expect("description present");
530            for k in known {
531                assert!(
532                    desc.contains(k),
533                    "known value {k} missing from description: {desc}"
534                );
535            }
536        }
537
538        /// Covers the `wire_safe_enum!` schemars arm: verifies that macro-generated
539        /// `JsonSchema` impls produce open string schemas, not closed enum lists.
540        #[test]
541        fn interaction_http_method_schema_is_open_string_with_known_values() {
542            assert_open_string_schema::<InteractionHttpMethod>(&["get", "post"]);
543        }
544    }
545
546    #[test]
547    fn http_method_round_trips_wire_string() {
548        assert_eq!(
549            InteractionHttpMethod::from("put".to_string()),
550            InteractionHttpMethod::Put
551        );
552        assert_eq!(InteractionHttpMethod::Put.as_str(), "put");
553        assert!(matches!(
554            InteractionHttpMethod::from("patch".to_string()),
555            InteractionHttpMethod::Other(_)
556        ));
557        "patch".parse::<InteractionHttpMethod>().unwrap_err(); // strict FromStr
558    }
559
560    #[test]
561    fn validate_for_provider_accepts_provider_invocable_for_plugin_and_unpermissioned_service() {
562        let mut plugin_owned = InteractionDescriptor::new(
563            InteractionId::new("act").unwrap(),
564            InteractionKind::DataLoad,
565            "Act",
566            InteractionTransport::ControllerLocal,
567        );
568        plugin_owned.required_action = Some("update_hosts".to_string());
569        plugin_owned.provider_invocable = true;
570        // matches! form — clippy::assertions_on_result_states is denied and this
571        // tests mod may not carry the #![expect] header sibling mods use.
572        assert!(matches!(
573            plugin_owned.validate_for_provider(ProviderKind::Plugin),
574            Ok(())
575        ));
576
577        let mut unpermissioned_service = InteractionDescriptor::new(
578            InteractionId::new("act2").unwrap(),
579            InteractionKind::DataLoad,
580            "Act2",
581            InteractionTransport::ProviderProxied,
582        );
583        unpermissioned_service.provider_invocable = true;
584        assert!(matches!(
585            unpermissioned_service.validate_for_provider(ProviderKind::Service),
586            Ok(())
587        ));
588    }
589
590    #[test]
591    fn validate_for_provider_accepts_provider_invocable_for_builtin() {
592        let mut builtin_owned = InteractionDescriptor::new(
593            InteractionId::new("act3").unwrap(),
594            InteractionKind::DataLoad,
595            "Act3",
596            InteractionTransport::ControllerLocal,
597        );
598        builtin_owned.required_action = Some("update_hosts".to_string());
599        builtin_owned.provider_invocable = true;
600        // The gate tests `== ProviderKind::Service`, so BuiltIn falls through
601        // permissively like Plugin; pinned here so a later kind-agnostic
602        // rewrite cannot silently tighten it.
603        assert!(matches!(
604            builtin_owned.validate_for_provider(ProviderKind::BuiltIn),
605            Ok(())
606        ));
607    }
608
609    #[test]
610    fn required_action_accepts_legacy_key_via_alias() {
611        // A stale-satellite payload lands in required_action; the (legacy)
612        // value then dies at the admission Action parse — never a silent None.
613        let json = serde_json::json!({
614            "interaction_id": "act",
615            "kind": "data_load",
616            "label": "Act",
617            "transport": { "mode": "controller_local" },
618            "required_permission": "update_hosts",
619        });
620        let descriptor: InteractionDescriptor =
621            serde_json::from_value(json).expect("alias must deserialize");
622        assert_eq!(descriptor.required_action.as_deref(), Some("update_hosts"));
623    }
624
625    #[test]
626    fn required_action_rejects_dual_key_payload() {
627        // serde derive: an alias shares the field's slot, so a second
628        // occurrence is duplicate_field — there is no last-wins.
629        let json = r#"{
630            "interaction_id": "act",
631            "kind": "data_load",
632            "label": "Act",
633            "transport": { "mode": "controller_local" },
634            "required_action": "hosts:update",
635            "required_permission": "update_hosts"
636        }"#;
637        // expect_err alone pins the semantics (dual key fails, no last-wins);
638        // do not assert serde_json's message text (upstream-behavior coupling).
639        serde_json::from_str::<InteractionDescriptor>(json).expect_err("dual key must fail");
640    }
641
642    #[test]
643    fn required_action_serializes_under_the_new_key_only() {
644        let mut descriptor = InteractionDescriptor::new(
645            InteractionId::new("act").unwrap(),
646            InteractionKind::DataLoad,
647            "Act",
648            InteractionTransport::ControllerLocal,
649        );
650        descriptor.required_action = Some("hosts:update".to_string());
651
652        let value = serde_json::to_value(&descriptor).expect("serialize");
653        assert!(value.get("required_action").is_some());
654        assert!(value.get("required_permission").is_none());
655    }
656}