Skip to main content

vta_sdk/protocols/
policy_management.rs

1//! `policy/*` — runtime management of the VTA's Policy Decision Point.
2//!
3//! The canonical family (`policy/list/0.2`, `policy/get/0.1`,
4//! `policy/upsert/0.2`, `policy/delete/0.1`, `policy/evaluate/0.3`). Before
5//! this, the VTA had **no** runtime policy surface at all: the only way to
6//! change what the PDP enforced was to edit `config.toml` and restart, which is
7//! why the declarative approvals model now lives in a policy row instead of a
8//! config section.
9//!
10//! # Members this maintainer does not implement
11//!
12//! `policy/activate/0.1` and `policy/active/0.1` are **deliberately absent**.
13//! Canonical models an activation pointer — one active module per slot — which
14//! VTC needs (`active_policies:<purpose>`) but the VTA does not have: here the
15//! active set is *every enabled row*, evaluated in priority order, so there is
16//! no pointer to flip. Implementing `activate` would mean inventing a slot
17//! concept purely to satisfy a URI, and `active` would return the same list
18//! `policy/list` already returns. Absence is the honest answer; a caller gets
19//! `UnsupportedType` rather than a surface that pretends.
20//!
21//! Correspondingly, `enabled` and `priority` **are** carried here (VTC omits
22//! them) — they are exactly how this maintainer selects.
23
24use serde::{Deserialize, Serialize};
25
26/// Canonical `policy/_shared` **PolicyModule** — the projection of a stored
27/// policy row returned by `list`, `get`, and `upsert`.
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29#[serde(rename_all = "camelCase")]
30#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
31pub struct PolicyModuleView {
32    pub id: String,
33    pub name: String,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub description: Option<String>,
36    /// Rego source. Entry point is the package's `decision` rule.
37    pub module: String,
38    /// Trust contexts this policy applies to; empty ⇒ all.
39    #[serde(default, skip_serializing_if = "Vec::is_empty")]
40    pub applies_to: Vec<String>,
41    /// Higher runs first; the first non-null `decision` wins.
42    pub priority: i32,
43    pub enabled: bool,
44    /// Monotone revision counter, and the optimistic-concurrency token
45    /// `upsert`/`delete` check `expectedVersion` against.
46    pub version: u64,
47    pub created_at: String,
48    pub updated_at: String,
49    /// Ecosystem extension members. Carries the declarative approvals model
50    /// (`openvtc.approvals` / `openvtc.approver-sets`) on the reserved row —
51    /// see [`crate::approvals`].
52    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
53    #[cfg_attr(feature = "openapi", schema(value_type = Object))]
54    pub ext: serde_json::Value,
55}
56
57/// `policy/list/0.2` request.
58#[derive(Debug, Clone, Default, Serialize, Deserialize)]
59#[serde(rename_all = "camelCase", deny_unknown_fields)]
60#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
61pub struct ListPoliciesBody {
62    /// Restrict to policies applying in this context (an unscoped policy
63    /// applies everywhere, so it matches every filter).
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub context_id: Option<String>,
66    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
67    pub enabled_only: bool,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub cursor: Option<String>,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub page_size: Option<u64>,
72}
73
74/// `policy/list/0.2` response.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76#[serde(rename_all = "camelCase")]
77#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
78pub struct ListPoliciesResultBody {
79    pub policies: Vec<PolicyModuleView>,
80    /// Canonical-required: more matching modules exist beyond this page.
81    pub truncated: bool,
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub cursor: Option<String>,
84}
85
86/// `policy/get/0.1` request.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88#[serde(rename_all = "camelCase", deny_unknown_fields)]
89#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
90pub struct GetPolicyBody {
91    pub id: String,
92}
93
94/// `policy/get/0.1` response.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96#[serde(rename_all = "camelCase")]
97#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
98pub struct GetPolicyResultBody {
99    pub policy: PolicyModuleView,
100}
101
102/// `policy/upsert/0.2` request.
103///
104/// `module` is the Rego source and is **authoritative** — the maintainer
105/// validates it, never invents it. A declarative approvals row additionally
106/// carries its rules in `ext`, and the VTA re-derives the module from them and
107/// refuses the write if the two disagree; see [`crate::approvals`] for why that
108/// check exists rather than server-side synthesis.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110#[serde(rename_all = "camelCase", deny_unknown_fields)]
111#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
112pub struct UpsertPolicyBody {
113    /// Target row. Omit to let the maintainer allocate one.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub id: Option<String>,
116    pub name: String,
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub description: Option<String>,
119    pub module: String,
120    #[serde(default, skip_serializing_if = "Vec::is_empty")]
121    pub applies_to: Vec<String>,
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub priority: Option<i32>,
124    pub enabled: bool,
125    /// Optimistic concurrency: when present it MUST equal the row's current
126    /// version, else the caller is overwriting a revision it never saw.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub expected_version: Option<u64>,
129    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
130    #[cfg_attr(feature = "openapi", schema(value_type = Object))]
131    pub ext: serde_json::Value,
132}
133
134/// `policy/upsert/0.2` response.
135#[derive(Debug, Clone, Serialize, Deserialize)]
136#[serde(rename_all = "camelCase")]
137#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
138pub struct UpsertPolicyResultBody {
139    pub policy: PolicyModuleView,
140    /// True when this call created a new row rather than revising one.
141    pub created: bool,
142}
143
144/// `policy/delete/0.1` request.
145#[derive(Debug, Clone, Serialize, Deserialize)]
146#[serde(rename_all = "camelCase", deny_unknown_fields)]
147#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
148pub struct DeletePolicyBody {
149    pub id: String,
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub expected_version: Option<u64>,
152    /// Operator rationale, recorded in the audit row.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub reason: Option<String>,
155}
156
157/// `policy/delete/0.1` response.
158#[derive(Debug, Clone, Serialize, Deserialize)]
159#[serde(rename_all = "camelCase")]
160#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
161pub struct DeletePolicyResultBody {
162    /// Id of the removed module.
163    pub id: String,
164    /// RFC 3339 removal timestamp.
165    pub deleted_at: String,
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn upsert_body_is_camel_case_and_strict() {
174        let body = UpsertPolicyBody {
175            id: Some("approvals".into()),
176            name: "n".into(),
177            description: None,
178            module: "package vta.policy".into(),
179            applies_to: vec![],
180            priority: Some(100),
181            enabled: true,
182            expected_version: Some(3),
183            ext: serde_json::json!({ "openvtc.approvals": [] }),
184        };
185        let v = serde_json::to_value(&body).unwrap();
186        assert_eq!(v["expectedVersion"], 3);
187        assert!(v.get("appliesTo").is_none(), "empty vec must be omitted");
188
189        // snake_case must not deserialize — the recurring casing-drift defect
190        // class (#656/#658) is exactly this.
191        let snake = serde_json::json!({
192            "name": "n", "module": "m", "enabled": true, "expected_version": 3
193        });
194        assert!(serde_json::from_value::<UpsertPolicyBody>(snake).is_err());
195    }
196
197    /// Canonical `policy/delete/0.1` names the removed module `id` and requires
198    /// a `deletedAt`; an earlier draft here called it `deleted`, which the
199    /// conformance witness caught.
200    #[test]
201    fn delete_result_matches_the_canonical_member_names() {
202        let v = serde_json::to_value(DeletePolicyResultBody {
203            id: "approvals".into(),
204            deleted_at: "2026-08-09T00:00:00Z".into(),
205        })
206        .unwrap();
207        assert_eq!(v["id"], "approvals");
208        assert_eq!(v["deletedAt"], "2026-08-09T00:00:00Z");
209    }
210}