lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

workflow.rs (24636B)


      1 //! Validated, side-effect-free trade workflow plans.
      2 //!
      3 //! Plans describe required private-term verification, signing, persistence,
      4 //! and delivery actions. Hosts retain exclusive authority to execute them and
      5 //! to define cancellation, deadlines, and durable commit behavior.
      6 
      7 #[cfg(not(feature = "std"))]
      8 use alloc::{format, string::String, vec, vec::Vec};
      9 #[cfg(feature = "std")]
     10 use std::{string::String, vec, vec::Vec};
     11 
     12 use core::fmt;
     13 
     14 use radroots_event::{
     15     id::{CandidateId, MutationId, TradeId},
     16     trade::{
     17         RADROOTS_TRADE_SCHEMA_VERSION, TradeMutationBodyV1, TradeMutationEnvelopeV1,
     18         TradeMutationKindV1,
     19     },
     20 };
     21 
     22 /// A host action required to execute a prepared trade plan.
     23 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
     24 #[non_exhaustive]
     25 pub enum WorkflowAction {
     26     /// Authorize and sign the canonical mutation.
     27     Sign,
     28     /// Persist the signed mutation atomically.
     29     Persist,
     30     /// Deliver the signed event through an explicitly selected transport.
     31     Deliver,
     32     /// Verify required private terms before signing.
     33     VerifyPrivateTerms,
     34 }
     35 
     36 /// Private-term material a host must verify before executing a plan.
     37 #[derive(Clone, Debug, PartialEq, Eq)]
     38 pub struct PrivateTermsPlan {
     39     candidate_id: CandidateId,
     40     artifact_id: String,
     41     schema_id: String,
     42     ciphertext_commitment: String,
     43 }
     44 
     45 impl PrivateTermsPlan {
     46     /// Returns the candidate whose private terms must be verified.
     47     pub const fn candidate_id(&self) -> &CandidateId {
     48         &self.candidate_id
     49     }
     50 
     51     /// Returns the host-owned encrypted artifact identifier.
     52     pub fn artifact_id(&self) -> &str {
     53         &self.artifact_id
     54     }
     55 
     56     /// Returns the private-term schema identifier.
     57     pub fn schema_id(&self) -> &str {
     58         &self.schema_id
     59     }
     60 
     61     /// Returns the expected ciphertext commitment.
     62     pub fn ciphertext_commitment(&self) -> &str {
     63         &self.ciphertext_commitment
     64     }
     65 }
     66 
     67 /// A validated description of a trade operation and its required host actions.
     68 ///
     69 /// Constructing a plan performs no signing, persistence, delivery, filesystem
     70 /// access, scheduling, or private-artifact access.
     71 #[derive(Clone, Debug, PartialEq, Eq)]
     72 #[must_use = "a workflow plan has no effect until a host explicitly executes its actions"]
     73 pub struct WorkflowPlan {
     74     mutation: TradeMutationEnvelopeV1,
     75     mutation_id: MutationId,
     76     kind: TradeMutationKindV1,
     77     required_actions: Vec<WorkflowAction>,
     78     private_terms: Option<PrivateTermsPlan>,
     79 }
     80 
     81 impl WorkflowPlan {
     82     /// Validates a canonical trade mutation and prepares its host action plan.
     83     pub fn prepare(mutation: TradeMutationEnvelopeV1) -> Result<Self, Error> {
     84         if mutation.schema_version != RADROOTS_TRADE_SCHEMA_VERSION {
     85             return Err(Error::unsupported_schema(mutation.schema_version));
     86         }
     87         let mutation_id = mutation
     88             .mutation_id
     89             .ok_or_else(Error::missing_mutation_id)?;
     90         mutation.validate().map_err(Error::invalid_mutation)?;
     91 
     92         let kind = mutation.mutation_kind();
     93         let private_terms = private_terms_plan(&mutation.body)?;
     94         let mut required_actions = vec![
     95             WorkflowAction::Sign,
     96             WorkflowAction::Persist,
     97             WorkflowAction::Deliver,
     98         ];
     99         if private_terms.is_some() {
    100             required_actions.insert(0, WorkflowAction::VerifyPrivateTerms);
    101         }
    102 
    103         debug_assert_eq!(mutation.mutation_id, Some(mutation_id));
    104         Ok(Self {
    105             mutation,
    106             mutation_id,
    107             kind,
    108             required_actions,
    109             private_terms,
    110         })
    111     }
    112 
    113     /// Returns the operation kind represented by the plan.
    114     pub const fn kind(&self) -> TradeMutationKindV1 {
    115         self.kind
    116     }
    117 
    118     /// Returns the canonical mutation identifier.
    119     pub const fn mutation_id(&self) -> &MutationId {
    120         &self.mutation_id
    121     }
    122 
    123     /// Returns the canonical trade identifier.
    124     pub const fn trade_id(&self) -> &TradeId {
    125         &self.mutation.trade_id
    126     }
    127 
    128     /// Returns the validated canonical mutation without transferring ownership.
    129     pub const fn mutation(&self) -> &TradeMutationEnvelopeV1 {
    130         &self.mutation
    131     }
    132 
    133     /// Returns the ordered host actions required to execute the plan.
    134     pub fn required_actions(&self) -> &[WorkflowAction] {
    135         &self.required_actions
    136     }
    137 
    138     /// Returns required private-term verification material, when present.
    139     pub const fn private_terms(&self) -> Option<&PrivateTermsPlan> {
    140         self.private_terms.as_ref()
    141     }
    142 
    143     /// Consumes the plan and returns the validated canonical mutation.
    144     pub fn into_mutation(self) -> TradeMutationEnvelopeV1 {
    145         self.mutation
    146     }
    147 }
    148 
    149 /// Stable workflow-planning error classification.
    150 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    151 #[non_exhaustive]
    152 pub enum ErrorKind {
    153     /// The mutation uses an unsupported trade schema.
    154     UnsupportedSchema,
    155     /// The canonical mutation identifier is absent.
    156     MissingMutationId,
    157     /// The mutation violates its event-domain contract.
    158     InvalidMutation,
    159     /// Required private-term metadata is incomplete.
    160     InvalidPrivateTerms,
    161 }
    162 
    163 /// Workflow-plan validation failure with a redacted diagnostic.
    164 #[derive(Clone, Debug, PartialEq, Eq)]
    165 pub struct Error {
    166     kind: ErrorKind,
    167     message: String,
    168     source: Option<radroots_event::trade::TradeProtocolError>,
    169 }
    170 
    171 impl Error {
    172     fn unsupported_schema(found: u16) -> Self {
    173         Self {
    174             kind: ErrorKind::UnsupportedSchema,
    175             message: format!(
    176                 "unsupported trade schema version {found}; expected {RADROOTS_TRADE_SCHEMA_VERSION}"
    177             ),
    178             source: None,
    179         }
    180     }
    181 
    182     fn missing_mutation_id() -> Self {
    183         Self {
    184             kind: ErrorKind::MissingMutationId,
    185             message: "canonical trade mutation identifier is missing".into(),
    186             source: None,
    187         }
    188     }
    189 
    190     fn invalid_mutation(source: radroots_event::trade::TradeProtocolError) -> Self {
    191         Self {
    192             kind: ErrorKind::InvalidMutation,
    193             message: format!("invalid canonical trade mutation: {source}"),
    194             source: Some(source),
    195         }
    196     }
    197 
    198     fn invalid_private_terms(message: &'static str) -> Self {
    199         Self {
    200             kind: ErrorKind::InvalidPrivateTerms,
    201             message: message.into(),
    202             source: None,
    203         }
    204     }
    205 
    206     /// Returns the stable error classification.
    207     pub const fn kind(&self) -> ErrorKind {
    208         self.kind
    209     }
    210 
    211     /// Returns the underlying event-domain validation failure, when present.
    212     pub const fn protocol_error(&self) -> Option<&radroots_event::trade::TradeProtocolError> {
    213         self.source.as_ref()
    214     }
    215 }
    216 
    217 impl fmt::Display for Error {
    218     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    219         formatter.write_str(&self.message)
    220     }
    221 }
    222 
    223 impl core::error::Error for Error {
    224     #[cfg(feature = "std")]
    225     fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
    226         self.source
    227             .as_ref()
    228             .map(|source| source as &(dyn core::error::Error + 'static))
    229     }
    230 }
    231 
    232 fn private_terms_plan(body: &TradeMutationBodyV1) -> Result<Option<PrivateTermsPlan>, Error> {
    233     let candidate = match body {
    234         TradeMutationBodyV1::Proposal { candidate }
    235         | TradeMutationBodyV1::RevisionProposal { candidate } => candidate,
    236         TradeMutationBodyV1::Decision { .. }
    237         | TradeMutationBodyV1::RevisionDecision { .. }
    238         | TradeMutationBodyV1::Cancellation { .. } => return Ok(None),
    239     };
    240     let required =
    241         candidate.fulfillment.requires_private_terms || candidate.private_terms.is_some();
    242     if !required {
    243         return Ok(None);
    244     }
    245     let candidate_id = candidate.candidate_id.ok_or_else(|| {
    246         Error::invalid_private_terms("required private terms need a canonical candidate identifier")
    247     })?;
    248     let private_terms = candidate.private_terms.as_ref().ok_or_else(|| {
    249         Error::invalid_private_terms("required private terms need an encrypted artifact reference")
    250     })?;
    251     Ok(Some(PrivateTermsPlan {
    252         candidate_id,
    253         artifact_id: private_terms.artifact_id.clone(),
    254         schema_id: private_terms.schema_id.clone(),
    255         ciphertext_commitment: private_terms.ciphertext_commitment.clone(),
    256     }))
    257 }
    258 
    259 #[cfg(test)]
    260 mod tests {
    261     use super::*;
    262     use crate::test_fixtures::{FIXTURE_ALICE_PUBLIC_KEY_HEX, FIXTURE_BOB_PUBLIC_KEY_HEX};
    263     use radroots_event::{
    264         id::{ClassifiedListingAddress, DTag, EventId, InventoryBinId},
    265         trade::{
    266             FulfillmentProfileV1, RADROOTS_TRADE_CANCELLATION_CONTRACT_ID,
    267             RADROOTS_TRADE_DECISION_CONTRACT_ID, RADROOTS_TRADE_PROPOSAL_CONTRACT_ID,
    268             RADROOTS_TRADE_REVISION_DECISION_CONTRACT_ID,
    269             RADROOTS_TRADE_REVISION_PROPOSAL_CONTRACT_ID, TradeCancellationProfileV1,
    270             TradeCandidateLineV1, TradeCandidateTermsV1, TradeDecisionV1,
    271             TradeEconomicAdjustmentV1, TradeEconomicsProfileV1, TradeLineTombstoneV1,
    272             TradePrivateTermsRefV1, canonical_trade_mutation_content,
    273         },
    274     };
    275     use radroots_identity::PublicKey;
    276 
    277     #[cfg(feature = "json")]
    278     const CANONICAL_WORKFLOW_VECTORS: &str =
    279         include_str!("../../../contracts/conformance/vectors/trade/prepare_workflow.v1.json");
    280     #[cfg(feature = "json")]
    281     const PACKAGED_WORKFLOW_VECTORS: &str =
    282         include_str!("../tests/fixtures/prepare_workflow.v1.json");
    283 
    284     fn pubkey(value: &str) -> PublicKey {
    285         PublicKey::from_hex(value).expect("fixture public key")
    286     }
    287 
    288     fn mutation_id(marker: char) -> MutationId {
    289         MutationId::parse(core::iter::repeat_n(marker, 64).collect::<String>())
    290             .expect("fixture mutation id")
    291     }
    292 
    293     fn candidate(suffix: &str) -> TradeCandidateTermsV1 {
    294         TradeCandidateTermsV1 {
    295             candidate_id: None,
    296             schema_version: RADROOTS_TRADE_SCHEMA_VERSION,
    297             base_candidate_id: None,
    298             supersession_intent: None,
    299             buyer_pubkey: pubkey(FIXTURE_ALICE_PUBLIC_KEY_HEX),
    300             seller_pubkey: pubkey(FIXTURE_BOB_PUBLIC_KEY_HEX),
    301             farm_id: DTag::parse("farm-1").expect("farm id"),
    302             lines: vec![TradeCandidateLineV1 {
    303                 line_id: DTag::parse(format!("line-{suffix}")).expect("line id"),
    304                 listing_addr: ClassifiedListingAddress::parse(format!(
    305                     "30402:{FIXTURE_BOB_PUBLIC_KEY_HEX}:listing-{suffix}"
    306                 ))
    307                 .expect("listing address"),
    308                 listing_event_id: EventId::parse("cc".repeat(32)).expect("event id"),
    309                 listing_snapshot_sha256: "dd".repeat(32),
    310                 product_id: format!("carrots-{suffix}"),
    311                 option_id: None,
    312                 bin_id: InventoryBinId::parse(format!("bin-{suffix}")).expect("bin id"),
    313                 quantity_mantissa: "2".into(),
    314                 quantity_scale: 0,
    315                 unit_code: "count".into(),
    316                 unit_profile: "mvp-count".into(),
    317                 unit_price_mantissa: "500".into(),
    318                 currency_code: "USD".into(),
    319                 line_subtotal_mantissa: "1000".into(),
    320                 replaces_line_id: None,
    321             }],
    322             line_tombstones: Vec::<TradeLineTombstoneV1>::new(),
    323             economics: TradeEconomicsProfileV1 {
    324                 profile_id: "mvp-fixed".into(),
    325                 currency_code: "USD".into(),
    326                 currency_exponent: 2,
    327                 rounding_profile: "half-even".into(),
    328                 subtotal_mantissa: "1000".into(),
    329                 discount_total_mantissa: "0".into(),
    330                 adjustment_total_mantissa: "0".into(),
    331                 total_mantissa: "1000".into(),
    332                 adjustments: Vec::<TradeEconomicAdjustmentV1>::new(),
    333             },
    334             fulfillment: FulfillmentProfileV1 {
    335                 profile_id: "market-pickup".into(),
    336                 method: "pickup".into(),
    337                 starts_at_unix_s: 1_800_000_000,
    338                 ends_at_unix_s: 1_800_003_600,
    339                 timezone: "America/New_York".into(),
    340                 utc_offset_seconds: -18_000,
    341                 fold: 0,
    342                 location_class: "farmstand".into(),
    343                 requires_private_terms: true,
    344             },
    345             cancellation: TradeCancellationProfileV1 {
    346                 profile_id: "buyer-pre-agreement".into(),
    347                 buyer_pre_agreement: true,
    348                 post_agreement_cutoff_unix_s: None,
    349             },
    350             private_terms: Some(TradePrivateTermsRefV1 {
    351                 artifact_id: "artifact-1".into(),
    352                 schema_id: "radroots.private.fulfillment.v1".into(),
    353                 ciphertext_commitment: "ee".repeat(32),
    354                 required_acknowledgement: true,
    355             }),
    356             proposal_expires_at_unix_s: 1_799_999_000,
    357         }
    358     }
    359 
    360     fn envelope(contract_id: &str, body: TradeMutationBodyV1) -> TradeMutationEnvelopeV1 {
    361         let is_initial = body.mutation_kind() == TradeMutationKindV1::Proposal;
    362         canonical_trade_mutation_content(TradeMutationEnvelopeV1 {
    363             mutation_id: None,
    364             contract_id: contract_id.into(),
    365             schema_version: RADROOTS_TRADE_SCHEMA_VERSION,
    366             trade_id: TradeId::parse("11".repeat(16)).expect("trade id"),
    367             root_mutation_id: (!is_initial).then(|| mutation_id('1')),
    368             buyer_pubkey: pubkey(FIXTURE_ALICE_PUBLIC_KEY_HEX),
    369             seller_pubkey: pubkey(FIXTURE_BOB_PUBLIC_KEY_HEX),
    370             farm_id: DTag::parse("farm-1").expect("farm id"),
    371             parent_mutation_ids: if is_initial {
    372                 Vec::new()
    373             } else {
    374                 vec![mutation_id('1')]
    375             },
    376             author_pubkey: pubkey(FIXTURE_ALICE_PUBLIC_KEY_HEX),
    377             counterparty_pubkey: pubkey(FIXTURE_BOB_PUBLIC_KEY_HEX),
    378             authored_at_unix_s: 100,
    379             body,
    380         })
    381         .expect("canonical workflow mutation")
    382         .envelope
    383     }
    384 
    385     fn all_operation_mutations() -> Vec<TradeMutationEnvelopeV1> {
    386         let proposal = envelope(
    387             RADROOTS_TRADE_PROPOSAL_CONTRACT_ID,
    388             TradeMutationBodyV1::Proposal {
    389                 candidate: candidate("1"),
    390             },
    391         );
    392         let proposal_id = proposal.mutation_id.expect("proposal id");
    393         let candidate_id = match &proposal.body {
    394             TradeMutationBodyV1::Proposal { candidate } => {
    395                 candidate.candidate_id.expect("candidate id")
    396             }
    397             _ => unreachable!(),
    398         };
    399         vec![
    400             proposal,
    401             envelope(
    402                 RADROOTS_TRADE_DECISION_CONTRACT_ID,
    403                 TradeMutationBodyV1::Decision {
    404                     proposal_mutation_id: proposal_id,
    405                     candidate_id,
    406                     decision: TradeDecisionV1::Declined {
    407                         reason: "unavailable".into(),
    408                     },
    409                 },
    410             ),
    411             envelope(
    412                 RADROOTS_TRADE_REVISION_PROPOSAL_CONTRACT_ID,
    413                 TradeMutationBodyV1::RevisionProposal {
    414                     candidate: candidate("2"),
    415                 },
    416             ),
    417             envelope(
    418                 RADROOTS_TRADE_REVISION_DECISION_CONTRACT_ID,
    419                 TradeMutationBodyV1::RevisionDecision {
    420                     proposal_mutation_id: mutation_id('2'),
    421                     candidate_id,
    422                     decision: TradeDecisionV1::Declined {
    423                         reason: "still unavailable".into(),
    424                     },
    425                 },
    426             ),
    427             envelope(
    428                 RADROOTS_TRADE_CANCELLATION_CONTRACT_ID,
    429                 TradeMutationBodyV1::Cancellation {
    430                     target_candidate_id: Some(candidate_id),
    431                     target_claim_mutation_id: None,
    432                     reason: "cancelled".into(),
    433                 },
    434             ),
    435         ]
    436     }
    437 
    438     #[test]
    439     fn plans_cover_every_trade_operation_without_host_side_effects() {
    440         let plans = all_operation_mutations()
    441             .into_iter()
    442             .map(WorkflowPlan::prepare)
    443             .collect::<Result<Vec<_>, _>>()
    444             .expect("valid workflow plans");
    445 
    446         assert_eq!(
    447             plans.iter().map(WorkflowPlan::kind).collect::<Vec<_>>(),
    448             vec![
    449                 TradeMutationKindV1::Proposal,
    450                 TradeMutationKindV1::Decision,
    451                 TradeMutationKindV1::RevisionProposal,
    452                 TradeMutationKindV1::RevisionDecision,
    453                 TradeMutationKindV1::Cancellation,
    454             ]
    455         );
    456         assert_eq!(
    457             plans[0].required_actions(),
    458             [
    459                 WorkflowAction::VerifyPrivateTerms,
    460                 WorkflowAction::Sign,
    461                 WorkflowAction::Persist,
    462                 WorkflowAction::Deliver,
    463             ]
    464         );
    465         assert_eq!(
    466             plans[0].private_terms().unwrap().artifact_id(),
    467             "artifact-1"
    468         );
    469         let private = plans[0].private_terms().expect("private terms");
    470         assert_eq!(
    471             private.candidate_id(),
    472             match &plans[0].mutation().body {
    473                 TradeMutationBodyV1::Proposal { candidate } =>
    474                     candidate.candidate_id.as_ref().expect("candidate id"),
    475                 _ => unreachable!(),
    476             }
    477         );
    478         assert_eq!(private.schema_id(), "radroots.private.fulfillment.v1");
    479         assert_eq!(private.ciphertext_commitment(), "ee".repeat(32));
    480         assert_eq!(
    481             plans[0].mutation_id(),
    482             plans[0]
    483                 .mutation()
    484                 .mutation_id
    485                 .as_ref()
    486                 .expect("mutation id")
    487         );
    488         assert_eq!(plans[0].trade_id(), &plans[0].mutation().trade_id);
    489         assert_eq!(plans[0].clone().into_mutation(), *plans[0].mutation());
    490         for plan in &plans[1..] {
    491             let expected = if plan.kind() == TradeMutationKindV1::RevisionProposal {
    492                 &[
    493                     WorkflowAction::VerifyPrivateTerms,
    494                     WorkflowAction::Sign,
    495                     WorkflowAction::Persist,
    496                     WorkflowAction::Deliver,
    497                 ][..]
    498             } else {
    499                 &[
    500                     WorkflowAction::Sign,
    501                     WorkflowAction::Persist,
    502                     WorkflowAction::Deliver,
    503                 ][..]
    504             };
    505             assert_eq!(plan.required_actions(), expected);
    506         }
    507     }
    508 
    509     #[test]
    510     fn plan_validation_rejects_unsupported_missing_and_invalid_transitions() {
    511         let mut unsupported = all_operation_mutations().remove(0);
    512         unsupported.schema_version += 1;
    513         assert_eq!(
    514             WorkflowPlan::prepare(unsupported).unwrap_err().kind(),
    515             ErrorKind::UnsupportedSchema
    516         );
    517 
    518         let mut missing_id = all_operation_mutations().remove(0);
    519         missing_id.mutation_id = None;
    520         assert_eq!(
    521             WorkflowPlan::prepare(missing_id).unwrap_err().kind(),
    522             ErrorKind::MissingMutationId
    523         );
    524 
    525         let mut invalid = all_operation_mutations().remove(1);
    526         invalid.parent_mutation_ids.clear();
    527         assert_eq!(
    528             WorkflowPlan::prepare(invalid.clone()).unwrap_err().kind(),
    529             ErrorKind::InvalidMutation
    530         );
    531         let error = WorkflowPlan::prepare(invalid).expect_err("invalid mutation");
    532         assert!(error.protocol_error().is_some());
    533         assert!(!error.to_string().is_empty());
    534         #[cfg(feature = "std")]
    535         assert!(core::error::Error::source(&error).is_some());
    536     }
    537 
    538     #[cfg(feature = "json")]
    539     #[test]
    540     fn workflow_conformance_vectors_execute_every_mutation_and_error_case() {
    541         assert_eq!(PACKAGED_WORKFLOW_VECTORS, CANONICAL_WORKFLOW_VECTORS);
    542         let suite: serde_json::Value =
    543             serde_json::from_str(PACKAGED_WORKFLOW_VECTORS).expect("workflow vector suite");
    544         assert_eq!(suite["suite"], "trade_workflow");
    545         assert_eq!(suite["contract_version"], "1.0.0");
    546         let vectors = suite["vectors"].as_array().expect("workflow vectors");
    547         assert_eq!(vectors.len(), 8);
    548         let mut ids = std::collections::BTreeSet::new();
    549 
    550         for vector in vectors {
    551             let id = vector["id"].as_str().expect("workflow vector id");
    552             assert!(ids.insert(id), "duplicate workflow vector {id}");
    553             let mutation_fixture = vector["input"]["mutation"]
    554                 .as_str()
    555                 .expect("workflow mutation fixture");
    556             let result = match mutation_fixture {
    557                 "proposal_with_private_terms" => {
    558                     WorkflowPlan::prepare(all_operation_mutations().remove(0))
    559                 }
    560                 "decision" => WorkflowPlan::prepare(all_operation_mutations().remove(1)),
    561                 "revision_proposal_with_private_terms" => {
    562                     WorkflowPlan::prepare(all_operation_mutations().remove(2))
    563                 }
    564                 "revision_decision" => WorkflowPlan::prepare(all_operation_mutations().remove(3)),
    565                 "cancellation" => WorkflowPlan::prepare(all_operation_mutations().remove(4)),
    566                 "proposal_with_unsupported_schema" => {
    567                     let mut mutation = all_operation_mutations().remove(0);
    568                     mutation.schema_version += 1;
    569                     WorkflowPlan::prepare(mutation)
    570                 }
    571                 "proposal_without_mutation_id" => {
    572                     let mut mutation = all_operation_mutations().remove(0);
    573                     mutation.mutation_id = None;
    574                     WorkflowPlan::prepare(mutation)
    575                 }
    576                 "decision_without_parent" => {
    577                     let mut mutation = all_operation_mutations().remove(1);
    578                     mutation.parent_mutation_ids.clear();
    579                     WorkflowPlan::prepare(mutation)
    580                 }
    581                 _ => panic!("{id}: unsupported mutation fixture {mutation_fixture}"),
    582             };
    583 
    584             let actual = match vector["kind"].as_str().expect("workflow vector kind") {
    585                 "trade.prepare_workflow.valid" => {
    586                     let plan = result.unwrap_or_else(|error| panic!("{id}: {error}"));
    587                     serde_json::json!({
    588                         "mutation_kind": mutation_kind_label(plan.kind()),
    589                         "required_actions": plan
    590                             .required_actions()
    591                             .iter()
    592                             .copied()
    593                             .map(workflow_action_label)
    594                             .collect::<Vec<_>>(),
    595                         "private_terms_artifact_id": plan
    596                             .private_terms()
    597                             .map(PrivateTermsPlan::artifact_id),
    598                     })
    599                 }
    600                 "trade.prepare_workflow.invalid" => {
    601                     let error = result.expect_err("invalid workflow vector must fail");
    602                     serde_json::json!({ "error_kind": error_kind_label(error.kind()) })
    603                 }
    604                 kind => panic!("{id}: unsupported workflow vector kind {kind}"),
    605             };
    606             assert_eq!(actual, vector["expected"], "{id}");
    607         }
    608     }
    609 
    610     #[cfg(feature = "json")]
    611     const fn mutation_kind_label(kind: TradeMutationKindV1) -> &'static str {
    612         match kind {
    613             TradeMutationKindV1::Proposal => "proposal",
    614             TradeMutationKindV1::Decision => "decision",
    615             TradeMutationKindV1::RevisionProposal => "revision_proposal",
    616             TradeMutationKindV1::RevisionDecision => "revision_decision",
    617             TradeMutationKindV1::Cancellation => "cancellation",
    618         }
    619     }
    620 
    621     #[cfg(feature = "json")]
    622     const fn workflow_action_label(action: WorkflowAction) -> &'static str {
    623         match action {
    624             WorkflowAction::Sign => "sign",
    625             WorkflowAction::Persist => "persist",
    626             WorkflowAction::Deliver => "deliver",
    627             WorkflowAction::VerifyPrivateTerms => "verify_private_terms",
    628         }
    629     }
    630 
    631     #[cfg(feature = "json")]
    632     const fn error_kind_label(kind: ErrorKind) -> &'static str {
    633         match kind {
    634             ErrorKind::UnsupportedSchema => "unsupported_schema",
    635             ErrorKind::MissingMutationId => "missing_mutation_id",
    636             ErrorKind::InvalidMutation => "invalid_mutation",
    637             ErrorKind::InvalidPrivateTerms => "invalid_private_terms",
    638         }
    639     }
    640 }