Skip to main content

aph_core/
vault_mutation.rs

1//! Vendored cross-vault wire types carried by `LinkedMandate.vault_mutation`.
2//!
3//! These types originate in the cross-vault federation layer of the
4//! reference implementation. They are vendored here so an APH envelope can
5//! carry a `VaultMutationMandate` without an external dependency. Their
6//! serde output MUST match the originating implementation byte-for-byte —
7//! every attribute below is load-bearing wire shape:
8//!
9//! - `VaultMutationKind` is internally tagged: `#[serde(tag = "kind",
10//!   rename_all = "PascalCase")]`. The `rename_all` applies to VARIANT
11//!   names only (already PascalCase); struct-variant FIELDS keep their
12//!   snake_case Rust names on the wire (e.g.
13//!   `{"kind":"WriteInto","dest_vault_id":"..."}`).
14//! - `VaultMutationMandate` has NO `rename_all`: its fields serialize as
15//!   `kind`, `grant_scope_id`, and (when present) `ap2_signed_payload_b64`.
16//! - The ID newtypes are `#[serde(transparent)]` / newtype structs and
17//!   serialize as bare JSON strings.
18
19/// Wire-form view of a vault identifier (36-char UUID hex-dashed).
20#[derive(Clone, Debug, PartialEq, Eq, Hash, ::serde::Serialize, ::serde::Deserialize)]
21#[serde(transparent)]
22pub struct VaultIdStr(pub String);
23
24impl VaultIdStr {
25  /// Wraps an existing vault identifier string.
26  pub fn new(value: impl Into<String>) -> Self {
27    Self(value.into())
28  }
29  /// Borrows the identifier as it appears on the wire.
30  pub fn as_str(&self) -> &str {
31    &self.0
32  }
33}
34
35/// Wire-form view of a principal DID URI.
36#[derive(Clone, Debug, PartialEq, Eq, Hash, ::serde::Serialize, ::serde::Deserialize)]
37#[serde(transparent)]
38pub struct PrincipalDidUri(pub String);
39
40impl PrincipalDidUri {
41  /// Wraps an existing principal DID URI string.
42  pub fn new(value: impl Into<String>) -> Self {
43    Self(value.into())
44  }
45  /// Borrows the DID URI as it appears on the wire.
46  pub fn as_str(&self) -> &str {
47    &self.0
48  }
49}
50
51/// Stable identifier for a cross-vault grant. Wire form: 36-char UUID
52/// hex-dashed string.
53#[derive(Clone, Debug, PartialEq, Eq, Hash, ::serde::Serialize, ::serde::Deserialize)]
54#[serde(transparent)]
55pub struct GrantId(pub String);
56
57impl GrantId {
58  /// Wraps an existing grant identifier string.
59  pub fn new(value: impl Into<String>) -> Self {
60    Self(value.into())
61  }
62  /// Borrows the identifier as it appears on the wire.
63  pub fn as_str(&self) -> &str {
64    &self.0
65  }
66}
67
68/// What kind of cross-vault mutation the APH envelope is notarizing.
69#[derive(Clone, Debug, PartialEq, Eq, ::serde::Serialize, ::serde::Deserialize)]
70#[serde(tag = "kind", rename_all = "PascalCase")]
71pub enum VaultMutationKind {
72  /// Write data into another vault.
73  WriteInto {
74    /// Vault receiving the write.
75    dest_vault_id: VaultIdStr,
76  },
77  /// Share data out of a vault to the grantee.
78  ShareFrom {
79    /// Vault the shared data originates from.
80    src_vault_id: VaultIdStr,
81  },
82  /// Promote an artifact across a vault boundary.
83  CrossVaultPromote {
84    /// Artifact being promoted.
85    artifact_id: String,
86  },
87  /// Extend the granted authority to a downstream principal.
88  Redelegate {
89    /// DID of the principal receiving the redelegated authority.
90    downstream_grantee_id: PrincipalDidUri,
91  },
92  /// Withdraw a previously issued grant.
93  Revoke,
94  /// Move a bridged item between lifecycle stages.
95  BridgeStageTransition {
96    /// Stage the item is leaving.
97    from_stage: String,
98    /// Stage the item is entering.
99    to_stage: String,
100  },
101  /// Application-defined mutation, for shapes this enum does not model.
102  Custom {
103    /// Application that defines the mutation.
104    snapp_id: String,
105    /// Application-scoped mutation name.
106    mutation_slug: String,
107  },
108}
109
110/// Body of the `LinkedMandate.vault_mutation` field on the APH
111/// `NotarizationEnvelope`.
112#[derive(Clone, Debug, PartialEq, Eq, ::serde::Serialize, ::serde::Deserialize)]
113#[serde(deny_unknown_fields)]
114pub struct VaultMutationMandate {
115  /// Which mutation is being authorized.
116  pub kind: VaultMutationKind,
117  /// Grant scope the mutation executes under.
118  pub grant_scope_id: GrantId,
119  /// Base64 AP2-signed payload, present only for commerce-impacting
120  /// mutations. Omitted entirely when absent so the canonical bytes of
121  /// mandates without it are unchanged.
122  #[serde(skip_serializing_if = "Option::is_none", default)]
123  pub ap2_signed_payload_b64: Option<String>,
124}
125
126#[cfg(test)]
127mod tests {
128  // -------- wire-shape pins: VaultMutationKind (internally tagged) --------
129  //
130  // The enum-level `rename_all = "PascalCase"` renames VARIANTS only.
131  // Struct-variant field names stay snake_case on the wire. These tests
132  // pin that exact shape so an accidental `rename_all_fields` or camelCase
133  // drift fails loudly.
134
135  #[test]
136  fn write_into_wire_shape() {
137    // Most common cross-vault variant. Pins both halves of the mixed
138    // casing — PascalCase discriminator, snake_case payload key — because
139    // this object rides inside a signed envelope and any casing drift
140    // changes the canonical bytes the notary signed.
141    let v = super::VaultMutationKind::WriteInto {
142      dest_vault_id: super::VaultIdStr::new("11111111-1111-4111-8111-111111111111"),
143    };
144    let json = serde_json::to_value(&v).unwrap();
145    std::assert_eq!(
146      json,
147      serde_json::json!({
148        "kind": "WriteInto",
149        "dest_vault_id": "11111111-1111-4111-8111-111111111111"
150      })
151    );
152  }
153
154  #[test]
155  fn share_from_wire_shape() {
156    // Sibling of WriteInto with the opposite data direction; pinned
157    // separately so a copy-paste that swapped src_vault_id for
158    // dest_vault_id — inverting who grants access to whom — is caught.
159    let v = super::VaultMutationKind::ShareFrom {
160      src_vault_id: super::VaultIdStr::new("22222222-2222-4222-8222-222222222222"),
161    };
162    let json = serde_json::to_value(&v).unwrap();
163    std::assert_eq!(
164      json,
165      serde_json::json!({
166        "kind": "ShareFrom",
167        "src_vault_id": "22222222-2222-4222-8222-222222222222"
168      })
169    );
170  }
171
172  #[test]
173  fn cross_vault_promote_wire_shape() {
174    // Multi-word variant: confirms PascalCase renaming joins the words
175    // ("CrossVaultPromote", not "Cross_Vault_Promote") while the payload
176    // key stays snake_case — the split-casing rule at its trickiest.
177    let v = super::VaultMutationKind::CrossVaultPromote {
178      artifact_id: String::from("artifact-1"),
179    };
180    let json = serde_json::to_value(&v).unwrap();
181    std::assert_eq!(
182      json,
183      serde_json::json!({"kind": "CrossVaultPromote", "artifact_id": "artifact-1"})
184    );
185  }
186
187  #[test]
188  fn redelegate_wire_shape() {
189    // Redelegation extends authority to a third party, so its DID payload
190    // is the security-critical field; pinned to catch any change that
191    // would drop or rename the grantee inside a signed mandate.
192    let v = super::VaultMutationKind::Redelegate {
193      downstream_grantee_id: super::PrincipalDidUri::new("did:key:zDownstream"),
194    };
195    let json = serde_json::to_value(&v).unwrap();
196    std::assert_eq!(
197      json,
198      serde_json::json!({
199        "kind": "Redelegate",
200        "downstream_grantee_id": "did:key:zDownstream"
201      })
202    );
203  }
204
205  #[test]
206  fn revoke_wire_shape() {
207    // The only fieldless variant: internal tagging must still emit a
208    // tagged object ({"kind":"Revoke"}), not a bare string. A revocation
209    // that silently changed shape could be dropped by a strict parser.
210    let v = super::VaultMutationKind::Revoke;
211    let json = serde_json::to_value(&v).unwrap();
212    std::assert_eq!(json, serde_json::json!({"kind": "Revoke"}));
213  }
214
215  #[test]
216  fn bridge_stage_transition_wire_shape() {
217    // Two-field variant: pins that both keys are emitted flat alongside
218    // the discriminator (not nested under a sub-object) and keep their
219    // declared order-independent names.
220    let v = super::VaultMutationKind::BridgeStageTransition {
221      from_stage: String::from("staged"),
222      to_stage: String::from("committed"),
223    };
224    let json = serde_json::to_value(&v).unwrap();
225    std::assert_eq!(
226      json,
227      serde_json::json!({
228        "kind": "BridgeStageTransition",
229        "from_stage": "staged",
230        "to_stage": "committed"
231      })
232    );
233  }
234
235  #[test]
236  fn custom_wire_shape() {
237    // The extension escape hatch third parties will actually populate, so
238    // its shape is the one most likely to be relied on externally and the
239    // least safe to change.
240    let v = super::VaultMutationKind::Custom {
241      snapp_id: String::from("snapp-1"),
242      mutation_slug: String::from("do-thing"),
243    };
244    let json = serde_json::to_value(&v).unwrap();
245    std::assert_eq!(
246      json,
247      serde_json::json!({
248        "kind": "Custom",
249        "snapp_id": "snapp-1",
250        "mutation_slug": "do-thing"
251      })
252    );
253  }
254
255  // -------- round-trip: all kinds --------
256
257  #[test]
258  fn all_kinds_round_trip() {
259    // Exhaustive sweep over all seven variants: the wire-shape tests above
260    // pin serialization only, so this proves the deserializer accepts what
261    // the serializer emits for every variant, including any added later.
262    let kinds = std::vec![
263      super::VaultMutationKind::WriteInto {
264        dest_vault_id: super::VaultIdStr::new("11111111-1111-4111-8111-111111111111"),
265      },
266      super::VaultMutationKind::ShareFrom {
267        src_vault_id: super::VaultIdStr::new("22222222-2222-4222-8222-222222222222"),
268      },
269      super::VaultMutationKind::CrossVaultPromote {
270        artifact_id: String::from("artifact-1"),
271      },
272      super::VaultMutationKind::Redelegate {
273        downstream_grantee_id: super::PrincipalDidUri::new("did:key:zDownstream"),
274      },
275      super::VaultMutationKind::Revoke,
276      super::VaultMutationKind::BridgeStageTransition {
277        from_stage: String::from("staged"),
278        to_stage: String::from("committed"),
279      },
280      super::VaultMutationKind::Custom {
281        snapp_id: String::from("snapp-1"),
282        mutation_slug: String::from("do-thing"),
283      },
284    ];
285    for kind in kinds {
286      let s = serde_json::to_string(&kind).unwrap();
287      let back: super::VaultMutationKind = serde_json::from_str(&s).unwrap();
288      std::assert_eq!(kind, back, "round-trip mismatch for {:?}", kind);
289    }
290  }
291
292  // -------- wire-shape pins: VaultMutationMandate --------
293
294  #[test]
295  fn mandate_wire_shape_without_payload() {
296    // Absent-vs-null matters for signatures: ap2_signed_payload_b64 must be
297    // OMITTED entirely when None, not emitted as null. An extra null member
298    // would change the canonical JCS bytes and break verification of
299    // mandates signed before the field existed.
300    let m = super::VaultMutationMandate {
301      kind: super::VaultMutationKind::WriteInto {
302        dest_vault_id: super::VaultIdStr::new("11111111-1111-4111-8111-111111111111"),
303      },
304      grant_scope_id: super::GrantId::new("33333333-3333-4333-8333-333333333333"),
305      ap2_signed_payload_b64: std::option::Option::None,
306    };
307    let json = serde_json::to_value(&m).unwrap();
308    // No rename_all on the mandate: snake_case field names on the wire.
309    // `ap2_signed_payload_b64` is skipped entirely when None.
310    std::assert_eq!(
311      json,
312      serde_json::json!({
313        "kind": {
314          "kind": "WriteInto",
315          "dest_vault_id": "11111111-1111-4111-8111-111111111111"
316        },
317        "grant_scope_id": "33333333-3333-4333-8333-333333333333"
318      })
319    );
320  }
321
322  #[test]
323  fn mandate_wire_shape_with_payload() {
324    // Counterpart to the omitted-field pin: when the AP2 payload IS
325    // present it must appear under its exact snake_case key, since a
326    // commerce-impacting mutation's payment cross-link is the field a
327    // verifier most needs to find.
328    let m = super::VaultMutationMandate {
329      kind: super::VaultMutationKind::Revoke,
330      grant_scope_id: super::GrantId::new("33333333-3333-4333-8333-333333333333"),
331      ap2_signed_payload_b64: std::option::Option::Some(String::from("cGF5bG9hZA==")),
332    };
333    let json = serde_json::to_value(&m).unwrap();
334    std::assert_eq!(
335      json,
336      serde_json::json!({
337        "kind": {"kind": "Revoke"},
338        "grant_scope_id": "33333333-3333-4333-8333-333333333333",
339        "ap2_signed_payload_b64": "cGF5bG9hZA=="
340      })
341    );
342  }
343
344  #[test]
345  fn mandate_round_trip() {
346    // Whole-struct round-trip over a populated mandate, ensuring the nested
347    // kind object and the flat sibling fields survive together — the
348    // per-field pins above cannot catch a broken composition.
349    let m = super::VaultMutationMandate {
350      kind: super::VaultMutationKind::Custom {
351        snapp_id: String::from("snapp-1"),
352        mutation_slug: String::from("do-thing"),
353      },
354      grant_scope_id: super::GrantId::new("33333333-3333-4333-8333-333333333333"),
355      ap2_signed_payload_b64: std::option::Option::Some(String::from("cGF5bG9hZA==")),
356    };
357    let s = serde_json::to_string(&m).unwrap();
358    let back: super::VaultMutationMandate = serde_json::from_str(&s).unwrap();
359    std::assert_eq!(m, back);
360  }
361
362  #[test]
363  fn mandate_deserializes_without_ap2_payload() {
364    // Backward compatibility: mandates minted before ap2_signed_payload_b64
365    // was added omit the key entirely and must still parse (via
366    // #[serde(default)]) rather than failing as malformed.
367    let s = serde_json::json!({
368      "kind": {"kind": "Revoke"},
369      "grant_scope_id": "33333333-3333-4333-8333-333333333333"
370    })
371    .to_string();
372    let m: super::VaultMutationMandate =
373      serde_json::from_str(&s).expect("must deserialize with ap2_signed_payload_b64 omitted");
374    std::assert!(m.ap2_signed_payload_b64.is_none());
375  }
376
377  // -------- ID newtypes serialize as bare strings --------
378
379  #[test]
380  fn id_newtypes_are_transparent_strings() {
381    // These newtypes exist for type safety in Rust only — on the wire they
382    // must stay bare JSON strings. Losing #[serde(transparent)] would wrap
383    // each id in an object and invalidate every signed envelope carrying a
384    // vault mutation.
385    std::assert_eq!(
386      serde_json::to_value(super::VaultIdStr::new("v-1")).unwrap(),
387      serde_json::json!("v-1")
388    );
389    std::assert_eq!(
390      serde_json::to_value(super::PrincipalDidUri::new("did:key:zX")).unwrap(),
391      serde_json::json!("did:key:zX")
392    );
393    std::assert_eq!(
394      serde_json::to_value(super::GrantId::new("g-1")).unwrap(),
395      serde_json::json!("g-1")
396    );
397    std::assert_eq!(super::VaultIdStr::new("v-1").as_str(), "v-1");
398    std::assert_eq!(super::PrincipalDidUri::new("did:key:zX").as_str(), "did:key:zX");
399    std::assert_eq!(super::GrantId::new("g-1").as_str(), "g-1");
400  }
401}