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 }