v1.rs (48003B)
1 //! Runtime operation descriptor contract generation 1. 2 //! 3 //! This module owns passive, serialized operation identities and policy 4 //! descriptors. Native SDK operation implementations and host execution state 5 //! deliberately remain outside the wire-contract package. 6 7 use alloc::{ 8 collections::BTreeSet, 9 format, 10 string::{String, ToString}, 11 vec::Vec, 12 }; 13 use core::fmt; 14 15 use crate::{ 16 capability::v1::{Maturity, TransportKind}, 17 schema::{Descriptor as SchemaDescriptor, ModuleVersion, Registry}, 18 }; 19 20 /// Schema generation shared by every runtime operation request and receipt. 21 pub const OPERATION_SCHEMA_VERSION: u16 = 1; 22 23 /// Typed readiness of one optional synchronization capability. 24 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 25 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] 26 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 27 pub enum SyncCapabilityState { 28 Unsupported, 29 Compiled, 30 Configured, 31 Available, 32 Degraded, 33 } 34 35 /// Aggregate synchronization health for the passive status operation. 36 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 37 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] 38 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 39 pub enum SyncHealth { 40 Healthy, 41 Degraded, 42 Unavailable, 43 } 44 45 /// Host-action classification for one durable outbox record. 46 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 47 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] 48 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 49 pub enum SyncRetryDecision { 50 Ready, 51 DeferredUntil { unix_ms: u64 }, 52 InFlightUntil { unix_ms: u64 }, 53 Satisfied, 54 Exhausted, 55 Expired, 56 } 57 58 /// Passive durable outbox cardinalities for `sync.status` generation 1. 59 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 60 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] 61 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 62 pub struct SyncOutboxStatus { 63 pub pending: u64, 64 pub leased: u64, 65 pub retryable: u64, 66 pub satisfied: u64, 67 pub exhausted: u64, 68 } 69 70 /// Passive projection cardinalities for `sync.status` generation 1. 71 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 72 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] 73 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 74 pub struct SyncProjectionStatus { 75 pub ready: u32, 76 pub invalidated: u32, 77 pub rebuilding: u32, 78 pub failed: u32, 79 pub untracked: u32, 80 } 81 82 /// Versioned passive receipt for the `sync.status` operation. 83 #[cfg_attr(feature = "serde", derive(serde::Serialize))] 84 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] 85 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 86 pub struct SyncStatusReceipt { 87 pub schema_version: u16, 88 pub health: SyncHealth, 89 pub storage: SyncCapabilityState, 90 pub source: SyncCapabilityState, 91 pub sink: SyncCapabilityState, 92 pub signer: SyncCapabilityState, 93 pub outbox: SyncOutboxStatus, 94 pub projections: SyncProjectionStatus, 95 } 96 97 impl SyncStatusReceipt { 98 /// Rejects status receipts from an unsupported operation generation. 99 pub const fn validate(&self) -> Result<(), Error> { 100 if self.schema_version != OPERATION_SCHEMA_VERSION { 101 return Err(Error::UnsupportedOperationSchemaVersion { 102 operation_id: OperationId::SyncStatus, 103 version: self.schema_version, 104 }); 105 } 106 Ok(()) 107 } 108 } 109 110 #[cfg(feature = "serde")] 111 impl<'de> serde::Deserialize<'de> for SyncStatusReceipt { 112 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 113 where 114 D: serde::Deserializer<'de>, 115 { 116 #[derive(serde::Deserialize)] 117 #[serde(deny_unknown_fields)] 118 struct Wire { 119 schema_version: u16, 120 health: SyncHealth, 121 storage: SyncCapabilityState, 122 source: SyncCapabilityState, 123 sink: SyncCapabilityState, 124 signer: SyncCapabilityState, 125 outbox: SyncOutboxStatus, 126 projections: SyncProjectionStatus, 127 } 128 let wire = Wire::deserialize(deserializer)?; 129 let receipt = Self { 130 schema_version: wire.schema_version, 131 health: wire.health, 132 storage: wire.storage, 133 source: wire.source, 134 sink: wire.sink, 135 signer: wire.signer, 136 outbox: wire.outbox, 137 projections: wire.projections, 138 }; 139 receipt.validate().map_err(serde::de::Error::custom)?; 140 Ok(receipt) 141 } 142 } 143 144 macro_rules! operation_ids { 145 ($( $variant:ident => $value:literal ),+ $(,)?) => { 146 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 147 pub enum OperationId { 148 $( $variant, )+ 149 } 150 151 impl OperationId { 152 pub fn as_str(self) -> &'static str { 153 match self { 154 $( Self::$variant => $value, )+ 155 } 156 } 157 158 pub fn parse(value: &str) -> Result<Self, Error> { 159 match value { 160 $( $value => Ok(Self::$variant), )+ 161 _ => Err(Error::UnknownOperationId { 162 operation_id: value.to_string(), 163 }), 164 } 165 } 166 167 pub fn request_schema_id(self) -> &'static str { 168 match self { 169 $( Self::$variant => concat!("radroots.runtime.", $value, ".request.v1"), )+ 170 } 171 } 172 173 pub fn receipt_schema_id(self) -> &'static str { 174 match self { 175 $( Self::$variant => concat!("radroots.runtime.", $value, ".receipt.v1"), )+ 176 } 177 } 178 } 179 180 #[cfg(feature = "serde")] 181 impl serde::Serialize for OperationId { 182 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 183 where 184 S: serde::Serializer, 185 { 186 serializer.serialize_str(self.as_str()) 187 } 188 } 189 190 #[cfg(feature = "serde")] 191 impl<'de> serde::Deserialize<'de> for OperationId { 192 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 193 where 194 D: serde::Deserializer<'de>, 195 { 196 let value = <String as serde::Deserialize>::deserialize(deserializer)?; 197 Self::parse(value.as_str()).map_err(serde::de::Error::custom) 198 } 199 } 200 }; 201 } 202 203 operation_ids! { 204 ProfileInspect => "profile.inspect", 205 ProfileReset => "profile.reset", 206 AccountCreate => "account.create", 207 AccountImport => "account.import", 208 AccountSelect => "account.select", 209 AccountList => "account.list", 210 AccountRemove => "account.remove", 211 SignerStatus => "signer.status", 212 StoreInspect => "store.inspect", 213 StoreBackup => "store.backup", 214 StoreRestore => "store.restore", 215 FarmCreate => "farm.create", 216 FarmUpdate => "farm.update", 217 FarmPublish => "farm.publish", 218 FarmGet => "farm.get", 219 FarmList => "farm.list", 220 ListingCreate => "listing.create", 221 ListingUpdate => "listing.update", 222 ListingPublish => "listing.publish", 223 ListingPause => "listing.pause", 224 ListingWithdraw => "listing.withdraw", 225 ListingGet => "listing.get", 226 ListingList => "listing.list", 227 MarketPull => "market.pull", 228 MarketSearch => "market.search", 229 MarketGet => "market.get", 230 BasketCreate => "basket.create", 231 BasketGet => "basket.get", 232 BasketList => "basket.list", 233 BasketItemAdd => "basket.item.add", 234 BasketItemUpdate => "basket.item.update", 235 BasketItemRemove => "basket.item.remove", 236 BasketQuote => "basket.quote", 237 TradeProposalSubmit => "trade.proposal.submit", 238 TradeRevisionPropose => "trade.revision.propose", 239 TradeCandidateDecide => "trade.candidate.decide", 240 TradeCancellationSubmit => "trade.cancellation.submit", 241 TradeOperationResume => "trade.operation.resume", 242 TradeGet => "trade.get", 243 TradeList => "trade.list", 244 TradeEvidenceRefresh => "trade.evidence.refresh", 245 TradeEvidenceInspect => "trade.evidence.inspect", 246 TradePrivateArtifactSeal => "trade.private_artifact.seal", 247 TradePrivateArtifactOpen => "trade.private_artifact.open", 248 TradePrivateArtifactDelete => "trade.private_artifact.delete", 249 ValidationStatus => "validation.status", 250 SyncStatus => "sync.status", 251 SyncPull => "sync.pull", 252 SyncPush => "sync.push", 253 HealthInspect => "health.inspect", 254 TransportCapabilityList => "transport.capability.list", 255 TransportConfigInspect => "transport.config.inspect", 256 TransportConfigUpdate => "transport.config.update", 257 TransportStatusInspect => "transport.status.inspect", 258 TransportDeliveryInspect => "transport.delivery.inspect", 259 TransportDeliveryRetry => "transport.delivery.retry", 260 DiagnosticsInspect => "diagnostics.inspect", 261 } 262 263 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 264 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] 265 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 266 pub enum Mutability { 267 Read, 268 Mutation, 269 } 270 271 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 272 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] 273 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 274 pub enum Risk { 275 Low, 276 Medium, 277 High, 278 Critical, 279 } 280 281 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 282 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] 283 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 284 pub enum ApprovalRequirement { 285 None, 286 ConditionalOrRequiredByMode, 287 Required, 288 } 289 290 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 291 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] 292 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 293 pub enum SignerRequirement { 294 None, 295 Required, 296 ConditionalRelayAuth, 297 } 298 299 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 300 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] 301 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 302 pub enum IdempotencyPolicy { 303 Forbidden, 304 RequiredUuidV7, 305 } 306 307 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 308 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] 309 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 310 pub enum DryRunSupport { 311 NotApplicable, 312 PureLocalPlan, 313 } 314 315 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 316 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] 317 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 318 pub enum DeadlinePolicy { 319 DefaultBounded, 320 OperationDeclared, 321 } 322 323 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 324 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] 325 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 326 pub enum PrivacyEffect { 327 None, 328 PublicEvent, 329 PrivateCoordination, 330 PrivateStore, 331 BackupRestore, 332 } 333 334 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 335 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] 336 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 337 pub enum ProjectionEffect { 338 None, 339 ReadsProjection, 340 WritesProjection, 341 MayUpdateProjection, 342 } 343 344 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 345 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] 346 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 347 pub struct TransportRoute { 348 pub local: bool, 349 pub nostr: bool, 350 pub reticulum: bool, 351 pub deliver: bool, 352 pub fetch: bool, 353 pub synchronize: bool, 354 pub diagnostics: bool, 355 } 356 357 impl TransportRoute { 358 pub const fn none() -> Self { 359 Self { 360 local: false, 361 nostr: false, 362 reticulum: false, 363 deliver: false, 364 fetch: false, 365 synchronize: false, 366 diagnostics: false, 367 } 368 } 369 370 pub const fn local() -> Self { 371 Self { 372 local: true, 373 nostr: false, 374 reticulum: false, 375 deliver: false, 376 fetch: false, 377 synchronize: false, 378 diagnostics: false, 379 } 380 } 381 382 pub const fn delivery() -> Self { 383 Self { 384 local: false, 385 nostr: true, 386 reticulum: true, 387 deliver: true, 388 fetch: false, 389 synchronize: false, 390 diagnostics: false, 391 } 392 } 393 394 pub const fn fetch() -> Self { 395 Self { 396 local: false, 397 nostr: true, 398 reticulum: true, 399 deliver: false, 400 fetch: true, 401 synchronize: true, 402 diagnostics: false, 403 } 404 } 405 406 pub const fn diagnostics() -> Self { 407 Self { 408 local: true, 409 nostr: true, 410 reticulum: true, 411 deliver: false, 412 fetch: false, 413 synchronize: false, 414 diagnostics: true, 415 } 416 } 417 418 pub fn includes_transport(self, kind: TransportKind) -> bool { 419 match kind { 420 TransportKind::LOCAL => self.local, 421 TransportKind::NOSTR => self.nostr, 422 TransportKind::RETICULUM => self.reticulum, 423 _ => false, 424 } 425 } 426 } 427 428 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 429 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] 430 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 431 pub struct OperationDescriptor { 432 pub operation_id: OperationId, 433 pub schema_version: u16, 434 pub mutability: Mutability, 435 pub risk: Risk, 436 pub approval: ApprovalRequirement, 437 pub signer: SignerRequirement, 438 pub transport_capability: TransportRoute, 439 pub idempotency: IdempotencyPolicy, 440 pub dry_run: DryRunSupport, 441 pub deadline: DeadlinePolicy, 442 pub privacy: PrivacyEffect, 443 pub projection: ProjectionEffect, 444 pub maturity: Maturity, 445 } 446 447 impl OperationDescriptor { 448 pub fn request_schema_id(self) -> &'static str { 449 self.operation_id.request_schema_id() 450 } 451 452 pub fn receipt_schema_id(self) -> &'static str { 453 self.operation_id.receipt_schema_id() 454 } 455 } 456 457 struct DescriptorSpec { 458 operation_id: OperationId, 459 mutability: Mutability, 460 risk: Risk, 461 approval: ApprovalRequirement, 462 signer: SignerRequirement, 463 transport_capability: TransportRoute, 464 idempotency: IdempotencyPolicy, 465 dry_run: DryRunSupport, 466 privacy: PrivacyEffect, 467 projection: ProjectionEffect, 468 } 469 470 const fn read( 471 operation_id: OperationId, 472 risk: Risk, 473 transport_capability: TransportRoute, 474 privacy: PrivacyEffect, 475 projection: ProjectionEffect, 476 ) -> OperationDescriptor { 477 descriptor(DescriptorSpec { 478 operation_id, 479 mutability: Mutability::Read, 480 risk, 481 approval: ApprovalRequirement::None, 482 signer: SignerRequirement::None, 483 transport_capability, 484 idempotency: IdempotencyPolicy::Forbidden, 485 dry_run: DryRunSupport::NotApplicable, 486 privacy, 487 projection, 488 }) 489 } 490 491 const fn mutation( 492 operation_id: OperationId, 493 risk: Risk, 494 approval: ApprovalRequirement, 495 signer: SignerRequirement, 496 transport_capability: TransportRoute, 497 privacy: PrivacyEffect, 498 projection: ProjectionEffect, 499 ) -> OperationDescriptor { 500 descriptor(DescriptorSpec { 501 operation_id, 502 mutability: Mutability::Mutation, 503 risk, 504 approval, 505 signer, 506 transport_capability, 507 idempotency: IdempotencyPolicy::RequiredUuidV7, 508 dry_run: DryRunSupport::PureLocalPlan, 509 privacy, 510 projection, 511 }) 512 } 513 514 const fn descriptor(spec: DescriptorSpec) -> OperationDescriptor { 515 OperationDescriptor { 516 operation_id: spec.operation_id, 517 schema_version: OPERATION_SCHEMA_VERSION, 518 mutability: spec.mutability, 519 risk: spec.risk, 520 approval: spec.approval, 521 signer: spec.signer, 522 transport_capability: spec.transport_capability, 523 idempotency: spec.idempotency, 524 dry_run: spec.dry_run, 525 deadline: DeadlinePolicy::DefaultBounded, 526 privacy: spec.privacy, 527 projection: spec.projection, 528 maturity: Maturity::Stable, 529 } 530 } 531 532 pub const CATALOG: &[OperationDescriptor] = &[ 533 read( 534 OperationId::ProfileInspect, 535 Risk::Low, 536 TransportRoute::local(), 537 PrivacyEffect::PrivateStore, 538 ProjectionEffect::ReadsProjection, 539 ), 540 mutation( 541 OperationId::ProfileReset, 542 Risk::Critical, 543 ApprovalRequirement::Required, 544 SignerRequirement::None, 545 TransportRoute::local(), 546 PrivacyEffect::PrivateStore, 547 ProjectionEffect::WritesProjection, 548 ), 549 mutation( 550 OperationId::AccountCreate, 551 Risk::High, 552 ApprovalRequirement::ConditionalOrRequiredByMode, 553 SignerRequirement::None, 554 TransportRoute::local(), 555 PrivacyEffect::PrivateStore, 556 ProjectionEffect::WritesProjection, 557 ), 558 mutation( 559 OperationId::AccountImport, 560 Risk::High, 561 ApprovalRequirement::ConditionalOrRequiredByMode, 562 SignerRequirement::None, 563 TransportRoute::local(), 564 PrivacyEffect::PrivateStore, 565 ProjectionEffect::WritesProjection, 566 ), 567 mutation( 568 OperationId::AccountSelect, 569 Risk::Medium, 570 ApprovalRequirement::None, 571 SignerRequirement::None, 572 TransportRoute::local(), 573 PrivacyEffect::PrivateStore, 574 ProjectionEffect::WritesProjection, 575 ), 576 read( 577 OperationId::AccountList, 578 Risk::Low, 579 TransportRoute::local(), 580 PrivacyEffect::PrivateStore, 581 ProjectionEffect::ReadsProjection, 582 ), 583 mutation( 584 OperationId::AccountRemove, 585 Risk::Critical, 586 ApprovalRequirement::Required, 587 SignerRequirement::None, 588 TransportRoute::local(), 589 PrivacyEffect::PrivateStore, 590 ProjectionEffect::WritesProjection, 591 ), 592 read( 593 OperationId::SignerStatus, 594 Risk::Low, 595 TransportRoute::local(), 596 PrivacyEffect::None, 597 ProjectionEffect::None, 598 ), 599 read( 600 OperationId::StoreInspect, 601 Risk::Low, 602 TransportRoute::local(), 603 PrivacyEffect::PrivateStore, 604 ProjectionEffect::ReadsProjection, 605 ), 606 mutation( 607 OperationId::StoreBackup, 608 Risk::High, 609 ApprovalRequirement::ConditionalOrRequiredByMode, 610 SignerRequirement::None, 611 TransportRoute::local(), 612 PrivacyEffect::BackupRestore, 613 ProjectionEffect::ReadsProjection, 614 ), 615 mutation( 616 OperationId::StoreRestore, 617 Risk::Critical, 618 ApprovalRequirement::Required, 619 SignerRequirement::None, 620 TransportRoute::local(), 621 PrivacyEffect::BackupRestore, 622 ProjectionEffect::WritesProjection, 623 ), 624 mutation( 625 OperationId::FarmCreate, 626 Risk::Medium, 627 ApprovalRequirement::None, 628 SignerRequirement::None, 629 TransportRoute::local(), 630 PrivacyEffect::PrivateStore, 631 ProjectionEffect::WritesProjection, 632 ), 633 mutation( 634 OperationId::FarmUpdate, 635 Risk::Medium, 636 ApprovalRequirement::None, 637 SignerRequirement::None, 638 TransportRoute::local(), 639 PrivacyEffect::PrivateStore, 640 ProjectionEffect::WritesProjection, 641 ), 642 mutation( 643 OperationId::FarmPublish, 644 Risk::Medium, 645 ApprovalRequirement::None, 646 SignerRequirement::Required, 647 TransportRoute::delivery(), 648 PrivacyEffect::PublicEvent, 649 ProjectionEffect::MayUpdateProjection, 650 ), 651 read( 652 OperationId::FarmGet, 653 Risk::Low, 654 TransportRoute::local(), 655 PrivacyEffect::None, 656 ProjectionEffect::ReadsProjection, 657 ), 658 read( 659 OperationId::FarmList, 660 Risk::Low, 661 TransportRoute::local(), 662 PrivacyEffect::None, 663 ProjectionEffect::ReadsProjection, 664 ), 665 mutation( 666 OperationId::ListingCreate, 667 Risk::Medium, 668 ApprovalRequirement::None, 669 SignerRequirement::None, 670 TransportRoute::local(), 671 PrivacyEffect::PrivateStore, 672 ProjectionEffect::WritesProjection, 673 ), 674 mutation( 675 OperationId::ListingUpdate, 676 Risk::Medium, 677 ApprovalRequirement::None, 678 SignerRequirement::None, 679 TransportRoute::local(), 680 PrivacyEffect::PrivateStore, 681 ProjectionEffect::WritesProjection, 682 ), 683 mutation( 684 OperationId::ListingPublish, 685 Risk::Medium, 686 ApprovalRequirement::None, 687 SignerRequirement::Required, 688 TransportRoute::delivery(), 689 PrivacyEffect::PublicEvent, 690 ProjectionEffect::MayUpdateProjection, 691 ), 692 mutation( 693 OperationId::ListingPause, 694 Risk::Medium, 695 ApprovalRequirement::None, 696 SignerRequirement::Required, 697 TransportRoute::delivery(), 698 PrivacyEffect::PublicEvent, 699 ProjectionEffect::MayUpdateProjection, 700 ), 701 mutation( 702 OperationId::ListingWithdraw, 703 Risk::High, 704 ApprovalRequirement::ConditionalOrRequiredByMode, 705 SignerRequirement::Required, 706 TransportRoute::delivery(), 707 PrivacyEffect::PublicEvent, 708 ProjectionEffect::MayUpdateProjection, 709 ), 710 read( 711 OperationId::ListingGet, 712 Risk::Low, 713 TransportRoute::local(), 714 PrivacyEffect::None, 715 ProjectionEffect::ReadsProjection, 716 ), 717 read( 718 OperationId::ListingList, 719 Risk::Low, 720 TransportRoute::local(), 721 PrivacyEffect::None, 722 ProjectionEffect::ReadsProjection, 723 ), 724 mutation( 725 OperationId::MarketPull, 726 Risk::Medium, 727 ApprovalRequirement::None, 728 SignerRequirement::ConditionalRelayAuth, 729 TransportRoute::fetch(), 730 PrivacyEffect::None, 731 ProjectionEffect::MayUpdateProjection, 732 ), 733 read( 734 OperationId::MarketSearch, 735 Risk::Low, 736 TransportRoute::local(), 737 PrivacyEffect::None, 738 ProjectionEffect::ReadsProjection, 739 ), 740 read( 741 OperationId::MarketGet, 742 Risk::Low, 743 TransportRoute::local(), 744 PrivacyEffect::None, 745 ProjectionEffect::ReadsProjection, 746 ), 747 mutation( 748 OperationId::BasketCreate, 749 Risk::Medium, 750 ApprovalRequirement::None, 751 SignerRequirement::None, 752 TransportRoute::local(), 753 PrivacyEffect::PrivateStore, 754 ProjectionEffect::WritesProjection, 755 ), 756 read( 757 OperationId::BasketGet, 758 Risk::Low, 759 TransportRoute::local(), 760 PrivacyEffect::PrivateStore, 761 ProjectionEffect::ReadsProjection, 762 ), 763 read( 764 OperationId::BasketList, 765 Risk::Low, 766 TransportRoute::local(), 767 PrivacyEffect::PrivateStore, 768 ProjectionEffect::ReadsProjection, 769 ), 770 mutation( 771 OperationId::BasketItemAdd, 772 Risk::Medium, 773 ApprovalRequirement::None, 774 SignerRequirement::None, 775 TransportRoute::local(), 776 PrivacyEffect::PrivateStore, 777 ProjectionEffect::WritesProjection, 778 ), 779 mutation( 780 OperationId::BasketItemUpdate, 781 Risk::Medium, 782 ApprovalRequirement::None, 783 SignerRequirement::None, 784 TransportRoute::local(), 785 PrivacyEffect::PrivateStore, 786 ProjectionEffect::WritesProjection, 787 ), 788 mutation( 789 OperationId::BasketItemRemove, 790 Risk::Medium, 791 ApprovalRequirement::None, 792 SignerRequirement::None, 793 TransportRoute::local(), 794 PrivacyEffect::PrivateStore, 795 ProjectionEffect::WritesProjection, 796 ), 797 mutation( 798 OperationId::BasketQuote, 799 Risk::Medium, 800 ApprovalRequirement::None, 801 SignerRequirement::None, 802 TransportRoute::local(), 803 PrivacyEffect::PrivateStore, 804 ProjectionEffect::WritesProjection, 805 ), 806 mutation( 807 OperationId::TradeProposalSubmit, 808 Risk::High, 809 ApprovalRequirement::ConditionalOrRequiredByMode, 810 SignerRequirement::Required, 811 TransportRoute::delivery(), 812 PrivacyEffect::PrivateCoordination, 813 ProjectionEffect::MayUpdateProjection, 814 ), 815 mutation( 816 OperationId::TradeRevisionPropose, 817 Risk::High, 818 ApprovalRequirement::ConditionalOrRequiredByMode, 819 SignerRequirement::Required, 820 TransportRoute::delivery(), 821 PrivacyEffect::PrivateCoordination, 822 ProjectionEffect::MayUpdateProjection, 823 ), 824 mutation( 825 OperationId::TradeCandidateDecide, 826 Risk::High, 827 ApprovalRequirement::ConditionalOrRequiredByMode, 828 SignerRequirement::Required, 829 TransportRoute::delivery(), 830 PrivacyEffect::PrivateCoordination, 831 ProjectionEffect::MayUpdateProjection, 832 ), 833 mutation( 834 OperationId::TradeCancellationSubmit, 835 Risk::High, 836 ApprovalRequirement::ConditionalOrRequiredByMode, 837 SignerRequirement::Required, 838 TransportRoute::delivery(), 839 PrivacyEffect::PrivateCoordination, 840 ProjectionEffect::MayUpdateProjection, 841 ), 842 mutation( 843 OperationId::TradeOperationResume, 844 Risk::High, 845 ApprovalRequirement::ConditionalOrRequiredByMode, 846 SignerRequirement::Required, 847 TransportRoute::delivery(), 848 PrivacyEffect::PrivateCoordination, 849 ProjectionEffect::MayUpdateProjection, 850 ), 851 read( 852 OperationId::TradeGet, 853 Risk::Low, 854 TransportRoute::local(), 855 PrivacyEffect::PrivateCoordination, 856 ProjectionEffect::ReadsProjection, 857 ), 858 read( 859 OperationId::TradeList, 860 Risk::Low, 861 TransportRoute::local(), 862 PrivacyEffect::PrivateCoordination, 863 ProjectionEffect::ReadsProjection, 864 ), 865 mutation( 866 OperationId::TradeEvidenceRefresh, 867 Risk::Medium, 868 ApprovalRequirement::None, 869 SignerRequirement::None, 870 TransportRoute::local(), 871 PrivacyEffect::PrivateCoordination, 872 ProjectionEffect::WritesProjection, 873 ), 874 read( 875 OperationId::TradeEvidenceInspect, 876 Risk::Low, 877 TransportRoute::local(), 878 PrivacyEffect::PrivateCoordination, 879 ProjectionEffect::ReadsProjection, 880 ), 881 mutation( 882 OperationId::TradePrivateArtifactSeal, 883 Risk::High, 884 ApprovalRequirement::ConditionalOrRequiredByMode, 885 SignerRequirement::None, 886 TransportRoute::local(), 887 PrivacyEffect::PrivateCoordination, 888 ProjectionEffect::WritesProjection, 889 ), 890 read( 891 OperationId::TradePrivateArtifactOpen, 892 Risk::High, 893 TransportRoute::local(), 894 PrivacyEffect::PrivateCoordination, 895 ProjectionEffect::ReadsProjection, 896 ), 897 mutation( 898 OperationId::TradePrivateArtifactDelete, 899 Risk::Critical, 900 ApprovalRequirement::Required, 901 SignerRequirement::None, 902 TransportRoute::local(), 903 PrivacyEffect::PrivateCoordination, 904 ProjectionEffect::WritesProjection, 905 ), 906 read( 907 OperationId::ValidationStatus, 908 Risk::Low, 909 TransportRoute::local(), 910 PrivacyEffect::None, 911 ProjectionEffect::ReadsProjection, 912 ), 913 read( 914 OperationId::SyncStatus, 915 Risk::Low, 916 TransportRoute::diagnostics(), 917 PrivacyEffect::None, 918 ProjectionEffect::ReadsProjection, 919 ), 920 mutation( 921 OperationId::SyncPull, 922 Risk::Medium, 923 ApprovalRequirement::None, 924 SignerRequirement::ConditionalRelayAuth, 925 TransportRoute::fetch(), 926 PrivacyEffect::None, 927 ProjectionEffect::MayUpdateProjection, 928 ), 929 mutation( 930 OperationId::SyncPush, 931 Risk::Medium, 932 ApprovalRequirement::None, 933 SignerRequirement::ConditionalRelayAuth, 934 TransportRoute::delivery(), 935 PrivacyEffect::PublicEvent, 936 ProjectionEffect::MayUpdateProjection, 937 ), 938 read( 939 OperationId::HealthInspect, 940 Risk::Low, 941 TransportRoute::diagnostics(), 942 PrivacyEffect::None, 943 ProjectionEffect::None, 944 ), 945 read( 946 OperationId::TransportCapabilityList, 947 Risk::Low, 948 TransportRoute::diagnostics(), 949 PrivacyEffect::None, 950 ProjectionEffect::None, 951 ), 952 read( 953 OperationId::TransportConfigInspect, 954 Risk::Low, 955 TransportRoute::local(), 956 PrivacyEffect::PrivateStore, 957 ProjectionEffect::ReadsProjection, 958 ), 959 mutation( 960 OperationId::TransportConfigUpdate, 961 Risk::High, 962 ApprovalRequirement::ConditionalOrRequiredByMode, 963 SignerRequirement::None, 964 TransportRoute::local(), 965 PrivacyEffect::PrivateStore, 966 ProjectionEffect::WritesProjection, 967 ), 968 read( 969 OperationId::TransportStatusInspect, 970 Risk::Low, 971 TransportRoute::diagnostics(), 972 PrivacyEffect::None, 973 ProjectionEffect::ReadsProjection, 974 ), 975 read( 976 OperationId::TransportDeliveryInspect, 977 Risk::Low, 978 TransportRoute::diagnostics(), 979 PrivacyEffect::None, 980 ProjectionEffect::ReadsProjection, 981 ), 982 mutation( 983 OperationId::TransportDeliveryRetry, 984 Risk::Medium, 985 ApprovalRequirement::None, 986 SignerRequirement::ConditionalRelayAuth, 987 TransportRoute::delivery(), 988 PrivacyEffect::PublicEvent, 989 ProjectionEffect::MayUpdateProjection, 990 ), 991 read( 992 OperationId::DiagnosticsInspect, 993 Risk::Low, 994 TransportRoute::diagnostics(), 995 PrivacyEffect::None, 996 ProjectionEffect::None, 997 ), 998 ]; 999 1000 /// Returns the descriptor for an exact operation identity. 1001 pub fn operation_descriptor(operation_id: OperationId) -> Result<OperationDescriptor, Error> { 1002 CATALOG 1003 .iter() 1004 .copied() 1005 .find(|descriptor| descriptor.operation_id == operation_id) 1006 .ok_or_else(|| Error::UnknownOperationId { 1007 operation_id: operation_id.as_str().to_string(), 1008 }) 1009 } 1010 1011 /// Validates uniqueness, schema generation, and descriptor policy invariants. 1012 pub fn validate_catalog(descriptors: &[OperationDescriptor]) -> Result<(), Error> { 1013 let mut operation_ids = BTreeSet::new(); 1014 for descriptor in descriptors { 1015 if !operation_ids.insert(descriptor.operation_id) { 1016 return Err(Error::DuplicateOperationId { 1017 operation_id: descriptor.operation_id, 1018 }); 1019 } 1020 match (descriptor.mutability, descriptor.idempotency) { 1021 (Mutability::Read, IdempotencyPolicy::Forbidden) 1022 | (Mutability::Mutation, IdempotencyPolicy::RequiredUuidV7) => {} 1023 _ => { 1024 return Err(Error::CatalogInvalid { 1025 message: format!( 1026 "operation {} has invalid idempotency policy", 1027 descriptor.operation_id.as_str() 1028 ), 1029 }); 1030 } 1031 } 1032 if descriptor.schema_version != OPERATION_SCHEMA_VERSION { 1033 return Err(Error::UnsupportedOperationSchemaVersion { 1034 operation_id: descriptor.operation_id, 1035 version: descriptor.schema_version, 1036 }); 1037 } 1038 } 1039 1040 for required in [ 1041 OperationId::TransportCapabilityList, 1042 OperationId::TransportConfigInspect, 1043 OperationId::TransportConfigUpdate, 1044 OperationId::TransportStatusInspect, 1045 OperationId::TransportDeliveryInspect, 1046 OperationId::TransportDeliveryRetry, 1047 OperationId::SyncStatus, 1048 OperationId::SyncPull, 1049 OperationId::SyncPush, 1050 OperationId::DiagnosticsInspect, 1051 ] { 1052 if !operation_ids.contains(&required) { 1053 return Err(Error::MissingRequiredOperation { 1054 operation_id: required, 1055 }); 1056 } 1057 } 1058 1059 for delivery in [ 1060 OperationId::FarmPublish, 1061 OperationId::ListingPublish, 1062 OperationId::ListingPause, 1063 OperationId::ListingWithdraw, 1064 OperationId::TradeProposalSubmit, 1065 OperationId::TradeRevisionPropose, 1066 OperationId::TradeCandidateDecide, 1067 OperationId::TradeCancellationSubmit, 1068 OperationId::TradeOperationResume, 1069 OperationId::SyncPush, 1070 OperationId::TransportDeliveryRetry, 1071 ] { 1072 let descriptor = descriptors 1073 .iter() 1074 .find(|descriptor| descriptor.operation_id == delivery) 1075 .ok_or(Error::MissingRequiredOperation { 1076 operation_id: delivery, 1077 })?; 1078 if !descriptor.transport_capability.deliver 1079 || !descriptor 1080 .transport_capability 1081 .includes_transport(TransportKind::NOSTR) 1082 || !descriptor 1083 .transport_capability 1084 .includes_transport(TransportKind::RETICULUM) 1085 { 1086 return Err(Error::CatalogInvalid { 1087 message: format!( 1088 "operation {} must use Nostr and Reticulum delivery capability", 1089 descriptor.operation_id.as_str() 1090 ), 1091 }); 1092 } 1093 } 1094 Ok(()) 1095 } 1096 1097 /// Builds the registry for every exact request and receipt schema identity. 1098 pub fn schema_registry() -> Result<Registry, crate::schema::Error> { 1099 let mut schemas = Vec::with_capacity(CATALOG.len() * 2); 1100 for descriptor in CATALOG { 1101 schemas.push(SchemaDescriptor::try_new( 1102 descriptor.request_schema_id(), 1103 ModuleVersion::RuntimeV1, 1104 )?); 1105 schemas.push(SchemaDescriptor::try_new( 1106 descriptor.receipt_schema_id(), 1107 ModuleVersion::RuntimeV1, 1108 )?); 1109 } 1110 Registry::try_new(schemas) 1111 } 1112 1113 /// Runtime operation catalog validation failure. 1114 #[derive(Clone, Debug, Eq, PartialEq)] 1115 #[non_exhaustive] 1116 pub enum Error { 1117 /// An operation identity appears more than once. 1118 DuplicateOperationId { 1119 /// Duplicated operation identity. 1120 operation_id: OperationId, 1121 }, 1122 /// A required operation is missing from a candidate catalog. 1123 MissingRequiredOperation { 1124 /// Missing operation identity. 1125 operation_id: OperationId, 1126 }, 1127 /// An operation identity is unknown. 1128 UnknownOperationId { 1129 /// Rejected identity. 1130 operation_id: String, 1131 }, 1132 /// An operation declares a non-V1 schema generation. 1133 UnsupportedOperationSchemaVersion { 1134 /// Operation with the incompatible version. 1135 operation_id: OperationId, 1136 /// Rejected version. 1137 version: u16, 1138 }, 1139 /// A descriptor invariant is violated. 1140 CatalogInvalid { 1141 /// Secret-safe validation diagnostic. 1142 message: String, 1143 }, 1144 } 1145 1146 impl fmt::Display for Error { 1147 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 1148 match self { 1149 Self::DuplicateOperationId { operation_id } => { 1150 write!( 1151 formatter, 1152 "duplicate operation id {}", 1153 operation_id.as_str() 1154 ) 1155 } 1156 Self::MissingRequiredOperation { operation_id } => { 1157 write!( 1158 formatter, 1159 "missing required operation {}", 1160 operation_id.as_str() 1161 ) 1162 } 1163 Self::UnknownOperationId { operation_id } => { 1164 write!(formatter, "unknown operation id {operation_id}") 1165 } 1166 Self::UnsupportedOperationSchemaVersion { 1167 operation_id, 1168 version, 1169 } => write!( 1170 formatter, 1171 "unsupported operation schema version {version} for {}", 1172 operation_id.as_str() 1173 ), 1174 Self::CatalogInvalid { message } => formatter.write_str(message), 1175 } 1176 } 1177 } 1178 1179 #[cfg(feature = "std")] 1180 impl std::error::Error for Error {} 1181 1182 #[cfg(test)] 1183 mod tests { 1184 use super::*; 1185 1186 #[test] 1187 fn catalog_is_unique_and_preserves_every_v1_operation() { 1188 validate_catalog(CATALOG).expect("runtime operation catalog"); 1189 assert_eq!(CATALOG.len(), 57); 1190 1191 let identities = CATALOG 1192 .iter() 1193 .map(|descriptor| descriptor.operation_id.as_str()) 1194 .collect::<BTreeSet<_>>(); 1195 assert_eq!(identities.len(), CATALOG.len()); 1196 for descriptor in CATALOG { 1197 assert_eq!( 1198 OperationId::parse(descriptor.operation_id.as_str()), 1199 Ok(descriptor.operation_id) 1200 ); 1201 assert!( 1202 descriptor 1203 .request_schema_id() 1204 .starts_with("radroots.runtime.") 1205 ); 1206 assert!(descriptor.request_schema_id().ends_with(".request.v1")); 1207 assert!( 1208 descriptor 1209 .receipt_schema_id() 1210 .starts_with("radroots.runtime.") 1211 ); 1212 assert!(descriptor.receipt_schema_id().ends_with(".receipt.v1")); 1213 assert_eq!( 1214 operation_descriptor(descriptor.operation_id), 1215 Ok(*descriptor) 1216 ); 1217 } 1218 } 1219 1220 #[test] 1221 fn parser_is_exact_and_rejects_retired_preview_names() { 1222 assert_eq!( 1223 OperationId::parse("trade.proposal.submit"), 1224 Ok(OperationId::TradeProposalSubmit) 1225 ); 1226 assert_eq!( 1227 OperationId::parse("runtime.unknown") 1228 .expect_err("unknown") 1229 .to_string(), 1230 "unknown operation id runtime.unknown" 1231 ); 1232 for value in [ 1233 ["sync.try_reticulum", "_preview_now"].concat(), 1234 ["transport.reticulum", "_preview.status"].concat(), 1235 ["transport.", "hybrid", ".publish"].concat(), 1236 ["radrootsd.", "proxy", ".publish"].concat(), 1237 ] { 1238 assert!(OperationId::parse(value.as_str()).is_err()); 1239 } 1240 } 1241 1242 #[test] 1243 fn schema_ids_and_registry_preserve_v1_vectors() { 1244 let profile = operation_descriptor(OperationId::ProfileInspect).expect("profile"); 1245 assert_eq!( 1246 profile.request_schema_id(), 1247 "radroots.runtime.profile.inspect.request.v1" 1248 ); 1249 assert_eq!( 1250 profile.receipt_schema_id(), 1251 "radroots.runtime.profile.inspect.receipt.v1" 1252 ); 1253 1254 let registry = schema_registry().expect("runtime schema registry"); 1255 assert_eq!(registry.len(), CATALOG.len() * 2); 1256 assert!( 1257 registry 1258 .descriptors() 1259 .iter() 1260 .all(|descriptor| descriptor.module() == ModuleVersion::RuntimeV1) 1261 ); 1262 } 1263 1264 #[test] 1265 fn validation_rejects_duplicates_and_policy_drift() { 1266 assert_eq!( 1267 validate_catalog(&[CATALOG[0], CATALOG[0]]), 1268 Err(Error::DuplicateOperationId { 1269 operation_id: OperationId::ProfileInspect, 1270 }) 1271 ); 1272 1273 let mut invalid = CATALOG.to_vec(); 1274 invalid[0].idempotency = IdempotencyPolicy::RequiredUuidV7; 1275 assert_eq!( 1276 validate_catalog(invalid.as_slice()), 1277 Err(Error::CatalogInvalid { 1278 message: "operation profile.inspect has invalid idempotency policy".into(), 1279 }) 1280 ); 1281 1282 let mut invalid = CATALOG.to_vec(); 1283 invalid[1].idempotency = IdempotencyPolicy::Forbidden; 1284 assert!(matches!( 1285 validate_catalog(&invalid), 1286 Err(Error::CatalogInvalid { .. }) 1287 )); 1288 1289 let mut invalid = CATALOG.to_vec(); 1290 invalid[0].schema_version = 2; 1291 assert_eq!( 1292 validate_catalog(&invalid), 1293 Err(Error::UnsupportedOperationSchemaVersion { 1294 operation_id: OperationId::ProfileInspect, 1295 version: 2, 1296 }) 1297 ); 1298 1299 for required in [ 1300 OperationId::TransportCapabilityList, 1301 OperationId::TransportConfigInspect, 1302 OperationId::TransportConfigUpdate, 1303 OperationId::TransportStatusInspect, 1304 OperationId::TransportDeliveryInspect, 1305 OperationId::TransportDeliveryRetry, 1306 OperationId::SyncStatus, 1307 OperationId::SyncPull, 1308 OperationId::SyncPush, 1309 OperationId::DiagnosticsInspect, 1310 ] { 1311 let missing = CATALOG 1312 .iter() 1313 .copied() 1314 .filter(|descriptor| descriptor.operation_id != required) 1315 .collect::<Vec<_>>(); 1316 assert_eq!( 1317 validate_catalog(&missing), 1318 Err(Error::MissingRequiredOperation { 1319 operation_id: required, 1320 }) 1321 ); 1322 } 1323 1324 for delivery in [ 1325 OperationId::FarmPublish, 1326 OperationId::ListingPublish, 1327 OperationId::ListingPause, 1328 OperationId::ListingWithdraw, 1329 OperationId::TradeProposalSubmit, 1330 OperationId::TradeRevisionPropose, 1331 OperationId::TradeCandidateDecide, 1332 OperationId::TradeCancellationSubmit, 1333 OperationId::TradeOperationResume, 1334 ] { 1335 let missing = CATALOG 1336 .iter() 1337 .copied() 1338 .filter(|descriptor| descriptor.operation_id != delivery) 1339 .collect::<Vec<_>>(); 1340 assert_eq!( 1341 validate_catalog(&missing), 1342 Err(Error::MissingRequiredOperation { 1343 operation_id: delivery, 1344 }) 1345 ); 1346 } 1347 1348 let mut invalid = CATALOG.to_vec(); 1349 let delivery = invalid 1350 .iter_mut() 1351 .find(|descriptor| descriptor.operation_id == OperationId::FarmPublish) 1352 .expect("delivery descriptor"); 1353 delivery.transport_capability.deliver = false; 1354 assert!(matches!( 1355 validate_catalog(&invalid), 1356 Err(Error::CatalogInvalid { .. }) 1357 )); 1358 } 1359 1360 #[test] 1361 fn route_constructors_and_errors_cover_all_variants() { 1362 let none = TransportRoute::none(); 1363 assert!(!none.includes_transport(TransportKind::LOCAL)); 1364 assert!(!none.includes_transport(TransportKind::NOSTR)); 1365 assert!(!none.includes_transport(TransportKind::RETICULUM)); 1366 assert!(!none.includes_transport(TransportKind::parse("future").expect("custom"))); 1367 assert!(TransportRoute::local().includes_transport(TransportKind::LOCAL)); 1368 assert!(TransportRoute::delivery().includes_transport(TransportKind::NOSTR)); 1369 assert!(TransportRoute::delivery().includes_transport(TransportKind::RETICULUM)); 1370 assert!(TransportRoute::fetch().fetch); 1371 assert!(TransportRoute::diagnostics().diagnostics); 1372 1373 let errors = [ 1374 Error::DuplicateOperationId { 1375 operation_id: OperationId::ProfileInspect, 1376 }, 1377 Error::MissingRequiredOperation { 1378 operation_id: OperationId::SyncStatus, 1379 }, 1380 Error::UnknownOperationId { 1381 operation_id: "unknown".to_owned(), 1382 }, 1383 Error::UnsupportedOperationSchemaVersion { 1384 operation_id: OperationId::SyncStatus, 1385 version: 2, 1386 }, 1387 Error::CatalogInvalid { 1388 message: "invalid catalog".to_owned(), 1389 }, 1390 ]; 1391 for error in errors { 1392 assert!(!error.to_string().is_empty()); 1393 } 1394 1395 let invalid = SyncStatusReceipt { 1396 schema_version: 2, 1397 health: SyncHealth::Unavailable, 1398 storage: SyncCapabilityState::Unsupported, 1399 source: SyncCapabilityState::Compiled, 1400 sink: SyncCapabilityState::Configured, 1401 signer: SyncCapabilityState::Degraded, 1402 outbox: SyncOutboxStatus { 1403 pending: 0, 1404 leased: 0, 1405 retryable: 0, 1406 satisfied: 0, 1407 exhausted: 0, 1408 }, 1409 projections: SyncProjectionStatus { 1410 ready: 0, 1411 invalidated: 0, 1412 rebuilding: 0, 1413 failed: 0, 1414 untracked: 0, 1415 }, 1416 }; 1417 assert_eq!( 1418 invalid.validate(), 1419 Err(Error::UnsupportedOperationSchemaVersion { 1420 operation_id: OperationId::SyncStatus, 1421 version: 2, 1422 }) 1423 ); 1424 } 1425 1426 #[cfg(feature = "serde")] 1427 #[test] 1428 fn serialized_descriptor_vector_is_exact() { 1429 let descriptor = operation_descriptor(OperationId::ProfileInspect).expect("profile"); 1430 assert_eq!( 1431 serde_json::to_value(descriptor).expect("descriptor JSON"), 1432 serde_json::json!({ 1433 "operation_id": "profile.inspect", 1434 "schema_version": 1, 1435 "mutability": "read", 1436 "risk": "low", 1437 "approval": "none", 1438 "signer": "none", 1439 "transport_capability": { 1440 "local": true, 1441 "nostr": false, 1442 "reticulum": false, 1443 "deliver": false, 1444 "fetch": false, 1445 "synchronize": false, 1446 "diagnostics": false 1447 }, 1448 "idempotency": "forbidden", 1449 "dry_run": "not_applicable", 1450 "deadline": "default_bounded", 1451 "privacy": "private_store", 1452 "projection": "reads_projection", 1453 "maturity": "stable" 1454 }) 1455 ); 1456 assert_eq!( 1457 serde_json::to_string(&OperationId::ProfileInspect).expect("operation JSON"), 1458 "\"profile.inspect\"" 1459 ); 1460 } 1461 1462 #[cfg(feature = "serde")] 1463 #[test] 1464 fn sync_status_receipt_is_typed_and_rejects_wire_drift() { 1465 let receipt = SyncStatusReceipt { 1466 schema_version: OPERATION_SCHEMA_VERSION, 1467 health: SyncHealth::Degraded, 1468 storage: SyncCapabilityState::Available, 1469 source: SyncCapabilityState::Available, 1470 sink: SyncCapabilityState::Degraded, 1471 signer: SyncCapabilityState::Configured, 1472 outbox: SyncOutboxStatus { 1473 pending: 1, 1474 leased: 2, 1475 retryable: 3, 1476 satisfied: 4, 1477 exhausted: 5, 1478 }, 1479 projections: SyncProjectionStatus { 1480 ready: 6, 1481 invalidated: 7, 1482 rebuilding: 8, 1483 failed: 9, 1484 untracked: 10, 1485 }, 1486 }; 1487 receipt.validate().expect("status receipt"); 1488 let value = serde_json::to_value(receipt).expect("status JSON"); 1489 assert_eq!(value["source"], "available"); 1490 assert_eq!(value["outbox"]["retryable"], 3); 1491 assert_eq!(value["projections"]["untracked"], 10); 1492 assert_eq!( 1493 serde_json::from_value::<SyncStatusReceipt>(value.clone()).expect("status decode"), 1494 receipt 1495 ); 1496 let mut invalid_version = value.clone(); 1497 invalid_version["schema_version"] = 2.into(); 1498 assert!(serde_json::from_value::<SyncStatusReceipt>(invalid_version).is_err()); 1499 let mut unknown = value; 1500 unknown["unknown"] = true.into(); 1501 assert!(serde_json::from_value::<SyncStatusReceipt>(unknown).is_err()); 1502 } 1503 }