Skip to main content

vta_sdk/approvals/
mod.rs

1//! The declarative **approvals** model — one answer to "does this operation
2//! need an additional human decision?".
3//!
4//! A VTA used to answer that question three ways: `[auth.step_up]` floors keyed
5//! by a closed list of op-class slugs, `[[policy.require_consent]]` rules keyed
6//! by task type URI, and the messaging-consent approver registry. Two config
7//! languages over two identifier spaces, only one of which was reachable at
8//! runtime. This module is the single replacement: a list of [`ApprovalRule`]s
9//! keyed on task type URI, plus the named [`ApproverSets`] a `consent` rule
10//! draws its approvers from.
11//!
12//! # Where it lives
13//!
14//! The rules are **not** config. They are carried in the `ext` members of one
15//! reserved row in the VTA's policy keyspace ([`DECLARATIVE_POLICY_ID`]),
16//! managed at runtime through the canonical `policy/*` Trust Tasks — so they
17//! are editable over DIDComm/TSP, not just REST, and they survive without a
18//! config-file edit or a restart. Config carries the same shape only as a
19//! **seed**, applied once when the row is absent (a fresh install or an
20//! IaC-provisioned VTA).
21//!
22//! # Why the module is client-authored
23//!
24//! Canonical `policy/upsert` declares `module` (the Rego source) as
25//! `minLength: 1` and authoritative — the maintainer validates it, it does not
26//! invent it. So a declarative row does not ask the VTA to synthesize on the
27//! caller's behalf: the **caller** runs [`synthesize_rego`] over its rules and
28//! sends the result as `module`, with the rules themselves in
29//! `ext["openvtc.approvals"]`. The VTA re-derives from `ext` and **byte-compares**
30//! against the submitted module, rejecting a mismatch.
31//!
32//! That keeps three properties at once: the canonical contract is unbroken
33//! (module stays client-authored and authoritative), nothing a caller sent is
34//! silently overwritten, and no hand-edited Rego can ride in under a
35//! declarative row's `ext` claiming to be something it isn't.
36//!
37//! Because both sides derive through this one function, its output is a wire
38//! compatibility surface: changing the generated text changes what an older
39//! client's row byte-compares against. Treat edits to [`synthesize_rego`] as
40//! wire changes.
41
42use std::collections::{BTreeMap, BTreeSet};
43
44use serde::{Deserialize, Serialize};
45
46/// Reserved id of the policy row carrying the declarative approvals model.
47///
48/// Owned by the approvals surface. An operator's hand-authored Rego uses its
49/// own ids and is never touched by it.
50pub const DECLARATIVE_POLICY_ID: &str = "approvals";
51
52/// `name` on the reserved row (canonical `policy/upsert` requires one).
53pub const DECLARATIVE_POLICY_NAME: &str = "Declarative approvals";
54
55/// Priority of the reserved row: above the permissive baseline (0) so it fires
56/// first for the task types it names, with headroom left for an operator's own
57/// higher-priority Rego.
58///
59/// Specifically **above** the legacy config-synthesized consent row (100), which
60/// still exists during the migration window. A task named by both would
61/// otherwise tie, and `decide()` breaks ties by keyspace iteration order — so
62/// which requirement applied would depend on how the rows happened to be laid
63/// out on disk. The declarative row is the source of truth, so it wins
64/// deterministically.
65pub const DECLARATIVE_POLICY_PRIORITY: i32 = 200;
66
67/// `ext` member carrying the [`ApprovalRule`] list (SPEC §4.5.1 reverse-DNS).
68///
69/// The canonical `ExtKey` pattern is `^[a-z][a-z0-9-]*(\.[a-z0-9-]+)+$` — lower
70/// case and dashes only, which is why the sibling key below is
71/// `approver-sets` and not `approverSets`.
72pub const EXT_KEY_RULES: &str = "openvtc.approvals";
73
74/// `ext` member carrying the [`ApproverSets`] map.
75pub const EXT_KEY_APPROVER_SETS: &str = "openvtc.approver-sets";
76
77/// What a gated task requires before it may run.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "camelCase")]
80pub enum Requires {
81    /// The caller re-authenticates: an AAL2 elevation of its **own** session,
82    /// proven with a second factor it already holds.
83    ///
84    /// This is the whole of what step-up is now. The former `delegated` /
85    /// `delegated-any` modes — a *different* party ratifying, which then
86    /// elevated the caller's session for a 15-minute window — are gone: that is
87    /// consent with weaker binding, and [`Requires::Consent`] does it properly.
88    Reauth,
89    /// Named approvers sign off on **this exact payload** (digest-bound,
90    /// N-of-M, optionally excluding the requester), and the decision is
91    /// re-asserted against the world at execution time.
92    Consent,
93}
94
95/// One declarative rule: "this task type needs this kind of approval".
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "camelCase", deny_unknown_fields)]
98pub struct ApprovalRule {
99    /// The Trust Task Type URI to gate, e.g.
100    /// `https://trusttasks.org/spec/acl/grant/0.1`.
101    ///
102    /// Any URI — unlike the step-up floors this replaces, which could only name
103    /// one of eleven hardcoded op-classes.
104    pub task_type: String,
105    /// Which kind of approval the task requires.
106    pub requires: Requires,
107    /// Named set the approvers must belong to. Required for
108    /// [`Requires::Consent`], refused for [`Requires::Reauth`] (which has no
109    /// third party).
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub approver_set: Option<String>,
112    /// Distinct approvals needed. Defaults to 1; consent only.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub min_approvals: Option<u32>,
115    /// When true the requester's own DID cannot count toward the threshold,
116    /// forcing a genuinely second party. Defaults to false; consent only.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub exclude_requester: Option<bool>,
119    /// Contexts this rule applies in. Empty ⇒ every context.
120    ///
121    /// Two rules may name the same `taskType` only if both scope to contexts
122    /// and those scopes are disjoint — otherwise the generated Rego would have
123    /// two complete rules with overlapping guards, which is an evaluation
124    /// error, not a precedence rule. [`validate`] enforces it.
125    #[serde(default, skip_serializing_if = "Vec::is_empty")]
126    pub contexts: Vec<String>,
127}
128
129impl ApprovalRule {
130    /// A `reauth` rule for `task_type`, applying in every context.
131    pub fn reauth(task_type: impl Into<String>) -> Self {
132        Self {
133            task_type: task_type.into(),
134            requires: Requires::Reauth,
135            approver_set: None,
136            min_approvals: None,
137            exclude_requester: None,
138            contexts: Vec::new(),
139        }
140    }
141
142    /// A `consent` rule for `task_type`, satisfied by `approver_set`.
143    pub fn consent(task_type: impl Into<String>, approver_set: impl Into<String>) -> Self {
144        Self {
145            task_type: task_type.into(),
146            requires: Requires::Consent,
147            approver_set: Some(approver_set.into()),
148            min_approvals: None,
149            exclude_requester: None,
150            contexts: Vec::new(),
151        }
152    }
153
154    /// Distinct approvals needed — the declared value floored at 1, so a rule
155    /// can never be satisfiable by zero approvals.
156    pub fn effective_min_approvals(&self) -> u32 {
157        self.min_approvals.unwrap_or(1).max(1)
158    }
159
160    /// Whether the requester is barred from counting toward the threshold.
161    pub fn effective_exclude_requester(&self) -> bool {
162        self.exclude_requester.unwrap_or(false)
163    }
164}
165
166/// Named approver sets: set name → the DIDs permitted to approve.
167///
168/// `BTreeMap` for a deterministic iteration order — the synthesized module is
169/// byte-compared, so nothing that feeds it may be hash-ordered.
170pub type ApproverSets = BTreeMap<String, Vec<String>>;
171
172/// Why a declarative approvals model was refused.
173///
174/// Every variant is a **write-time** refusal. The point is that an
175/// unsatisfiable rule is caught when an operator writes it, not discovered by
176/// the first caller it blocks — which is how a `delegated` step-up floor with
177/// no approver used to fail.
178#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
179pub enum ApprovalsError {
180    #[error(
181        "rule for `{task_type}` is not a Trust Task Type URI: expected \
182         `https://trusttasks.org/spec/<slug>/<major>.<minor>`"
183    )]
184    MalformedTaskType { task_type: String },
185
186    #[error("rule for `{task_type}` requires consent but names no approverSet")]
187    MissingApproverSet { task_type: String },
188
189    #[error(
190        "rule for `{task_type}` requires reauth but names approverSet `{approver_set}`: \
191         reauth elevates the caller's own session and has no third-party approver — \
192         use requires = \"consent\" if another party must sign off"
193    )]
194    ApproverSetOnReauth {
195        task_type: String,
196        approver_set: String,
197    },
198
199    #[error(
200        "rule for `{task_type}` names approver set `{approver_set}`, which is not defined; \
201         define it before referencing it, or the rule could never be satisfied"
202    )]
203    UnknownApproverSet {
204        task_type: String,
205        approver_set: String,
206    },
207
208    #[error(
209        "approver set `{approver_set}` is empty: a consent rule naming it could never reach \
210         its threshold, so every task it gates would be permanently refused"
211    )]
212    EmptyApproverSet { approver_set: String },
213
214    #[error(
215        "rule for `{task_type}` needs {min_approvals} approvals but set `{approver_set}` has \
216         only {members} member(s)"
217    )]
218    ThresholdExceedsSet {
219        task_type: String,
220        approver_set: String,
221        min_approvals: u32,
222        members: usize,
223    },
224
225    #[error(
226        "two rules name `{task_type}` with overlapping scope: rules for one task type must \
227         either be a single unscoped rule or carry disjoint `contexts`"
228    )]
229    OverlappingRules { task_type: String },
230
231    #[error(
232        "`{field}` is a consent-only field and cannot be set on the reauth rule for `{task_type}`"
233    )]
234    ConsentFieldOnReauth {
235        task_type: String,
236        field: &'static str,
237    },
238}
239
240/// Structural check that `uri` is a canonical Trust Task Type URI.
241///
242/// Deliberately structural, not a registry lookup: a VTA may legitimately gate
243/// a task the SDK's own catalogue does not name (a newer service, a private
244/// namespace). The strict parse belongs to whatever routes the URI; here the
245/// job is only to keep an obvious typo out of a policy that would then silently
246/// gate nothing.
247fn is_task_type_uri(uri: &str) -> bool {
248    const PREFIX: &str = "https://trusttasks.org/spec/";
249    let Some(rest) = uri.strip_prefix(PREFIX) else {
250        return false;
251    };
252    let Some((slug, version)) = rest.rsplit_once('/') else {
253        return false;
254    };
255    if slug.is_empty() {
256        return false;
257    }
258    // `<major>.<minor>`, digits only.
259    let Some((major, minor)) = version.split_once('.') else {
260        return false;
261    };
262    !major.is_empty()
263        && !minor.is_empty()
264        && major.bytes().all(|b| b.is_ascii_digit())
265        && minor.bytes().all(|b| b.is_ascii_digit())
266}
267
268/// Validate a complete declarative model.
269///
270/// Called by the CLI before it sends and by the VTA before it persists — the
271/// same function, so a rule that the server would refuse is refused locally
272/// first, with the same words.
273pub fn validate(rules: &[ApprovalRule], sets: &ApproverSets) -> Result<(), ApprovalsError> {
274    for rule in rules {
275        if !is_task_type_uri(&rule.task_type) {
276            return Err(ApprovalsError::MalformedTaskType {
277                task_type: rule.task_type.clone(),
278            });
279        }
280        match rule.requires {
281            Requires::Reauth => {
282                if let Some(set) = &rule.approver_set {
283                    return Err(ApprovalsError::ApproverSetOnReauth {
284                        task_type: rule.task_type.clone(),
285                        approver_set: set.clone(),
286                    });
287                }
288                // Silently ignoring these would let an operator write a rule
289                // that reads as N-of-M and behaves as a self-elevation.
290                for (present, field) in [
291                    (rule.min_approvals.is_some(), "minApprovals"),
292                    (rule.exclude_requester.is_some(), "excludeRequester"),
293                ] {
294                    if present {
295                        return Err(ApprovalsError::ConsentFieldOnReauth {
296                            task_type: rule.task_type.clone(),
297                            field,
298                        });
299                    }
300                }
301            }
302            Requires::Consent => {
303                let Some(set_name) = rule.approver_set.as_deref() else {
304                    return Err(ApprovalsError::MissingApproverSet {
305                        task_type: rule.task_type.clone(),
306                    });
307                };
308                let Some(members) = sets.get(set_name) else {
309                    return Err(ApprovalsError::UnknownApproverSet {
310                        task_type: rule.task_type.clone(),
311                        approver_set: set_name.to_string(),
312                    });
313                };
314                if members.is_empty() {
315                    return Err(ApprovalsError::EmptyApproverSet {
316                        approver_set: set_name.to_string(),
317                    });
318                }
319                let min = rule.effective_min_approvals();
320                if min as usize > members.len() {
321                    return Err(ApprovalsError::ThresholdExceedsSet {
322                        task_type: rule.task_type.clone(),
323                        approver_set: set_name.to_string(),
324                        min_approvals: min,
325                        members: members.len(),
326                    });
327                }
328            }
329        }
330    }
331
332    // Guards must be mutually exclusive: the generated module uses complete
333    // rules, and two that fire on the same input is an evaluation error rather
334    // than a precedence decision.
335    for (i, rule) in rules.iter().enumerate() {
336        for other in &rules[i + 1..] {
337            if other.task_type != rule.task_type {
338                continue;
339            }
340            let disjoint = !rule.contexts.is_empty()
341                && !other.contexts.is_empty()
342                && rule
343                    .contexts
344                    .iter()
345                    .collect::<BTreeSet<_>>()
346                    .is_disjoint(&other.contexts.iter().collect::<BTreeSet<_>>());
347            if !disjoint {
348                return Err(ApprovalsError::OverlappingRules {
349                    task_type: rule.task_type.clone(),
350                });
351            }
352        }
353    }
354
355    // An approver set nothing references is harmless (an operator staging one
356    // before the rule that uses it), so it is deliberately not an error.
357    Ok(())
358}
359
360/// Encode `s` as a Rego string literal.
361///
362/// Escapes the characters that would otherwise let operator-supplied text break
363/// out of the literal and alter the generated policy's meaning — an approver
364/// set named `", "decision": "allow` must not be able to rewrite the rule it
365/// appears in.
366fn rego_string(s: &str) -> String {
367    let mut out = String::with_capacity(s.len() + 2);
368    out.push('"');
369    for c in s.chars() {
370        match c {
371            '"' => out.push_str("\\\""),
372            '\\' => out.push_str("\\\\"),
373            '\n' => out.push_str("\\n"),
374            '\r' => out.push_str("\\r"),
375            '\t' => out.push_str("\\t"),
376            _ => out.push(c),
377        }
378    }
379    out.push('"');
380    out
381}
382
383/// Header of every generated module. Explains to whoever opens the stored row
384/// why hand-editing it will be rejected.
385const GENERATED_HEADER: &str = "\
386# Generated from the declarative approvals rules — do not hand-edit.
387#
388# The VTA re-derives this module from ext[\"openvtc.approvals\"] on every upsert
389# and refuses the write if the two disagree, so an edit here is not a way to
390# change behaviour: change the rules instead.
391";
392
393/// Render `rules` as a `vta.policy` Rego module.
394///
395/// One complete `decision` rule per entry, each guarded on
396/// `input.request.typeUri` (and `input.contextId` when the rule is scoped), so
397/// the module is *undefined* — it abstains — for every task it does not name,
398/// letting the permissive baseline underneath decide.
399///
400/// **Deterministic**: same rules in, byte-identical module out. That is what
401/// makes the server's re-derive-and-compare check meaningful.
402pub fn synthesize_rego(rules: &[ApprovalRule]) -> String {
403    let mut out = String::from("package vta.policy\n\nimport rego.v1\n\n");
404    out.push_str(GENERATED_HEADER);
405
406    for rule in rules {
407        out.push('\n');
408        let guard_type = format!("input.request.typeUri == {}", rego_string(&rule.task_type));
409        let guard_ctx = (!rule.contexts.is_empty()).then(|| {
410            let set = rule
411                .contexts
412                .iter()
413                .map(|c| rego_string(c))
414                .collect::<Vec<_>>()
415                .join(", ");
416            format!("input.contextId in {{{set}}}")
417        });
418
419        let head = match rule.requires {
420            Requires::Reauth => "decision := {\n\t\"decision\": \"requireStepUp\",\n}".to_string(),
421            Requires::Consent => format!(
422                "decision := {{\n\t\"decision\": \"requireConsent\",\n\t\"requireConsent\": \
423                 {{\"approverSet\": {set}, \"minApprovals\": {min}, \"excludeRequester\": \
424                 {exclude}}},\n}}",
425                set = rego_string(rule.approver_set.as_deref().unwrap_or_default()),
426                min = rule.effective_min_approvals(),
427                exclude = rule.effective_exclude_requester(),
428            ),
429        };
430
431        match guard_ctx {
432            None => out.push_str(&format!("{head} if {guard_type}\n")),
433            Some(ctx) => out.push_str(&format!("{head} if {{\n\t{guard_type}\n\t{ctx}\n}}\n")),
434        }
435    }
436
437    out
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    const ACL_GRANT: &str = "https://trusttasks.org/spec/acl/grant/0.1";
445    const WEBVH_UPDATE: &str = "https://trusttasks.org/spec/vta/webvh/dids/update/1.0";
446
447    fn sets(name: &str, members: &[&str]) -> ApproverSets {
448        let mut m = ApproverSets::new();
449        m.insert(
450            name.to_string(),
451            members.iter().map(|s| s.to_string()).collect(),
452        );
453        m
454    }
455
456    #[test]
457    fn synthesis_is_deterministic() {
458        let rules = vec![
459            ApprovalRule::reauth(ACL_GRANT),
460            ApprovalRule::consent(WEBVH_UPDATE, "ops"),
461        ];
462        assert_eq!(synthesize_rego(&rules), synthesize_rego(&rules));
463    }
464
465    /// The generated shape is a wire contract (the server byte-compares it), so
466    /// it is pinned rather than merely exercised.
467    #[test]
468    fn synthesis_shape_is_pinned() {
469        let mut consent = ApprovalRule::consent(WEBVH_UPDATE, "webvh-approvers");
470        consent.exclude_requester = Some(true);
471        consent.contexts = vec!["openvtc".into()];
472        let rego = synthesize_rego(&[ApprovalRule::reauth(ACL_GRANT), consent]);
473
474        assert!(rego.starts_with("package vta.policy\n\nimport rego.v1\n\n"));
475        assert!(rego.contains(
476            "decision := {\n\t\"decision\": \"requireStepUp\",\n} if input.request.typeUri == \
477             \"https://trusttasks.org/spec/acl/grant/0.1\"\n"
478        ));
479        assert!(rego.contains(
480            "\t\"requireConsent\": {\"approverSet\": \"webvh-approvers\", \"minApprovals\": 1, \
481             \"excludeRequester\": true},\n"
482        ));
483        assert!(rego.contains("\tinput.contextId in {\"openvtc\"}\n}"));
484    }
485
486    /// An approver-set name is operator-supplied text that lands inside a Rego
487    /// literal. If it could close the string it could rewrite the decision.
488    #[test]
489    fn injection_through_an_approver_set_name_is_escaped() {
490        let injected = "x\", \"decision\": \"allow";
491        let rego = synthesize_rego(&[ApprovalRule::consent(WEBVH_UPDATE, injected)]);
492        assert!(rego.contains("\\\""), "quote was not escaped: {rego}");
493        // The only `"decision":` keys are the two this generator wrote.
494        assert_eq!(rego.matches("\"decision\": \"require").count(), 1);
495        assert!(!rego.contains("\"decision\": \"allow\""));
496    }
497
498    #[test]
499    fn empty_rules_produce_an_abstaining_module() {
500        let rego = synthesize_rego(&[]);
501        assert!(!rego.contains("decision :="));
502    }
503
504    #[test]
505    fn consent_without_a_set_is_refused() {
506        let rule = ApprovalRule {
507            approver_set: None,
508            ..ApprovalRule::consent(WEBVH_UPDATE, "ops")
509        };
510        assert!(matches!(
511            validate(&[rule], &ApproverSets::new()),
512            Err(ApprovalsError::MissingApproverSet { .. })
513        ));
514    }
515
516    #[test]
517    fn unknown_and_empty_approver_sets_are_refused_at_write_time() {
518        let rule = ApprovalRule::consent(WEBVH_UPDATE, "ops");
519        assert!(matches!(
520            validate(std::slice::from_ref(&rule), &ApproverSets::new()),
521            Err(ApprovalsError::UnknownApproverSet { .. })
522        ));
523        assert!(matches!(
524            validate(&[rule], &sets("ops", &[])),
525            Err(ApprovalsError::EmptyApproverSet { .. })
526        ));
527    }
528
529    #[test]
530    fn a_threshold_no_set_could_meet_is_refused() {
531        let mut rule = ApprovalRule::consent(WEBVH_UPDATE, "ops");
532        rule.min_approvals = Some(3);
533        assert!(matches!(
534            validate(&[rule], &sets("ops", &["did:key:a", "did:key:b"])),
535            Err(ApprovalsError::ThresholdExceedsSet { .. })
536        ));
537    }
538
539    /// `reauth` has no third party. Accepting these fields and ignoring them
540    /// would let a rule read as two-person control and behave as one.
541    #[test]
542    fn consent_only_fields_are_refused_on_a_reauth_rule() {
543        let mut rule = ApprovalRule::reauth(ACL_GRANT);
544        rule.min_approvals = Some(2);
545        assert!(matches!(
546            validate(&[rule], &ApproverSets::new()),
547            Err(ApprovalsError::ConsentFieldOnReauth {
548                field: "minApprovals",
549                ..
550            })
551        ));
552
553        let mut rule = ApprovalRule::reauth(ACL_GRANT);
554        rule.approver_set = Some("ops".into());
555        assert!(matches!(
556            validate(&[rule], &sets("ops", &["did:key:a"])),
557            Err(ApprovalsError::ApproverSetOnReauth { .. })
558        ));
559    }
560
561    #[test]
562    fn overlapping_rules_for_one_task_type_are_refused() {
563        // Two unscoped rules: the generated module would have two complete
564        // rules firing on the same input.
565        let dup = vec![
566            ApprovalRule::reauth(ACL_GRANT),
567            ApprovalRule::reauth(ACL_GRANT),
568        ];
569        assert!(matches!(
570            validate(&dup, &ApproverSets::new()),
571            Err(ApprovalsError::OverlappingRules { .. })
572        ));
573
574        // Scoped but overlapping.
575        let mut a = ApprovalRule::reauth(ACL_GRANT);
576        a.contexts = vec!["x".into(), "y".into()];
577        let mut b = ApprovalRule::reauth(ACL_GRANT);
578        b.contexts = vec!["y".into()];
579        assert!(matches!(
580            validate(&[a, b], &ApproverSets::new()),
581            Err(ApprovalsError::OverlappingRules { .. })
582        ));
583    }
584
585    #[test]
586    fn disjoint_scoped_rules_for_one_task_type_are_allowed() {
587        let mut a = ApprovalRule::reauth(ACL_GRANT);
588        a.contexts = vec!["x".into()];
589        let mut b = ApprovalRule::consent(ACL_GRANT, "ops");
590        b.contexts = vec!["y".into()];
591        assert!(validate(&[a, b], &sets("ops", &["did:key:a"])).is_ok());
592    }
593
594    #[test]
595    fn a_malformed_task_type_is_refused() {
596        // Assembled rather than written out: a complete, version-terminated
597        // `trusttasks.org/spec/…` literal anywhere under a binding site is read
598        // by vtc-service's registry census as a claim that the registry serves
599        // that task. This one is a negative fixture — an empty slug — and the
600        // scanner has no way to tell the difference, so don't hand it one.
601        let empty_slug = format!("{}{}", "https://trusttasks.org/spec/", "0.1");
602        for bad in [
603            "acl/grant/0.1",
604            "https://trusttasks.org/acl/grant/0.1",
605            "https://trusttasks.org/spec/acl/grant",
606            "https://trusttasks.org/spec/acl/grant/v1",
607            &empty_slug,
608        ] {
609            let rule = ApprovalRule::reauth(bad);
610            assert!(
611                matches!(
612                    validate(&[rule], &ApproverSets::new()),
613                    Err(ApprovalsError::MalformedTaskType { .. })
614                ),
615                "{bad} should be refused"
616            );
617        }
618        assert!(validate(&[ApprovalRule::reauth(ACL_GRANT)], &ApproverSets::new()).is_ok());
619    }
620
621    #[test]
622    fn rules_round_trip_as_camel_case_json() {
623        let mut rule = ApprovalRule::consent(WEBVH_UPDATE, "ops");
624        rule.min_approvals = Some(2);
625        rule.exclude_requester = Some(true);
626        rule.contexts = vec!["openvtc".into()];
627        let json = serde_json::to_value(&rule).unwrap();
628        assert_eq!(json["taskType"], WEBVH_UPDATE);
629        assert_eq!(json["requires"], "consent");
630        assert_eq!(json["approverSet"], "ops");
631        assert_eq!(json["minApprovals"], 2);
632        assert_eq!(json["excludeRequester"], true);
633        assert_eq!(serde_json::from_value::<ApprovalRule>(json).unwrap(), rule);
634    }
635
636    /// Absent optional fields must not serialize — a stored row that grew empty
637    /// members would byte-compare differently after a round-trip.
638    #[test]
639    fn absent_optionals_are_omitted() {
640        let json = serde_json::to_value(ApprovalRule::reauth(ACL_GRANT)).unwrap();
641        assert_eq!(
642            json.as_object().unwrap().keys().collect::<BTreeSet<_>>(),
643            BTreeSet::from([&"requires".to_string(), &"taskType".to_string()])
644        );
645    }
646
647    #[test]
648    fn unknown_rule_fields_are_refused() {
649        let json = serde_json::json!({
650            "taskType": ACL_GRANT,
651            "requires": "reauth",
652            "approverSets": ["typo-for-approverSet"],
653        });
654        assert!(serde_json::from_value::<ApprovalRule>(json).is_err());
655    }
656}