commit 9101d82b79c6c12ec680fdd240239f09d3a796cb
parent c8f4c211fba41db7494df7134c08f822fdd0646c
Author: triesap <tyson@radroots.org>
Date: Tue, 28 Jul 2026 15:39:28 +0000
protocol: define stable error report v1
- Generate the 56-code catalog from one code, class, retry, and recovery authority.
- Add validated operation/capability context and deterministic safe structured details.
- Redact native source failures and fail closed for unknown codes and schema versions.
- Pass focused, no-std, WASM, architecture, hygiene, and full contract checks.
Diffstat:
4 files changed, 895 insertions(+), 4 deletions(-)
diff --git a/crates/protocol/src/error.rs b/crates/protocol/src/error.rs
@@ -1,4 +1,4 @@
//! Versioned stable error-report contracts.
/// Error-report contracts for generation 1.
-pub mod v1 {}
+pub mod v1;
diff --git a/crates/protocol/src/error/v1.rs b/crates/protocol/src/error/v1.rs
@@ -0,0 +1,885 @@
+//! Stable serialized error-report contract generation 1.
+//!
+//! Native crate errors preserve their source chains in their owning packages.
+//! This module accepts only validated, secret-safe data at the serialization
+//! boundary and cannot contain a native source error.
+
+use alloc::{
+ string::{String, ToString},
+ vec::Vec,
+};
+use core::fmt;
+
+use crate::{
+ runtime::v1::OperationId,
+ schema::{Metadata, ModuleVersion, Registry},
+};
+
+/// Error-report schema generation.
+pub const SCHEMA_VERSION: u16 = 1;
+/// Stable error-report schema identity.
+pub const SCHEMA_ID: &str = "radroots.protocol.error_report.v1";
+/// Replacement used when a native source message is not explicitly safe.
+pub const REDACTED_MESSAGE: &str = "[redacted]";
+const MAX_CODE_BYTES: usize = 96;
+const MAX_CAPABILITY_ID_BYTES: usize = 128;
+const MAX_SAFE_MESSAGE_BYTES: usize = 256;
+const MAX_DETAIL_ENTRIES: usize = 32;
+const MAX_DETAIL_TEXT_BYTES: usize = 128;
+
+/// Stable error class.
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
+#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub enum Class {
+ Validation,
+ Contract,
+ Storage,
+ Resource,
+ Conflict,
+ Operation,
+ Authorization,
+ Signer,
+ Network,
+ Sync,
+ Runtime,
+ Projection,
+ Query,
+ Capability,
+ Privacy,
+ Security,
+ Maintenance,
+ Internal,
+ Unknown,
+}
+
+/// Stable recovery action vocabulary established by the SDK surface.
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
+#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub enum RecoveryAction {
+ InspectLocalStores,
+ InspectGeoNamesAsset,
+ RetryOperationWithSameIdempotencyKey,
+ ConfigureTransportTargets,
+ ConfigureGeoNamesCache,
+ ConfigureSigner,
+ FixRequest,
+ SelectAuthorizedActor,
+ CompleteSignerAuthentication,
+ RetryAfterTransportFailure,
+ RetryGeoNamesDownload,
+ EnableRequiredFeature,
+}
+
+/// One generated catalog descriptor.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct Descriptor {
+ pub code: KnownCode,
+ pub class: Class,
+ pub retryable: bool,
+ pub recovery_actions: &'static [RecoveryAction],
+}
+
+macro_rules! error_catalog {
+ ($( $variant:ident => ($value:literal, $class:ident, $retryable:literal, [$($action:ident),* $(,)?]) ),+ $(,)?) => {
+ /// A code known to this protocol generation.
+ #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+ pub enum KnownCode {
+ $( $variant, )+
+ }
+
+ impl KnownCode {
+ /// Every code defined by this generation.
+ pub const ALL: &'static [Self] = &[$(Self::$variant),+];
+
+ /// Returns the stable wire identity.
+ pub const fn as_str(self) -> &'static str {
+ match self {
+ $(Self::$variant => $value,)+
+ }
+ }
+
+ /// Parses a code known to this generation.
+ pub fn parse(value: &str) -> Option<Self> {
+ match value {
+ $($value => Some(Self::$variant),)+
+ _ => None,
+ }
+ }
+
+ /// Returns the generated descriptor from the same authority.
+ pub const fn descriptor(self) -> Descriptor {
+ match self {
+ $(Self::$variant => Descriptor {
+ code: Self::$variant,
+ class: Class::$class,
+ retryable: $retryable,
+ recovery_actions: &[$(RecoveryAction::$action),*],
+ },)+
+ }
+ }
+ }
+
+ /// Complete generated error catalog.
+ pub const CATALOG: &[Descriptor] = &[
+ $(Descriptor {
+ code: KnownCode::$variant,
+ class: Class::$class,
+ retryable: $retryable,
+ recovery_actions: &[$(RecoveryAction::$action),*],
+ },)+
+ ];
+ };
+}
+
+error_catalog! {
+ InvalidArgument => ("invalid_argument", Validation, false, [FixRequest]),
+ UnsupportedContractVersion => ("unsupported_contract_version", Contract, false, [EnableRequiredFeature]),
+ UnsupportedProfileSchema => ("unsupported_profile_schema", Storage, false, [InspectLocalStores]),
+ SchemaTooNew => ("schema_too_new", Storage, false, [EnableRequiredFeature]),
+ NotFound => ("not_found", Resource, false, [FixRequest]),
+ AmbiguousTrade => ("ambiguous_trade", Conflict, false, [FixRequest]),
+ StaleListingRevision => ("stale_listing_revision", Conflict, false, [FixRequest]),
+ PreconditionChanged => ("precondition_changed", Conflict, true, [RetryOperationWithSameIdempotencyKey]),
+ RevisionRequired => ("revision_required", Conflict, false, [FixRequest]),
+ InventoryUnavailable => ("inventory_unavailable", Conflict, false, [RetryOperationWithSameIdempotencyKey]),
+ IdempotencyConflict => ("idempotency_conflict", Conflict, false, [RetryOperationWithSameIdempotencyKey]),
+ OperationInProgress => ("operation_in_progress", Operation, true, [RetryOperationWithSameIdempotencyKey]),
+ ApprovalRequired => ("approval_required", Authorization, false, [SelectAuthorizedActor]),
+ ApprovalInvalid => ("approval_invalid", Authorization, false, [SelectAuthorizedActor]),
+ ApprovalExpired => ("approval_expired", Authorization, false, [SelectAuthorizedActor]),
+ ApprovalReplayed => ("approval_replayed", Authorization, false, [SelectAuthorizedActor]),
+ AuthorizationDenied => ("authorization_denied", Authorization, false, [SelectAuthorizedActor]),
+ SignerCapabilityMissing => ("signer_capability_missing", Signer, false, [ConfigureSigner]),
+ SignerUnavailable => ("signer_unavailable", Signer, true, [ConfigureSigner]),
+ SignerRejected => ("signer_rejected", Signer, false, [SelectAuthorizedActor]),
+ SignerTimeout => ("signer_timeout", Signer, true, [RetryAfterTransportFailure]),
+ SignerCancelled => ("signer_cancelled", Signer, false, [ConfigureSigner]),
+ RelayAuthRequired => ("relay_auth_required", Network, true, [CompleteSignerAuthentication]),
+ RelayAuthRejected => ("relay_auth_rejected", Network, false, [CompleteSignerAuthentication]),
+ RelayPaymentRequired => ("relay_payment_required", Network, false, [ConfigureTransportTargets]),
+ RelayPolicyRestricted => ("relay_policy_restricted", Network, false, [ConfigureTransportTargets]),
+ RelayRateLimited => ("relay_rate_limited", Network, true, [RetryAfterTransportFailure]),
+ RelayPowRequired => ("relay_pow_required", Network, false, [ConfigureTransportTargets]),
+ TransportPartial => ("transport_partial", Network, true, [RetryAfterTransportFailure]),
+ TransportOperationUnavailable => ("transport_operation_unavailable", Capability, false, [ConfigureTransportTargets]),
+ SyncSaturated => ("sync_saturated", Sync, true, [RetryAfterTransportFailure]),
+ SyncPartial => ("sync_partial", Sync, true, [RetryAfterTransportFailure]),
+ DeadlineExceeded => ("deadline_exceeded", Runtime, true, [RetryAfterTransportFailure]),
+ CancelledNoCommit => ("cancelled_no_commit", Runtime, false, [RetryOperationWithSameIdempotencyKey]),
+ LocalCommittedDeliveryPending => ("local_committed_delivery_pending", Operation, true, [RetryAfterTransportFailure]),
+ DatabaseBusy => ("database_busy", Storage, true, [InspectLocalStores]),
+ ProfileWriterInUse => ("profile_writer_in_use", Storage, true, [InspectLocalStores]),
+ MaintenanceInProgress => ("maintenance_in_progress", Storage, true, [RetryOperationWithSameIdempotencyKey]),
+ StorageIntegrityFailed => ("storage_integrity_failed", Storage, false, [InspectLocalStores]),
+ StorageSpaceInsufficient => ("storage_space_insufficient", Storage, true, [InspectLocalStores]),
+ ProjectionStale => ("projection_stale", Projection, true, [InspectLocalStores]),
+ ProjectionFailed => ("projection_failed", Projection, true, [InspectLocalStores]),
+ ProjectionGenerationChanged => ("projection_generation_changed", Projection, true, [InspectLocalStores]),
+ InvalidCursor => ("invalid_cursor", Query, false, [FixRequest]),
+ UnsupportedCapability => ("unsupported_capability", Capability, false, [EnableRequiredFeature]),
+ DmRelayUnconfigured => ("dm_relay_unconfigured", Privacy, false, [ConfigureTransportTargets]),
+ PrivateDataUnavailable => ("private_data_unavailable", Privacy, false, [FixRequest]),
+ ValidationPending => ("validation_pending", Validation, true, [RetryOperationWithSameIdempotencyKey]),
+ ValidationExpired => ("validation_expired", Validation, false, [FixRequest]),
+ ValidatorSetInvalid => ("validator_set_invalid", Validation, false, [FixRequest]),
+ MediaPolicyDenied => ("media_policy_denied", Security, false, [FixRequest]),
+ BackupInvalid => ("backup_invalid", Maintenance, false, [InspectLocalStores]),
+ BackupAuthenticationFailed => ("backup_authentication_failed", Maintenance, false, [InspectLocalStores]),
+ RestoreFailed => ("restore_failed", Maintenance, true, [InspectLocalStores]),
+ Backpressure => ("backpressure", Runtime, true, [RetryAfterTransportFailure]),
+ InternalError => ("internal_error", Internal, false, [InspectLocalStores]),
+}
+
+/// A stable code that preserves unknown future values.
+#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct Code(String);
+
+impl Code {
+ /// Creates a code from a known catalog identity.
+ pub fn known(code: KnownCode) -> Self {
+ Self(code.as_str().to_string())
+ }
+
+ /// Parses a canonical code while preserving unknown future values.
+ pub fn parse(value: impl Into<String>) -> Result<Self, Error> {
+ let value = value.into();
+ if !valid_identifier(value.as_str(), MAX_CODE_BYTES) {
+ return Err(Error::InvalidCode);
+ }
+ Ok(Self(value))
+ }
+
+ /// Returns the exact serialized identity.
+ pub fn as_str(&self) -> &str {
+ self.0.as_str()
+ }
+
+ /// Resolves this identity against the current generated catalog.
+ pub fn known_code(&self) -> Option<KnownCode> {
+ KnownCode::parse(self.as_str())
+ }
+}
+
+#[cfg(feature = "serde")]
+impl serde::Serialize for Code {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ serializer.serialize_str(self.as_str())
+ }
+}
+
+#[cfg(feature = "serde")]
+impl<'de> serde::Deserialize<'de> for Code {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let value = <String as serde::Deserialize>::deserialize(deserializer)?;
+ Self::parse(value).map_err(serde::de::Error::custom)
+ }
+}
+
+/// Validated optional capability identity.
+#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct CapabilityId(String);
+
+impl CapabilityId {
+ /// Parses a canonical public capability identity.
+ pub fn parse(value: impl Into<String>) -> Result<Self, Error> {
+ let value = value.into();
+ if !valid_identifier(value.as_str(), MAX_CAPABILITY_ID_BYTES) {
+ return Err(Error::InvalidCapabilityId);
+ }
+ Ok(Self(value))
+ }
+
+ pub fn as_str(&self) -> &str {
+ self.0.as_str()
+ }
+}
+
+#[cfg(feature = "serde")]
+impl serde::Serialize for CapabilityId {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ serializer.serialize_str(self.as_str())
+ }
+}
+
+#[cfg(feature = "serde")]
+impl<'de> serde::Deserialize<'de> for CapabilityId {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let value = <String as serde::Deserialize>::deserialize(deserializer)?;
+ Self::parse(value).map_err(serde::de::Error::custom)
+ }
+}
+
+/// Validated secret-safe human-readable message.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct SafeMessage(String);
+
+impl SafeMessage {
+ /// Validates explicitly safe application-authored text.
+ pub fn parse(value: impl Into<String>) -> Result<Self, Error> {
+ let value = value.into();
+ if value.is_empty()
+ || value.len() > MAX_SAFE_MESSAGE_BYTES
+ || value.chars().any(char::is_control)
+ {
+ return Err(Error::InvalidSafeMessage);
+ }
+ if contains_sensitive_material(value.as_str()) {
+ return Err(Error::SensitiveMessage);
+ }
+ Ok(Self(value))
+ }
+
+ /// Returns the mandatory redacted source-message replacement.
+ pub fn redacted() -> Self {
+ Self(REDACTED_MESSAGE.to_string())
+ }
+
+ pub fn as_str(&self) -> &str {
+ self.0.as_str()
+ }
+}
+
+#[cfg(feature = "serde")]
+impl serde::Serialize for SafeMessage {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ serializer.serialize_str(self.as_str())
+ }
+}
+
+#[cfg(feature = "serde")]
+impl<'de> serde::Deserialize<'de> for SafeMessage {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let value = <String as serde::Deserialize>::deserialize(deserializer)?;
+ Self::parse(value).map_err(serde::de::Error::custom)
+ }
+}
+
+/// Stable scalar value allowed in safe structured details.
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(
+ feature = "serde",
+ serde(tag = "kind", content = "value", rename_all = "snake_case")
+)]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum DetailValue {
+ Text(String),
+ Bool(bool),
+ Signed(i64),
+ Unsigned(u64),
+}
+
+/// One key/value detail entry.
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Detail {
+ pub key: String,
+ pub value: DetailValue,
+}
+
+impl Detail {
+ /// Creates a detail. Collection validation applies the allowlist.
+ pub fn new(key: impl Into<String>, value: DetailValue) -> Self {
+ Self {
+ key: key.into(),
+ value,
+ }
+ }
+}
+
+/// Deterministically ordered, validated safe detail collection.
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(
+ feature = "serde",
+ serde(try_from = "Vec<Detail>", into = "Vec<Detail>")
+)]
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+pub struct SafeDetails {
+ entries: Vec<Detail>,
+}
+
+impl SafeDetails {
+ /// Validates, sorts, and stores safe details.
+ pub fn try_new(entries: impl IntoIterator<Item = Detail>) -> Result<Self, Error> {
+ let mut entries: Vec<_> = entries.into_iter().collect();
+ if entries.len() > MAX_DETAIL_ENTRIES {
+ return Err(Error::TooManyDetails);
+ }
+ entries.sort_by(|left, right| left.key.cmp(&right.key));
+ for (index, entry) in entries.iter().enumerate() {
+ validate_detail(entry)?;
+ if index > 0 && entries[index - 1].key == entry.key {
+ return Err(Error::DuplicateDetailKey);
+ }
+ }
+ Ok(Self { entries })
+ }
+
+ pub fn entries(&self) -> &[Detail] {
+ self.entries.as_slice()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.entries.is_empty()
+ }
+}
+
+impl TryFrom<Vec<Detail>> for SafeDetails {
+ type Error = Error;
+
+ fn try_from(entries: Vec<Detail>) -> Result<Self, Self::Error> {
+ Self::try_new(entries)
+ }
+}
+
+impl From<SafeDetails> for Vec<Detail> {
+ fn from(details: SafeDetails) -> Self {
+ details.entries
+ }
+}
+
+/// Secret-safe serialized error boundary.
+#[cfg_attr(feature = "serde", derive(serde::Serialize))]
+#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ErrorReport {
+ schema_version: u16,
+ code: Code,
+ class: Class,
+ retryable: bool,
+ recovery_actions: Vec<RecoveryAction>,
+ #[cfg_attr(
+ feature = "serde",
+ serde(default, skip_serializing_if = "Option::is_none")
+ )]
+ operation_id: Option<OperationId>,
+ #[cfg_attr(
+ feature = "serde",
+ serde(default, skip_serializing_if = "Option::is_none")
+ )]
+ capability_id: Option<CapabilityId>,
+ message: SafeMessage,
+ #[cfg_attr(feature = "serde", serde(default))]
+ details: SafeDetails,
+}
+
+impl ErrorReport {
+ /// Builds a report for a known code from its generated descriptor.
+ pub fn known(
+ code: KnownCode,
+ operation_id: Option<OperationId>,
+ capability_id: Option<CapabilityId>,
+ message: SafeMessage,
+ details: SafeDetails,
+ ) -> Self {
+ let descriptor = code.descriptor();
+ Self {
+ schema_version: SCHEMA_VERSION,
+ code: Code::known(code),
+ class: descriptor.class,
+ retryable: descriptor.retryable,
+ recovery_actions: descriptor.recovery_actions.to_vec(),
+ operation_id,
+ capability_id,
+ message,
+ details,
+ }
+ }
+
+ /// Converts an untrusted native source failure without copying its message.
+ pub fn redacted_from_source(
+ code: KnownCode,
+ operation_id: Option<OperationId>,
+ capability_id: Option<CapabilityId>,
+ ) -> Self {
+ Self::known(
+ code,
+ operation_id,
+ capability_id,
+ SafeMessage::redacted(),
+ SafeDetails::default(),
+ )
+ }
+
+ /// Builds the fail-closed representation of an unknown future code.
+ pub fn unknown(code: Code) -> Result<Self, Error> {
+ if code.known_code().is_some() {
+ return Err(Error::ExpectedUnknownCode);
+ }
+ Ok(Self {
+ schema_version: SCHEMA_VERSION,
+ code,
+ class: Class::Unknown,
+ retryable: false,
+ recovery_actions: Vec::new(),
+ operation_id: None,
+ capability_id: None,
+ message: SafeMessage::redacted(),
+ details: SafeDetails::default(),
+ })
+ }
+
+ /// Validates version, catalog agreement, and unknown-code policy.
+ pub fn validate(&self) -> Result<(), Error> {
+ if self.schema_version != SCHEMA_VERSION {
+ return Err(Error::UnsupportedSchemaVersion {
+ version: self.schema_version,
+ });
+ }
+ if let Some(known) = self.code.known_code() {
+ let descriptor = known.descriptor();
+ if self.class != descriptor.class
+ || self.retryable != descriptor.retryable
+ || self.recovery_actions.as_slice() != descriptor.recovery_actions
+ {
+ return Err(Error::DescriptorMismatch { code: known });
+ }
+ } else if self.class != Class::Unknown
+ || self.retryable
+ || !self.recovery_actions.is_empty()
+ || self.operation_id.is_some()
+ || self.capability_id.is_some()
+ || self.message.as_str() != REDACTED_MESSAGE
+ || !self.details.is_empty()
+ {
+ return Err(Error::InvalidUnknownCodePolicy);
+ }
+ SafeDetails::try_new(self.details.entries.clone())?;
+ Ok(())
+ }
+
+ pub const fn schema_version(&self) -> u16 {
+ self.schema_version
+ }
+
+ pub fn code(&self) -> &Code {
+ &self.code
+ }
+
+ pub const fn class(&self) -> Class {
+ self.class
+ }
+
+ pub const fn retryable(&self) -> bool {
+ self.retryable
+ }
+
+ pub fn recovery_actions(&self) -> &[RecoveryAction] {
+ self.recovery_actions.as_slice()
+ }
+
+ pub const fn operation_id(&self) -> Option<OperationId> {
+ self.operation_id
+ }
+
+ pub fn capability_id(&self) -> Option<&CapabilityId> {
+ self.capability_id.as_ref()
+ }
+
+ pub fn message(&self) -> &SafeMessage {
+ &self.message
+ }
+
+ pub fn details(&self) -> &SafeDetails {
+ &self.details
+ }
+}
+
+#[cfg(feature = "serde")]
+#[derive(serde::Deserialize)]
+#[serde(deny_unknown_fields)]
+struct WireReport {
+ schema_version: u16,
+ code: Code,
+ class: Class,
+ retryable: bool,
+ recovery_actions: Vec<RecoveryAction>,
+ #[serde(default)]
+ operation_id: Option<OperationId>,
+ #[serde(default)]
+ capability_id: Option<CapabilityId>,
+ message: SafeMessage,
+ #[serde(default)]
+ details: SafeDetails,
+}
+
+#[cfg(feature = "serde")]
+impl<'de> serde::Deserialize<'de> for ErrorReport {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let wire = WireReport::deserialize(deserializer)?;
+ let report = Self {
+ schema_version: wire.schema_version,
+ code: wire.code,
+ class: wire.class,
+ retryable: wire.retryable,
+ recovery_actions: wire.recovery_actions,
+ operation_id: wire.operation_id,
+ capability_id: wire.capability_id,
+ message: wire.message,
+ details: wire.details,
+ };
+ report.validate().map_err(serde::de::Error::custom)?;
+ Ok(report)
+ }
+}
+
+/// Exact schema metadata for generated-language authority.
+pub const SCHEMAS: &[Metadata] = &[Metadata {
+ type_name: "ErrorReport",
+ schema_id: SCHEMA_ID,
+ schema_version: SCHEMA_VERSION,
+}];
+
+/// Builds the stable error schema registry.
+pub fn schema_registry() -> Result<Registry, crate::schema::Error> {
+ Registry::try_from_metadata(
+ SCHEMAS
+ .iter()
+ .copied()
+ .map(|metadata| (metadata, ModuleVersion::ErrorV1)),
+ )
+}
+
+/// Error-report validation failure.
+#[derive(Clone, Debug, Eq, PartialEq)]
+#[non_exhaustive]
+pub enum Error {
+ InvalidCode,
+ InvalidCapabilityId,
+ InvalidSafeMessage,
+ SensitiveMessage,
+ TooManyDetails,
+ InvalidDetailKey,
+ SensitiveDetailKey,
+ DuplicateDetailKey,
+ InvalidDetailText,
+ ExpectedUnknownCode,
+ UnsupportedSchemaVersion { version: u16 },
+ DescriptorMismatch { code: KnownCode },
+ InvalidUnknownCodePolicy,
+}
+
+impl fmt::Display for Error {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::InvalidCode => formatter.write_str("invalid error code"),
+ Self::InvalidCapabilityId => formatter.write_str("invalid capability id"),
+ Self::InvalidSafeMessage => formatter.write_str("invalid safe message"),
+ Self::SensitiveMessage => formatter.write_str("sensitive error message rejected"),
+ Self::TooManyDetails => formatter.write_str("too many safe detail entries"),
+ Self::InvalidDetailKey => formatter.write_str("invalid safe detail key"),
+ Self::SensitiveDetailKey => formatter.write_str("sensitive detail key rejected"),
+ Self::DuplicateDetailKey => formatter.write_str("duplicate safe detail key"),
+ Self::InvalidDetailText => formatter.write_str("invalid safe detail text"),
+ Self::ExpectedUnknownCode => formatter.write_str("expected an unknown error code"),
+ Self::UnsupportedSchemaVersion { version } => {
+ write!(
+ formatter,
+ "unsupported error report schema version {version}"
+ )
+ }
+ Self::DescriptorMismatch { code } => {
+ write!(formatter, "error descriptor mismatch for {}", code.as_str())
+ }
+ Self::InvalidUnknownCodePolicy => {
+ formatter.write_str("unknown error code violates fail-closed policy")
+ }
+ }
+ }
+}
+
+#[cfg(feature = "std")]
+impl std::error::Error for Error {}
+
+fn validate_detail(detail: &Detail) -> Result<(), Error> {
+ const ALLOWED_KEYS: &[&str] = &[
+ "actual",
+ "available_count",
+ "committed",
+ "delivery_pending",
+ "expected",
+ "field",
+ "index",
+ "limit",
+ "mode",
+ "required_count",
+ "retry_after_ms",
+ "schema_version",
+ "status",
+ "target_count",
+ ];
+ if !ALLOWED_KEYS.contains(&detail.key.as_str()) {
+ if sensitive_identifier(detail.key.as_str()) {
+ return Err(Error::SensitiveDetailKey);
+ }
+ return Err(Error::InvalidDetailKey);
+ }
+ if let DetailValue::Text(value) = &detail.value
+ && (value.is_empty()
+ || value.len() > MAX_DETAIL_TEXT_BYTES
+ || !value.bytes().all(|byte| {
+ byte.is_ascii_lowercase()
+ || byte.is_ascii_digit()
+ || matches!(byte, b'_' | b'-' | b'.')
+ })
+ || contains_sensitive_material(value.as_str()))
+ {
+ return Err(Error::InvalidDetailText);
+ }
+ Ok(())
+}
+
+fn valid_identifier(value: &str, max_bytes: usize) -> bool {
+ !value.is_empty()
+ && value.len() <= max_bytes
+ && value
+ .bytes()
+ .next()
+ .is_some_and(|byte| byte.is_ascii_lowercase())
+ && value.bytes().all(|byte| {
+ byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-' | b'.')
+ })
+}
+
+fn sensitive_identifier(value: &str) -> bool {
+ let lower = value.to_ascii_lowercase();
+ [
+ "authorization",
+ "cookie",
+ "credential",
+ "mnemonic",
+ "password",
+ "private_key",
+ "raw_event",
+ "secret",
+ "seed",
+ "signature",
+ "token",
+ ]
+ .iter()
+ .any(|marker| lower.contains(marker))
+}
+
+fn contains_sensitive_material(value: &str) -> bool {
+ let lower = value.to_ascii_lowercase();
+ sensitive_identifier(value)
+ || lower.contains("bearer ")
+ || lower.contains("://")
+ || lower.contains("sk_")
+ || lower.contains("nsec1")
+ || lower.contains("-----begin ")
+ || lower.contains("private key")
+}
+
+#[cfg(test)]
+mod tests {
+ use alloc::collections::BTreeSet;
+
+ use super::*;
+
+ #[test]
+ fn generated_catalog_is_complete_unique_and_self_consistent() {
+ assert_eq!(CATALOG.len(), 56);
+ assert_eq!(KnownCode::ALL.len(), CATALOG.len());
+ let mut codes = BTreeSet::new();
+ for (index, descriptor) in CATALOG.iter().enumerate() {
+ assert!(codes.insert(descriptor.code.as_str()));
+ assert_eq!(descriptor.code, KnownCode::ALL[index]);
+ assert_eq!(descriptor.code.descriptor(), *descriptor);
+ assert_eq!(
+ KnownCode::parse(descriptor.code.as_str()),
+ Some(descriptor.code)
+ );
+ assert!(!descriptor.recovery_actions.is_empty());
+ }
+ }
+
+ #[cfg(feature = "serde")]
+ #[test]
+ fn known_report_round_trips_with_exact_v1_shape() {
+ let details = SafeDetails::try_new([
+ Detail::new("retry_after_ms", DetailValue::Unsigned(250)),
+ Detail::new("status", DetailValue::Text("rate_limited".to_string())),
+ ])
+ .expect("safe details");
+ let report = ErrorReport::known(
+ KnownCode::RelayRateLimited,
+ Some(OperationId::SyncPush),
+ Some(CapabilityId::parse("nostr").expect("capability")),
+ SafeMessage::parse("Relay rate limit requires a later retry").expect("safe message"),
+ details,
+ );
+ report.validate().expect("report");
+
+ let json = serde_json::to_string(&report).expect("serialize");
+ let decoded: ErrorReport = serde_json::from_str(json.as_str()).expect("deserialize");
+ assert_eq!(decoded, report);
+ assert_eq!(
+ serde_json::to_value(report).expect("value"),
+ serde_json::json!({
+ "schema_version": 1,
+ "code": "relay_rate_limited",
+ "class": "network",
+ "retryable": true,
+ "recovery_actions": ["retry_after_transport_failure"],
+ "operation_id": "sync.push",
+ "capability_id": "nostr",
+ "message": "Relay rate limit requires a later retry",
+ "details": [
+ {"key": "retry_after_ms", "value": {"kind": "unsigned", "value": 250}},
+ {"key": "status", "value": {"kind": "text", "value": "rate_limited"}}
+ ]
+ })
+ );
+ }
+
+ #[cfg(feature = "serde")]
+ #[test]
+ fn unknown_codes_are_preserved_but_fail_closed() {
+ let report = ErrorReport::unknown(Code::parse("future_failure").expect("code"))
+ .expect("unknown report");
+ let json = serde_json::to_string(&report).expect("serialize");
+ let decoded: ErrorReport = serde_json::from_str(json.as_str()).expect("deserialize");
+ assert_eq!(decoded.code().as_str(), "future_failure");
+ assert_eq!(decoded.class(), Class::Unknown);
+ assert!(!decoded.retryable());
+ assert!(decoded.recovery_actions().is_empty());
+ assert_eq!(decoded.message().as_str(), REDACTED_MESSAGE);
+
+ let invalid = json.replace("\"unknown\"", "\"network\"");
+ assert!(serde_json::from_str::<ErrorReport>(invalid.as_str()).is_err());
+ }
+
+ #[cfg(feature = "serde")]
+ #[test]
+ fn unknown_fields_and_versions_fail_closed() {
+ let report = ErrorReport::redacted_from_source(KnownCode::InternalError, None, None);
+ let mut value = serde_json::to_value(report).expect("value");
+ value["schema_version"] = serde_json::json!(2);
+ assert!(serde_json::from_value::<ErrorReport>(value.clone()).is_err());
+ value["schema_version"] = serde_json::json!(1);
+ value
+ .as_object_mut()
+ .expect("object")
+ .insert("source".to_string(), serde_json::json!("native error"));
+ assert!(serde_json::from_value::<ErrorReport>(value).is_err());
+ }
+
+ #[cfg(feature = "serde")]
+ #[test]
+ fn native_source_messages_are_redacted_and_secrets_are_rejected() {
+ for source in [
+ "Bearer top-secret-token",
+ "password=hunter2",
+ "nsec1privatekeymaterial",
+ "wss://user:password@relay.example.com?token=secret",
+ "-----BEGIN PRIVATE KEY-----",
+ "sk_live_sensitive",
+ ] {
+ assert!(SafeMessage::parse(source).is_err());
+ let report = ErrorReport::redacted_from_source(KnownCode::InternalError, None, None);
+ let json = serde_json::to_string(&report).expect("serialize");
+ assert!(!json.contains(source));
+ assert!(json.contains(REDACTED_MESSAGE));
+ }
+
+ assert_eq!(
+ SafeDetails::try_new([Detail::new(
+ "access_token",
+ DetailValue::Text("secret".to_string())
+ )]),
+ Err(Error::SensitiveDetailKey)
+ );
+ }
+
+ #[test]
+ fn schema_registry_dispatches_error_report_v1() {
+ let registry = schema_registry().expect("registry");
+ assert_eq!(registry.len(), 1);
+ assert_eq!(registry.descriptors()[0].module(), ModuleVersion::ErrorV1);
+ assert_eq!(registry.descriptors()[0].id().as_str(), SCHEMA_ID);
+ }
+}
diff --git a/crates/protocol/src/schema.rs b/crates/protocol/src/schema.rs
@@ -268,7 +268,11 @@ pub fn protocol_v1_registry() -> Result<Registry, Error> {
.iter()
.copied()
.map(|metadata| (metadata, ModuleVersion::EventV1));
- let mut descriptors = Registry::try_from_metadata(capability.chain(event))?
+ let error = crate::error::v1::SCHEMAS
+ .iter()
+ .copied()
+ .map(|metadata| (metadata, ModuleVersion::ErrorV1));
+ let mut descriptors = Registry::try_from_metadata(capability.chain(event).chain(error))?
.descriptors()
.to_vec();
descriptors.extend(
@@ -524,12 +528,14 @@ mod tests {
#[test]
fn protocol_v1_registry_dispatches_all_migrated_schemas() {
let registry = protocol_v1_registry().expect("protocol V1 registry");
- assert_eq!(registry.len(), 6 + crate::runtime::v1::CATALOG.len() * 2);
+ assert_eq!(registry.len(), 7 + crate::runtime::v1::CATALOG.len() * 2);
for descriptor in registry.descriptors() {
let expected = if descriptor.id().as_str()
== crate::radrootsd::transport_publish::v5::API_VERSION
{
ModuleVersion::RadrootsdTransportPublishV5
+ } else if descriptor.id().as_str() == crate::error::v1::SCHEMA_ID {
+ ModuleVersion::ErrorV1
} else if descriptor.id().as_str().starts_with("radroots.runtime.") {
ModuleVersion::RuntimeV1
} else if descriptor.id().as_str().contains("event_descriptor")
diff --git a/crates/protocol/tests/package_boundary.rs b/crates/protocol/tests/package_boundary.rs
@@ -46,7 +46,7 @@ fn crate_root_exposes_only_the_approved_versioned_skeleton() {
for source in [CAPABILITY, EVENT] {
assert!(source.lines().any(|line| line.trim() == "pub mod v1;"));
}
- assert!(ERROR.lines().any(|line| line.trim() == "pub mod v1 {}"));
+ assert!(ERROR.lines().any(|line| line.trim() == "pub mod v1;"));
assert!(RUNTIME.lines().any(|line| line.trim() == "pub mod v1;"));
assert!(RADROOTSD.contains("pub mod transport_publish {"));
assert!(RADROOTSD.lines().any(|line| line.trim() == "pub mod v5;"));