diff --git a/Cargo.lock b/Cargo.lock index 5fe16c72e..30a5d87ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1514,6 +1514,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-contract" +version = "0.1.0" +dependencies = [ + "iri-string", + "schemars", + "serde", + "serde_json", + "thiserror 2.0.18", + "ts-rs", +] + [[package]] name = "euclid" version = "0.20.14" @@ -3798,6 +3810,7 @@ version = "0.1.0" name = "protocol" version = "0.1.0" dependencies = [ + "error-contract", "schemars", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 400d2e489..da9875613 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ members = [ "crates/lint-common", "crates/tools", "crates/fs-operation", + "crates/error-contract", "crates/flow", "crates/builtin-source", "crates/config-source", @@ -68,6 +69,7 @@ default-members = [ "crates/lint-common", "crates/tools", "crates/fs-operation", + "crates/error-contract", "crates/flow", "crates/builtin-source", "crates/config-source", @@ -138,6 +140,7 @@ secrets = { path = "crates/secrets" } tools = { path = "crates/tools" } builtin-source = { path = "crates/builtin-source" } config-source = { path = "crates/config-source" } +error-contract = { path = "crates/error-contract" } fs-operation = { path = "crates/fs-operation" } workdir = { path = "crates/workdir" } tui = { path = "crates/tui" } diff --git a/crates/error-contract/Cargo.toml b/crates/error-contract/Cargo.toml new file mode 100644 index 000000000..99f7ca082 --- /dev/null +++ b/crates/error-contract/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "error-contract" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[features] +default = [] +json-schema = ["dep:schemars"] +typescript = ["dep:ts-rs", "json-schema"] +test-support = [] + +[dependencies] +iri-string = { version = "0.7.12", default-features = false } +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +thiserror.workspace = true +schemars = { workspace = true, optional = true } +ts-rs = { version = "12.0.1", optional = true } + +[[example]] +name = "two_domains" +required-features = ["test-support"] diff --git a/crates/error-contract/README.md b/crates/error-contract/README.md new file mode 100644 index 000000000..67099081c --- /dev/null +++ b/crates/error-contract/README.md @@ -0,0 +1,18 @@ +# Error contract + +This crate owns typed cause/context preservation, current-context disclosure interfaces, +bounded codecs and RFC 9457 primitives. It does not own operation outcomes, authorization, +retry, cleanup, persistence, HTTP servers, logging sinks or production domain mappings. + +[FailureFacts](crate::FailureFacts) retains the concrete domain cause. A domain derives +identity from its variant and implements [ProjectFor](crate::ProjectFor) per audience, +selecting typed wire fields and common metadata using the boundary's current authenticated +context and resource visibility. Only [project_failure](crate::project_failure) produces an +encodable audience marker. Sources and Display are never automatically serialized. + +[codec] distinguishes validated, unsupported and corrupt input. A received value must be +reprojected; opaque unsupported payload cannot be blindly forwarded to another audience. +[problem] uses API-boundary classification, not inner status forwarding. The `schema` module +(with `json-schema`) exposes the same DTOs to existing schema generators. The `test_support` +module (with `test-support`) provides two independent sample domains and shared wire fixtures for +consumer contract tests, not production authorization adapters. diff --git a/crates/error-contract/examples/two_domains.rs b/crates/error-contract/examples/two_domains.rs new file mode 100644 index 000000000..1865dffe6 --- /dev/null +++ b/crates/error-contract/examples/two_domains.rs @@ -0,0 +1,49 @@ +use error_contract::{compatibility::*, problem::*, test_support::*, *}; + +fn main() -> Result<(), ContractError> { + let access = CurrentAccess { + can_view_resource: true, + language: Language::English, + }; + let budget = Budget::new(1024 * 1024, 16 * 1024).with_depth(64, 15); + let subject = subject_facts(); + let browser = project_failure::(&subject, &access)?; + let response = + to_problem_details(&browser, &WorkerApi, "urn:yoi:occurrence:example-1", budget)?; + assert_eq!(response.status, 409); // The inner Subject API would classify this as 403. + assert_no_fixture_secrets(response.body()); + + let workdir = workdir_facts(); + let tool = project_failure::(&workdir, &access)?; + let encoded = encode_projected_failure(&tool, budget)?; + assert_semantic_roundtrip(encoded.as_bytes(), tool.details(), budget); + + // Ship this reader before switching any existing result writer. Old diagnostic prose is + // deliberately not an input to classification. The real domain result remains separate. + assert!(matches!( + read_failure_attachment::(None, budget), + StoredFailureRead::LegacyWithoutFacts + )); + let storage = project_failure::(&subject, &access)?; + let stored = encode_projected_failure(&storage, budget)?; + let received = match read_failure_attachment::(Some(stored.as_bytes()), budget) { + StoredFailureRead::Structured(DecodeFailure::Known(value)) => value, + _ => return Err(ContractError::Invalid), + }; + let current_access = CurrentAccess { + can_view_resource: false, + language: Language::Japanese, + }; + let reread = project_failure::(received.facts(), ¤t_access)?; + assert_eq!( + reread.details().required_operation, + RequiredOperation::SessionRecord + ); + assert!(reread.details().subject_ref.is_none()); + + let telemetry = project_failure::(&subject, &access)?; + let fields = observation_fields(&telemetry); + assert_eq!(fields.stage, Some("register")); + // Feed fields to the existing boundary's tracing sink; never log the source chain here. + Ok(()) +} diff --git a/crates/error-contract/fixtures/subject-denied.json b/crates/error-contract/fixtures/subject-denied.json new file mode 100644 index 000000000..13e8a05a5 --- /dev/null +++ b/crates/error-contract/fixtures/subject-denied.json @@ -0,0 +1 @@ +{"version":1,"required_extensions":[],"domain":"subject","reason":"operation_denied","category":"authorization","details_version":1,"details":{"required_operation":"session.record","subject_ref":"subject-1"},"explanation":"Worker creation cannot register the session: session.record is denied.","context":{"operations":[{"operation":"worker.create","stage":"register","operation_ref":null}],"causes":[],"diagnostics":[],"correlation":"correlation-1","supplementary_omitted":false}} diff --git a/crates/error-contract/fixtures/workdir-in-use.json b/crates/error-contract/fixtures/workdir-in-use.json new file mode 100644 index 000000000..ebecc6a79 --- /dev/null +++ b/crates/error-contract/fixtures/workdir-in-use.json @@ -0,0 +1 @@ +{"version":1,"required_extensions":[],"domain":"workdir","reason":"in_use","category":"conflict","details_version":1,"details":{"operation":"open","workdir_ref":"workdir-1"},"explanation":"Workdir open cannot proceed because the workdir is in use.","context":{"operations":[{"operation":"workdir.open","stage":"prepare","operation_ref":null}],"causes":[],"diagnostics":[],"correlation":"correlation-2","supplementary_omitted":false}} diff --git a/crates/error-contract/src/codec.rs b/crates/error-contract/src/codec.rs new file mode 100644 index 000000000..15d2f57e9 --- /dev/null +++ b/crates/error-contract/src/codec.rs @@ -0,0 +1,296 @@ +//! Bounded wire handling. A validated receiver value is still not a disclosure capability. +use serde::{Deserialize, Serialize}; +use std::{io::Write, marker::PhantomData}; + +use crate::*; + +/// Both the transport limit and the remaining enclosing-envelope budget are mandatory. +#[derive(Debug, Clone, Copy)] +pub struct Budget { + bytes: usize, + depth: usize, +} +impl Budget { + pub fn new(transport_limit: usize, remaining_envelope_bytes: usize) -> Self { + Self { + bytes: transport_limit + .min(remaining_envelope_bytes) + .min(MAX_FAILURE_BYTES), + depth: MAX_JSON_DEPTH, + } + } + /// Use the smaller transport depth and the remaining depth after enclosing wrappers. + /// A JSON-string embedding must additionally budget its escaped bytes at that boundary. + pub fn with_depth(mut self, transport_depth: usize, remaining_envelope_depth: usize) -> Self { + self.depth = self + .depth + .min(transport_depth) + .min(remaining_envelope_depth); + self + } + pub fn bytes(self) -> usize { + self.bytes + } + pub fn depth(self) -> usize { + self.depth + } +} + +/// DTO/schema source only. Deserialization does not produce a ProjectedFailure. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct FailureEnvelope { + #[cfg_attr(feature = "json-schema", schemars(range(min = 1)))] + pub version: u16, + #[cfg_attr(feature = "json-schema", schemars(length(max = 8)))] + pub required_extensions: Vec, + #[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))] + pub domain: String, + #[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))] + pub reason: String, + pub category: Category, + #[cfg_attr(feature = "json-schema", schemars(range(min = 1)))] + pub details_version: u16, + pub details: D, + #[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 2048)))] + pub explanation: String, + pub context: FailureContext, +} + +// A tolerant reader header keeps future category spellings opaque. Public schema/TS authority +// stays on FailureEnvelope; only supported identities are deserialized into its typed shape. +#[derive(Deserialize)] +struct ReaderEnvelope { + version: u16, + required_extensions: Vec, + domain: String, + reason: String, + category: String, + details_version: u16, + #[serde(rename = "details")] + _details: serde_json::Value, + explanation: String, + context: FailureContext, +} + +pub struct ReceivedFailure { + facts: FailureFacts, +} +impl ReceivedFailure { + /// Reprojection with current authorization is required before any encoder can use this. + pub fn facts(&self) -> &FailureFacts { + &self.facts + } +} + +/// Unknown bytes are retained opaquely, not serializable or available for blind forwarding. +/// Readers may use safe local stage/correlation separately; no reason inference is provided. +pub struct UnsupportedFailure { + payload: Vec, +} +impl UnsupportedFailure { + pub fn retained_bytes(&self) -> usize { + self.payload.len() + } +} +pub enum DecodeFailure { + Known(ReceivedFailure), + Unsupported(UnsupportedFailure), + Corrupt(ContractError), +} + +pub fn decode_failure(input: &[u8], budget: Budget) -> DecodeFailure { + match decode_checked(input, budget) { + Ok(value) => value, + Err(error) => DecodeFailure::Corrupt(error), + } +} + +pub(crate) fn decode_checked( + input: &[u8], + budget: Budget, +) -> Result, ContractError> { + check_json_bounds(input, budget)?; + let raw: ReaderEnvelope = serde_json::from_slice(input).map_err(|_| ContractError::Invalid)?; + validate_envelope_common(&raw)?; + if raw.version != SCHEMA_VERSION + || !raw.required_extensions.is_empty() + || !D::supports(&raw.domain, &raw.reason, raw.details_version) + { + return Ok(DecodeFailure::Unsupported(UnsupportedFailure { + payload: input.to_vec(), + })); + } + // Deserialize from the original bytes again, not a Value: typed duplicate fields must fail. + let typed: FailureEnvelope = + serde_json::from_slice(input).map_err(|_| ContractError::InvalidDetails)?; + accept_typed_envelope(typed).map(DecodeFailure::Known) +} + +pub(crate) fn accept_typed_envelope( + typed: FailureEnvelope, +) -> Result, ContractError> { + let identity = typed.details.identity(); + if identity.domain != typed.domain + || identity.reason != typed.reason + || identity.category != typed.category + || identity.details_version != typed.details_version + { + return Err(ContractError::InvalidDetails); + } + typed.details.validate()?; + let facts = FailureFacts::from_received(typed.details, typed.context); + validate_failure(&facts)?; + Ok(ReceivedFailure { facts }) +} + +fn validate_envelope_common(raw: &ReaderEnvelope) -> Result<(), ContractError> { + if raw.version == 0 || raw.details_version == 0 { + return Err(ContractError::Invalid); + } + validate_identifier(&raw.domain)?; + if raw.required_extensions.len() > MAX_DIAGNOSTICS { + return Err(ContractError::Invalid); + } + for extension in &raw.required_extensions { + validate_identifier(extension)?; + } + validate_identifier(&raw.reason)?; + validate_identifier(&raw.category)?; + validate_explanation(&raw.explanation)?; + raw.context.validate() +} + +/// Linear lexical depth guard runs before serde allocates any JSON tree. Serde validates grammar. +pub(crate) fn check_json_bounds(input: &[u8], budget: Budget) -> Result<(), ContractError> { + if input.len() > budget.bytes() { + return Err(ContractError::TooLarge); + } + let (mut depth, mut in_string, mut escaped) = (0usize, false, false); + for &byte in input { + if in_string { + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if byte == b'"' { + in_string = false; + } + } else { + match byte { + b'"' => in_string = true, + b'{' | b'[' => { + depth += 1; + if depth > budget.depth() { + return Err(ContractError::TooDeep); + } + } + b'}' | b']' => { + depth = depth.checked_sub(1).ok_or(ContractError::Invalid)?; + } + _ => {} + } + } + } + Ok(()) +} + +struct BoundedWriter { + bytes: Vec, + budget: usize, + exceeded: bool, +} +impl Write for BoundedWriter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + if bytes.len() > self.budget.saturating_sub(self.bytes.len()) { + self.exceeded = true; + return Err(std::io::Error::other("failure byte budget")); + } + self.bytes.extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} +pub(crate) fn encode_bounded( + value: &T, + budget: Budget, +) -> Result, ContractError> { + let mut writer = BoundedWriter { + bytes: Vec::new(), + budget: budget.bytes(), + exceeded: false, + }; + if serde_json::to_writer(&mut writer, value).is_err() { + return Err(if writer.exceeded { + ContractError::TooLarge + } else { + ContractError::Encoding + }); + } + check_json_bounds(&writer.bytes, budget)?; + Ok(writer.bytes) +} + +/// The audience remains present until the transport deliberately takes the bytes. +pub struct EncodedFailure { + bytes: Vec, + audience: PhantomData, +} +impl EncodedFailure { + pub fn as_bytes(&self) -> &[u8] { + &self.bytes + } +} + +pub fn encode_projected_failure( + failure: &ProjectedFailure, + budget: Budget, +) -> Result, ContractError> { + let bytes = encode_bounded(&failure.envelope, budget)?; + Ok(EncodedFailure { + bytes, + audience: PhantomData, + }) +} + +/// Deliberately not a FailureEnvelope: it reports inability to disclose, not a new domain cause. +pub const STATIC_FALLBACK: &[u8] = + br#"{"failure_unavailable":true,"detail":"Failure details unavailable."}"#; + +pub struct BoundaryEncoding { + pub encoded: EncodedFailure, + pub conversion_error: Option, + /// Kept separate from static payload; the boundary must allowlist it for disclosure. + pub correlation: Option, +} + +/// No formatting of errors, recursive projection or operation invocation occurs on this path. +/// If even the static payload cannot fit, return a boundary error; never truncate required facts. +pub fn encode_or_fallback( + projection: Result<&ProjectedFailure, ContractError>, + budget: Budget, + correlation: Option, +) -> Result, ContractError> { + let result = projection.and_then(|failure| encode_projected_failure(failure, budget)); + let (encoded, conversion_error) = match result { + Ok(encoded) => (encoded, None), + Err(error) => { + check_json_bounds(STATIC_FALLBACK, budget)?; + ( + EncodedFailure { + bytes: STATIC_FALLBACK.to_vec(), + audience: PhantomData, + }, + Some(error), + ) + } + }; + Ok(BoundaryEncoding { + encoded, + conversion_error, + correlation, + }) +} diff --git a/crates/error-contract/src/compatibility.rs b/crates/error-contract/src/compatibility.rs new file mode 100644 index 000000000..c297613d0 --- /dev/null +++ b/crates/error-contract/src/compatibility.rs @@ -0,0 +1,29 @@ +//! Reader-first compatibility for optional structured failure attachments. +//! +//! Existing domain envelopes retain their operation outcome and legacy diagnostics. Ship a +//! reader accepting an absent attachment before enabling a writer of a versioned failure. +//! Absence is historical text without typed facts, not a fabricated `unknown` domain cause. +//! Present-but-corrupt and present-but-unsupported are distinct and must not fall back to +//! parsing legacy prose. Readers reproject validated attachments using current visibility. +//! +//! This module does not migrate DBs or rewrite old archives. The owning store must choose +//! its additive field/migration and rollout unit with its consumer. A rollback disables new +//! writes first and retains the reader while any new-format results remain. Unknown payload +//! retention is local to the original reader; it is never permission to forward to another +//! audience. No production writer is enabled merely by depending on this crate. +use crate::*; + +pub enum StoredFailureRead { + LegacyWithoutFacts, + Structured(DecodeFailure), +} + +pub fn read_failure_attachment( + attachment: Option<&[u8]>, + budget: Budget, +) -> StoredFailureRead { + match attachment { + None => StoredFailureRead::LegacyWithoutFacts, + Some(bytes) => StoredFailureRead::Structured(decode_failure(bytes, budget)), + } +} diff --git a/crates/error-contract/src/facts.rs b/crates/error-contract/src/facts.rs new file mode 100644 index 000000000..83e65b8d8 --- /dev/null +++ b/crates/error-contract/src/facts.rs @@ -0,0 +1,232 @@ +use serde::{Deserialize, Serialize}; +use std::{error::Error, fmt}; + +use crate::*; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub enum Category { + Authorization, + InvalidInput, + Conflict, + Dependency, + Internal, + Unknown, +} + +/// Identity is derived from a domain variant, never supplied independently by a caller. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FailureIdentity { + pub domain: &'static str, + pub reason: &'static str, + pub category: Category, + pub details_version: u16, +} + +pub trait FailureCause { + fn identity(&self) -> FailureIdentity; + /// Domain-owned required fields, length and collection bounds. + fn validate(&self) -> Result<(), ContractError>; +} + +/// Implement on a typed wire allowlist, not on arbitrary error strings or maps. +pub trait WireDetails: FailureCause + Serialize + serde::de::DeserializeOwned { + fn supports(domain: &str, reason: &str, details_version: u16) -> bool; +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct OperationContext { + #[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))] + pub operation: String, + #[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))] + pub stage: String, + /// A reference, not an idempotency/retry grant. Disclosure remains domain-owned. + #[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))] + pub operation_ref: Option, +} + +impl OperationContext { + pub fn new( + operation: impl Into, + stage: impl Into, + ) -> Result { + let context = Self { + operation: operation.into(), + stage: stage.into(), + operation_ref: None, + }; + context.validate()?; + Ok(context) + } + + pub fn validate(&self) -> Result<(), ContractError> { + validate_identifier(&self.operation)?; + validate_identifier(&self.stage)?; + if let Some(reference) = &self.operation_ref { + validate_identifier(reference)?; + } + Ok(()) + } +} + +/// Correlation is deliberately a distinct field/type from operation identity. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct CorrelationRef( + #[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))] String, +); + +impl CorrelationRef { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + validate_identifier(&value)?; + Ok(Self(value)) + } + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Only explicit causal references belong here. Cleanup/audit failures stay in domain results. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct CausalLink { + #[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))] + pub domain: String, + #[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))] + pub reason: String, + pub correlation: CorrelationRef, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct Diagnostic { + #[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))] + pub code: String, + #[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 2048)))] + pub explanation: String, +} + +/// Common metadata is untrusted after deserialization. No audience label confers trust. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct FailureContext { + #[cfg_attr(feature = "json-schema", schemars(length(max = 8)))] + pub operations: Vec, + /// Ordered immediate-to-root chain, at most four references; never recursive payloads. + #[cfg_attr(feature = "json-schema", schemars(length(max = 4)))] + pub causes: Vec, + #[cfg_attr(feature = "json-schema", schemars(length(max = 8)))] + pub diagnostics: Vec, + pub correlation: Option, + pub supplementary_omitted: bool, +} + +impl FailureContext { + pub fn validate(&self) -> Result<(), ContractError> { + if self.operations.len() > MAX_CONTEXTS + || self.causes.len() > MAX_CAUSES + || self.diagnostics.len() > MAX_DIAGNOSTICS + { + return Err(ContractError::Invalid); + } + for op in &self.operations { + op.validate()?; + } + for cause in &self.causes { + validate_identifier(&cause.domain)?; + validate_identifier(&cause.reason)?; + validate_identifier(cause.correlation.as_str())?; + } + for diagnostic in &self.diagnostics { + validate_identifier(&diagnostic.code)?; + validate_explanation(&diagnostic.explanation)?; + } + if let Some(correlation) = &self.correlation { + validate_identifier(correlation.as_str())?; + } + Ok(()) + } +} + +/// Not serializable: the domain retains its concrete error and source chain. +#[derive(Debug)] +pub struct FailureFacts { + cause: D, + context: FailureContext, +} + +impl FailureFacts { + pub fn new(cause: D) -> Result { + let facts = Self { + cause, + context: FailureContext::default(), + }; + validate_failure(&facts)?; + Ok(facts) + } + pub fn cause(&self) -> &D { + &self.cause + } + pub fn context(&self) -> &FailureContext { + &self.context + } + /// A failed append leaves the original facts intact. + pub fn with_context(&mut self, context: OperationContext) -> Result<(), ContractError> { + context.validate()?; + if self.context.operations.len() == MAX_CONTEXTS { + return Err(ContractError::Invalid); + } + self.context.operations.push(context); + Ok(()) + } + pub fn set_correlation(&mut self, correlation: CorrelationRef) -> Result<(), ContractError> { + validate_identifier(correlation.as_str())?; + self.context.correlation = Some(correlation); + Ok(()) + } + pub fn with_causal_link(&mut self, cause: CausalLink) -> Result<(), ContractError> { + validate_identifier(&cause.domain)?; + validate_identifier(&cause.reason)?; + validate_identifier(cause.correlation.as_str())?; + if self.context.causes.len() == MAX_CAUSES { + return Err(ContractError::Invalid); + } + self.context.causes.push(cause); + Ok(()) + } + pub(crate) fn from_received(cause: D, context: FailureContext) -> Self { + Self { cause, context } + } +} + +pub fn validate_failure(facts: &FailureFacts) -> Result<(), ContractError> { + let identity = facts.cause.identity(); + validate_identifier(identity.domain)?; + validate_identifier(identity.reason)?; + if identity.details_version == 0 { + return Err(ContractError::InvalidDetails); + } + facts.cause.validate()?; + facts.context.validate() +} + +impl fmt::Display for FailureFacts { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let identity = self.cause.identity(); + write!(f, "{}.{}", identity.domain, identity.reason) + } +} +impl Error for FailureFacts { + fn source(&self) -> Option<&(dyn Error + 'static)> { + Some(&self.cause) + } +} diff --git a/crates/error-contract/src/lib.rs b/crates/error-contract/src/lib.rs new file mode 100644 index 000000000..d4db17b6a --- /dev/null +++ b/crates/error-contract/src/lib.rs @@ -0,0 +1,56 @@ +#![doc = include_str!("../README.md")] + +pub mod codec; +pub mod compatibility; +pub mod facts; +pub mod problem; +pub mod projection; +#[cfg(feature = "json-schema")] +pub mod schema; +#[cfg(feature = "test-support")] +pub mod test_support; + +pub use codec::{Budget, DecodeFailure, ReceivedFailure, decode_failure, encode_projected_failure}; +pub use facts::*; +pub use projection::*; + +pub const SCHEMA_VERSION: u16 = 1; +pub const MAX_FAILURE_BYTES: usize = 32 * 1024; +pub const MAX_JSON_DEPTH: usize = 16; +pub const MAX_CONTEXTS: usize = 8; +pub const MAX_CAUSES: usize = 4; +pub const MAX_DIAGNOSTICS: usize = 8; +pub const MAX_EXPLANATION_BYTES: usize = 2 * 1024; +pub const MAX_IDENTIFIER_BYTES: usize = 128; + +/// Conversion errors describe only the boundary, never the original operation's outcome. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum ContractError { + #[error("failure exceeds the available byte budget")] + TooLarge, + #[error("failure exceeds the JSON depth limit")] + TooDeep, + #[error("invalid failure structure")] + Invalid, + #[error("invalid typed failure details")] + InvalidDetails, + #[error("failure projection unavailable")] + ProjectionUnavailable, + #[error("failure serialization unavailable")] + Encoding, +} + +pub fn validate_identifier(value: &str) -> Result<(), ContractError> { + if value.is_empty() || value.len() > MAX_IDENTIFIER_BYTES || value.chars().any(char::is_control) + { + return Err(ContractError::Invalid); + } + Ok(()) +} + +pub fn validate_explanation(value: &str) -> Result<(), ContractError> { + if value.is_empty() || value.len() > MAX_EXPLANATION_BYTES || value.contains('\0') { + return Err(ContractError::Invalid); + } + Ok(()) +} diff --git a/crates/error-contract/src/problem.rs b/crates/error-contract/src/problem.rs new file mode 100644 index 000000000..37f3675bf --- /dev/null +++ b/crates/error-contract/src/problem.rs @@ -0,0 +1,245 @@ +//! RFC 9457 primitives, without an HTTP server/client dependency or inner-status forwarding. +use crate::{ + codec::{self, FailureEnvelope}, + *, +}; +use serde::{Deserialize, Serialize}; + +pub const PROBLEM_CONTENT_TYPE: &str = "application/problem+json"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HttpFailureStatus { + BadRequest, + Unauthorized, + Forbidden, + NotFound, + Conflict, + Unprocessable, + TooManyRequests, + Internal, + BadGateway, + Unavailable, + GatewayTimeout, +} +impl HttpFailureStatus { + pub fn code(self) -> u16 { + match self { + Self::BadRequest => 400, + Self::Unauthorized => 401, + Self::Forbidden => 403, + Self::NotFound => 404, + Self::Conflict => 409, + Self::Unprocessable => 422, + Self::TooManyRequests => 429, + Self::Internal => 500, + Self::BadGateway => 502, + Self::Unavailable => 503, + Self::GatewayTimeout => 504, + } + } +} + +/// The API boundary, not Category or an inner HTTP response, chooses this mapping. +#[derive(Debug, Clone, Copy)] +pub struct ProblemClass { + pub status: HttpFailureStatus, + pub type_uri: &'static str, + pub title: &'static str, +} +impl ProblemClass { + pub fn authorization_denied() -> Self { + Self { + status: HttpFailureStatus::Forbidden, + type_uri: "urn:yoi:problem:authorization-denied", + title: "Authorization denied", + } + } + pub fn invalid_input() -> Self { + Self { + status: HttpFailureStatus::BadRequest, + type_uri: "urn:yoi:problem:invalid-input", + title: "Invalid input", + } + } + pub fn conflict() -> Self { + Self { + status: HttpFailureStatus::Conflict, + type_uri: "urn:yoi:problem:conflict", + title: "Operation conflict", + } + } + pub fn dependency_failure() -> Self { + Self { + status: HttpFailureStatus::BadGateway, + type_uri: "urn:yoi:problem:dependency", + title: "Dependency failure", + } + } +} +pub trait ApiBoundary { + fn classify(&self, details: &D) -> ProblemClass; +} + +/// Untrusted wire DTO. Unknown RFC extensions are ignored; yoi_failure is mandatory here. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct ProblemDetails { + #[serde(rename = "type")] + #[cfg_attr(feature = "json-schema", schemars(length(max = 2048)))] + pub type_uri: String, + #[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 2048)))] + pub title: String, + #[cfg_attr(feature = "json-schema", schemars(range(min = 400, max = 599)))] + pub status: u16, + #[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 2048)))] + pub detail: String, + #[cfg_attr(feature = "json-schema", schemars(length(max = 2048)))] + pub instance: String, + pub yoi_failure: FailureEnvelope, +} + +#[derive(Deserialize)] +struct ReaderProblem { + #[serde(rename = "type")] + type_uri: String, + title: String, + status: u16, + detail: String, + instance: String, + yoi_failure: serde_json::Value, +} + +pub struct ProblemResponse { + pub status: u16, + pub content_type: &'static str, + body: Vec, +} +impl ProblemResponse { + pub fn body(&self) -> &[u8] { + &self.body + } +} + +fn validate_uri(value: &str) -> Result<(), ContractError> { + if value.len() > MAX_EXPLANATION_BYTES { + return Err(ContractError::Invalid); + } + // Reuse the RFC 3986 validator already in the transport dependency graph, without its + // optional features. Unlike WHATWG URL parsing, borrowed UriReferenceStr validation + // neither resolves a base URI nor normalizes malformed input into different text. + // Empty references, relative paths, queries and fragments are valid URI-references. + iri_string::types::UriReferenceStr::new(value) + .map(|_| ()) + .map_err(|_| ContractError::Invalid) +} + +pub fn to_problem_details( + projected: &ProjectedFailure, + api_boundary: &impl ApiBoundary, + instance: &str, + budget: Budget, +) -> Result { + let class = api_boundary.classify(projected.details()); + validate_uri(class.type_uri)?; + validate_uri(instance)?; + validate_explanation(class.title)?; + // Serialize by reference; no new projection or conversion from arbitrary Display exists. + let problem = ProblemDetails { + type_uri: class.type_uri.into(), + title: class.title.into(), + status: class.status.code(), + detail: projected.explanation().into(), + instance: instance.into(), + yoi_failure: FailureEnvelope { + version: projected.envelope.version, + required_extensions: projected.envelope.required_extensions.clone(), + domain: projected.envelope.domain.clone(), + reason: projected.envelope.reason.clone(), + category: projected.envelope.category, + details_version: projected.envelope.details_version, + details: &projected.envelope.details, + explanation: projected.envelope.explanation.clone(), + context: projected.envelope.context.clone(), + }, + }; + let body = codec::encode_bounded(&problem, budget)?; + Ok(ProblemResponse { + status: problem.status, + content_type: PROBLEM_CONTENT_TYPE, + body, + }) +} + +pub fn decode_problem_details( + input: &[u8], + actual_status: u16, + content_type: &str, + api_boundary: &impl ApiBoundary, + budget: Budget, +) -> DecodeFailure { + match decode_problem_checked(input, actual_status, content_type, api_boundary, budget) { + Ok(result) => result, + Err(error) => DecodeFailure::Corrupt(error), + } +} +fn decode_problem_checked( + input: &[u8], + actual_status: u16, + content_type: &str, + api_boundary: &impl ApiBoundary, + budget: Budget, +) -> Result, ContractError> { + codec::check_json_bounds(input, budget)?; + if !content_type + .split(';') + .next() + .unwrap_or("") + .trim() + .eq_ignore_ascii_case(PROBLEM_CONTENT_TYPE) + { + return Err(ContractError::Invalid); + } + let raw: ReaderProblem = serde_json::from_slice(input).map_err(|_| ContractError::Invalid)?; + if raw.status != actual_status + || !(400..=599).contains(&raw.status) + || Some(raw.detail.as_str()) + != raw + .yoi_failure + .get("explanation") + .and_then(serde_json::Value::as_str) + { + return Err(ContractError::Invalid); + } + validate_uri(&raw.type_uri)?; + validate_uri(&raw.instance)?; + validate_explanation(&raw.title)?; + let failure_bytes = codec::encode_bounded(&raw.yoi_failure, budget)?; + match codec::decode_checked::(&failure_bytes, budget)? { + DecodeFailure::Known(_) => { + // Use original JSON for typed parsing, preserving duplicate-field rejection. + let typed: ProblemDetails = + serde_json::from_slice(input).map_err(|_| ContractError::InvalidDetails)?; + let class = api_boundary.classify(&typed.yoi_failure.details); + if typed.status != class.status.code() + || typed.type_uri != class.type_uri + || typed.title != class.title + { + return Err(ContractError::Invalid); + } + codec::accept_typed_envelope(typed.yoi_failure).map(DecodeFailure::Known) + } + other => Ok(other), + } +} + +pub const STATIC_PROBLEM_FALLBACK: &[u8] = br#"{"type":"about:blank","title":"Internal Server Error","status":500,"detail":"Failure details unavailable."}"#; +/// Static fallback is deliberately outside the domain contract and never formats a failure. +pub fn problem_fallback(budget: Budget) -> Result { + codec::check_json_bounds(STATIC_PROBLEM_FALLBACK, budget)?; + Ok(ProblemResponse { + status: 500, + content_type: PROBLEM_CONTENT_TYPE, + body: STATIC_PROBLEM_FALLBACK.to_vec(), + }) +} diff --git a/crates/error-contract/src/projection.rs b/crates/error-contract/src/projection.rs new file mode 100644 index 000000000..3b2cbb430 --- /dev/null +++ b/crates/error-contract/src/projection.rs @@ -0,0 +1,174 @@ +//! Domain allowlists are the only path from internal facts to encodable values. +use std::marker::PhantomData; + +use crate::{codec::FailureEnvelope, *}; + +mod sealed { + pub trait Sealed {} +} +pub trait Audience: sealed::Sealed {} +macro_rules! audiences { + ($($name:ident),*) => { $( + #[derive(Debug)] pub struct $name; + impl sealed::Sealed for $name {} + impl Audience for $name {} + )* }; +} +audiences!(Browser, Tool, InternalTransfer, Storage, Telemetry); + +/// Domain-owned allowlisted output. Context is selected too; it is not copied implicitly. +pub struct Disclosure { + pub details: D, + pub explanation: String, + pub context: FailureContext, +} +impl Disclosure { + pub fn new(details: D, explanation: impl Into) -> Self { + Self { + details, + explanation: explanation.into(), + context: FailureContext::default(), + } + } +} + +/// Implement separately for each audience. The boundary supplies *current* authorization +/// and resource visibility, including when projecting a previously stored/received value. +/// This trait does not authenticate a caller or turn an audience name into authority. +pub trait ProjectFor: FailureCause + Sized { + type Authorization: ?Sized; + type Details: WireDetails; + fn disclose( + &self, + facts: &FailureFacts, + authorization: &Self::Authorization, + ) -> Result, ContractError>; +} + +/// No Deserialize/Serialize implementation: only projection creates this trust marker. +/// +/// ```compile_fail +/// use error_contract::{Browser, Storage, ProjectedFailure}; +/// fn public_only(_: &ProjectedFailure) {} +/// fn cannot_relabel(stored: &ProjectedFailure) { public_only(stored); } +/// ``` +/// +/// ```compile_fail +/// use error_contract::{Browser, ProjectedFailure}; +/// let _: ProjectedFailure = serde_json::from_str("{}").unwrap(); +/// ``` +/// +/// ```compile_fail +/// use error_contract::{Browser, Budget, ReceivedFailure, WireDetails, encode_projected_failure}; +/// fn received_is_not_projected(received: &ReceivedFailure) { +/// encode_projected_failure::(received, Budget::new(1024, 1024)); +/// } +/// ``` +pub struct ProjectedFailure { + pub(crate) envelope: FailureEnvelope, + pub(crate) audience: PhantomData, +} +impl ProjectedFailure { + pub fn details(&self) -> &D { + &self.envelope.details + } + pub fn identity(&self) -> FailureIdentity { + self.envelope.details.identity() + } + pub fn explanation(&self) -> &str { + &self.envelope.explanation + } + pub fn context(&self) -> &FailureContext { + &self.envelope.context + } +} + +pub fn project_failure>( + facts: &FailureFacts, + authorization: &D::Authorization, +) -> Result, ContractError> { + validate_failure(facts)?; + let disclosure = facts.cause().disclose(facts, authorization)?; + disclosure.details.validate()?; + let identity = disclosure.details.identity(); + // A domain can remove optional details, not silently replace a known cause. + if identity != facts.cause().identity() { + return Err(ContractError::InvalidDetails); + } + validate_explanation(&disclosure.explanation)?; + disclosure.context.validate()?; + Ok(ProjectedFailure { + envelope: FailureEnvelope { + version: SCHEMA_VERSION, + required_extensions: Vec::new(), + domain: identity.domain.into(), + reason: identity.reason.into(), + category: identity.category, + details_version: identity.details_version, + details: disclosure.details, + explanation: disclosure.explanation, + context: disclosure.context, + }, + audience: PhantomData, + }) +} + +/// Compose an allowlisted cause and impact without truncating either required part. +/// Domains select the translated fragments; consumers must not classify from this prose. +pub fn compose_explanation(cause: &str, impact: &str) -> Result { + validate_explanation(cause)?; + validate_explanation(impact)?; + let explanation = format!("{impact}: {cause}"); + validate_explanation(&explanation)?; + Ok(explanation) +} + +/// Safe local presentation when no supported cause can be disclosed. This is not a +/// replacement domain failure or permission to forward unsupported payload bytes. +#[derive(Debug)] +pub struct UnavailablePresentation { + pub detail: &'static str, + pub stage: &'static str, + pub correlation: Option, +} +pub fn unavailable_presentation( + stage: &'static str, + correlation: Option, +) -> Result { + validate_identifier(stage)?; + if let Some(reference) = &correlation { + validate_identifier(reference.as_str())?; + } + Ok(UnavailablePresentation { + detail: "Failure details unavailable.", + stage, + correlation, + }) +} + +/// Safe fields for an existing tracing sink. No implicit recording at wrappers. +#[derive(Debug, PartialEq, Eq)] +pub struct ObservationFields<'a> { + pub domain: &'static str, + pub reason: &'static str, + pub operation: Option<&'a str>, + pub stage: Option<&'a str>, + pub correlation: Option<&'a str>, +} +pub fn observation_fields( + failure: &ProjectedFailure, +) -> ObservationFields<'_> { + let identity = failure.identity(); + let operation = failure.context().operations.last(); + ObservationFields { + domain: identity.domain, + reason: identity.reason, + operation: operation.map(|op| op.operation.as_str()), + stage: operation.map(|op| op.stage.as_str()), + correlation: failure + .context() + .correlation + .as_ref() + .map(CorrelationRef::as_str), + } +} diff --git a/crates/error-contract/src/schema.rs b/crates/error-contract/src/schema.rs new file mode 100644 index 000000000..c3cc9d6f7 --- /dev/null +++ b/crates/error-contract/src/schema.rs @@ -0,0 +1,32 @@ +//! The wire DTOs are the schema source. Domain details supply their own JsonSchema. +//! Byte/depth budgets and cross-field validation remain runtime requirements; JSON Schema +//! string lengths count codepoints and cannot replace the codec's UTF-8 byte limits. +use crate::{codec::FailureEnvelope, problem::ProblemDetails}; + +pub fn failure_schema() -> schemars::Schema { + schemars::schema_for!(FailureEnvelope) +} +pub fn problem_schema() -> schemars::Schema { + schemars::schema_for!(ProblemDetails) +} + +/// Existing protocol TS generation consumes this fragment; no second DTO/generator is needed. +#[cfg(feature = "typescript")] +pub fn typescript_declarations() -> String { + use crate::*; + use ts_rs::TS; + let config = ts_rs::Config::default(); + [ + Category::decl(&config), + OperationContext::decl(&config), + CorrelationRef::decl(&config), + CausalLink::decl(&config), + Diagnostic::decl(&config), + FailureContext::decl(&config), + FailureEnvelope::::decl(&config), + ProblemDetails::::decl(&config), + ] + .into_iter() + .map(|declaration| format!("export {declaration}\n")) + .collect() +} diff --git a/crates/error-contract/src/test_support.rs b/crates/error-contract/src/test_support.rs new file mode 100644 index 000000000..a510bd6ff --- /dev/null +++ b/crates/error-contract/src/test_support.rs @@ -0,0 +1,354 @@ +//! Shared executable examples, not production authorization/domain adapters. +//! The fixture authorization context stands in for a boundary's current authenticated policy +//! decision; it must never be reconstructed from a received failure or an audience label. +use crate::*; +use serde::{Deserialize, Serialize}; + +pub const SUBJECT_FIXTURE: &str = include_str!("../fixtures/subject-denied.json"); +pub const WORKDIR_FIXTURE: &str = include_str!("../fixtures/workdir-in-use.json"); +pub const FIXTURE_SECRETS: &[&str] = &[ + "credential-secret", + "/private/worktree", + "raw-http-body", + "internal-source", +]; + +#[derive(Clone, Copy)] +pub enum Language { + English, + Japanese, +} +/// Fixture policy for subject-1/workdir-1 only; a true visibility decision is not a grant +/// to disclose arbitrary resource references supplied by a received wire payload. +pub struct CurrentAccess { + pub can_view_resource: bool, + pub language: Language, +} +impl CurrentAccess { + fn visible_reference(&self, reference: Option<&str>, expected: &str) -> Option { + reference + .filter(|value| self.can_view_resource && *value == expected) + .map(str::to_owned) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub enum RequiredOperation { + #[serde(rename = "session.record")] + SessionRecord, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct SubjectDenied { + pub required_operation: RequiredOperation, + pub subject_ref: Option, +} +impl FailureCause for SubjectDenied { + fn identity(&self) -> FailureIdentity { + FailureIdentity { + domain: "subject", + reason: "operation_denied", + category: Category::Authorization, + details_version: 1, + } + } + fn validate(&self) -> Result<(), ContractError> { + if let Some(reference) = &self.subject_ref { + validate_identifier(reference)?; + } + Ok(()) + } +} +impl WireDetails for SubjectDenied { + fn supports(domain: &str, reason: &str, version: u16) -> bool { + domain == "subject" && reason == "operation_denied" && version == 1 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub enum WorkdirOperation { + Open, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct WorkdirInUse { + pub operation: WorkdirOperation, + pub workdir_ref: Option, +} +impl FailureCause for WorkdirInUse { + fn identity(&self) -> FailureIdentity { + FailureIdentity { + domain: "workdir", + reason: "in_use", + category: Category::Conflict, + details_version: 1, + } + } + fn validate(&self) -> Result<(), ContractError> { + if let Some(reference) = &self.workdir_ref { + validate_identifier(reference)?; + } + Ok(()) + } +} +impl WireDetails for WorkdirInUse { + fn supports(domain: &str, reason: &str, version: u16) -> bool { + domain == "workdir" && reason == "in_use" && version == 1 + } +} + +#[derive(Debug, thiserror::Error)] +#[error("internal-source credential-secret /private/worktree raw-http-body")] +pub struct PrivateSource; + +#[derive(Debug, thiserror::Error)] +#[error("subject rejected operation")] +pub struct SubjectError { + pub details: SubjectDenied, + #[source] + pub source: PrivateSource, +} +#[derive(Debug, thiserror::Error)] +#[error("workdir in use")] +pub struct WorkdirError { + pub details: WorkdirInUse, + #[source] + pub source: PrivateSource, +} +macro_rules! cause { + ($name:ty) => { + impl FailureCause for $name { + fn identity(&self) -> FailureIdentity { + self.details.identity() + } + fn validate(&self) -> Result<(), ContractError> { + self.details.validate() + } + } + }; +} +cause!(SubjectError); +cause!(WorkdirError); + +// The domain selects both details and common metadata. No raw source/Display is inspected. +fn allowed_context(context: &FailureContext, access: &CurrentAccess) -> FailureContext { + FailureContext { + operations: context + .operations + .iter() + .filter_map(|operation| { + if !matches!( + operation.operation.as_str(), + "worker.create" | "workdir.open" + ) || !matches!(operation.stage.as_str(), "register" | "prepare") + { + return None; + } + Some(OperationContext { + operation: operation.operation.clone(), + stage: operation.stage.clone(), + operation_ref: access.visible_reference( + operation.operation_ref.as_deref(), + "visible-operation-ref", + ), + }) + }) + .collect(), + // A wire correlation is not a local proof. This fixture boundary knows only these + // two safe occurrence references; production adapters use their current binding. + correlation: context + .correlation + .as_ref() + .filter(|reference| matches!(reference.as_str(), "correlation-1" | "correlation-2")) + .cloned(), + supplementary_omitted: !context.diagnostics.is_empty() + || !context.causes.is_empty() + || context.supplementary_omitted, + ..FailureContext::default() + } +} +fn subject_disclosure( + details: &SubjectDenied, + context: &FailureContext, + access: &CurrentAccess, +) -> Result, ContractError> { + let details = SubjectDenied { + required_operation: details.required_operation, + subject_ref: access.visible_reference(details.subject_ref.as_deref(), "subject-1"), + }; + let explanation = match access.language { + Language::English => compose_explanation( + "session.record is denied.", + "Worker creation cannot register the session", + )?, + Language::Japanese => compose_explanation( + "session.record が拒否されました。", + "Worker作成でSessionを登録できません", + )?, + }; + Ok(Disclosure { + details, + explanation, + context: allowed_context(context, access), + }) +} +fn workdir_disclosure( + details: &WorkdirInUse, + context: &FailureContext, + access: &CurrentAccess, +) -> Disclosure { + let details = WorkdirInUse { + operation: details.operation, + workdir_ref: access.visible_reference(details.workdir_ref.as_deref(), "workdir-1"), + }; + let explanation = match access.language { + Language::English => "Workdir open cannot proceed because the workdir is in use.", + Language::Japanese => "Workdirは使用中のため開けません。", + }; + Disclosure { + details, + explanation: explanation.into(), + context: allowed_context(context, access), + } +} +macro_rules! adapters { + ($($audience:ty),*) => { $( + impl ProjectFor<$audience> for SubjectDenied { + type Authorization = CurrentAccess; type Details = SubjectDenied; + fn disclose(&self, facts: &FailureFacts, access: &CurrentAccess) -> Result, ContractError> { + subject_disclosure(self, facts.context(), access) + } + } + impl ProjectFor<$audience> for WorkdirInUse { + type Authorization = CurrentAccess; type Details = WorkdirInUse; + fn disclose(&self, facts: &FailureFacts, access: &CurrentAccess) -> Result, ContractError> { + Ok(workdir_disclosure(self, facts.context(), access)) + } + } + impl ProjectFor<$audience> for SubjectError { + type Authorization = CurrentAccess; type Details = SubjectDenied; + fn disclose(&self, facts: &FailureFacts, access: &CurrentAccess) -> Result, ContractError> { + subject_disclosure(&self.details, facts.context(), access) + } + } + impl ProjectFor<$audience> for WorkdirError { + type Authorization = CurrentAccess; type Details = WorkdirInUse; + fn disclose(&self, facts: &FailureFacts, access: &CurrentAccess) -> Result, ContractError> { + Ok(workdir_disclosure(&self.details, facts.context(), access)) + } + } + )* }; +} +adapters!(Browser, Tool, Storage, InternalTransfer, Telemetry); + +pub fn subject_facts() -> FailureFacts { + let mut facts = FailureFacts::new(SubjectError { + details: SubjectDenied { + required_operation: RequiredOperation::SessionRecord, + subject_ref: Some("subject-1".into()), + }, + source: PrivateSource, + }) + .unwrap(); + facts + .with_context(OperationContext::new("worker.create", "register").unwrap()) + .unwrap(); + facts + .set_correlation(CorrelationRef::new("correlation-1").unwrap()) + .unwrap(); + facts +} +pub fn workdir_facts() -> FailureFacts { + let mut facts = FailureFacts::new(WorkdirError { + details: WorkdirInUse { + operation: WorkdirOperation::Open, + workdir_ref: Some("workdir-1".into()), + }, + source: PrivateSource, + }) + .unwrap(); + facts + .with_context(OperationContext::new("workdir.open", "prepare").unwrap()) + .unwrap(); + facts + .set_correlation(CorrelationRef::new("correlation-2").unwrap()) + .unwrap(); + facts +} + +/// Inner Subject API vs outer Worker API deliberately choose different HTTP classifications. +pub struct SubjectApi; +pub struct WorkerApi; +pub struct WorkdirApi; +impl crate::problem::ApiBoundary for SubjectApi { + fn classify(&self, _: &SubjectDenied) -> crate::problem::ProblemClass { + crate::problem::ProblemClass::authorization_denied() + } +} +impl crate::problem::ApiBoundary for WorkerApi { + fn classify(&self, _: &SubjectDenied) -> crate::problem::ProblemClass { + crate::problem::ProblemClass::conflict() + } +} +impl crate::problem::ApiBoundary for WorkdirApi { + fn classify(&self, _: &WorkdirInUse) -> crate::problem::ProblemClass { + crate::problem::ProblemClass::conflict() + } +} + +pub fn assert_no_fixture_secrets(bytes: &[u8]) { + let text = std::str::from_utf8(bytes).expect("JSON is UTF-8"); + for secret in FIXTURE_SECRETS { + assert!(!text.contains(secret), "secret escaped the allowlist"); + } +} + +/// Check the field allowlist on the same wire fixture used by HTTP/TS/storage consumers. +pub fn assert_detail_allowlist(bytes: &[u8], allowed: &[&str]) { + let value: serde_json::Value = serde_json::from_slice(bytes).expect("fixture JSON"); + let details = value["details"] + .as_object() + .expect("fixture details object"); + for key in details.keys() { + assert!( + allowed.contains(&key.as_str()), + "unexpected public detail field: {key}" + ); + } +} + +pub fn assert_context_preserves_details( + facts: &mut FailureFacts, + context: OperationContext, +) { + let original = facts.cause().clone(); + facts + .with_context(context) + .expect("fixture context fits budget"); + assert_eq!(facts.cause(), &original); +} + +/// Semantic check shared by Rust/HTTP/storage fixture consumers. Compare typed details, +/// not translated prose or diagnostic order. Reprojection is intentionally a separate step. +pub fn assert_semantic_roundtrip( + bytes: &[u8], + expected: &D, + budget: Budget, +) { + match decode_failure::(bytes, budget) { + DecodeFailure::Known(received) => assert_eq!(received.facts().cause(), expected), + _ => panic!("expected a known validated fixture"), + } +} diff --git a/crates/error-contract/tests/contracts.rs b/crates/error-contract/tests/contracts.rs new file mode 100644 index 000000000..7a4a28a74 --- /dev/null +++ b/crates/error-contract/tests/contracts.rs @@ -0,0 +1,1565 @@ +//! Public contract component tests, run by `cargo test -p error-contract` with default features. +//! Production projection/codecs/RFC adapters consume the shared sample domains and JSON fixtures. +//! Authorization, operation outcomes and side effects are fixtures, not production policy or +//! persistence guarantees. No network, process, external service or test-support feature is needed. + +use error_contract::*; + +#[path = "../src/test_support.rs"] +mod support; + +use error_contract::{codec, problem}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::{cell::Cell, error::Error, rc::Rc}; +use support::*; + +fn budget() -> Budget { + Budget::new(usize::MAX, usize::MAX) +} + +fn access(can_view_resource: bool, language: Language) -> CurrentAccess { + CurrentAccess { + can_view_resource, + language, + } +} + +fn subject_json() -> Value { + serde_json::from_str(SUBJECT_FIXTURE).unwrap() +} + +fn known(bytes: &[u8]) -> ReceivedFailure { + match decode_failure(bytes, budget()) { + DecodeFailure::Known(received) => received, + DecodeFailure::Unsupported(_) => panic!("expected a known failure, got unsupported"), + DecodeFailure::Corrupt(error) => panic!("expected a known failure, got {error:?}"), + } +} + +fn assert_corrupt(bytes: &[u8], expected: ContractError) { + match decode_failure::(bytes, budget()) { + DecodeFailure::Corrupt(error) => assert_eq!(error, expected), + _ => panic!("expected corrupt failure: {expected:?}"), + } +} + +fn assert_subject_value_corrupt(value: &Value, expected: ContractError) { + assert_corrupt::(&serde_json::to_vec(value).unwrap(), expected); +} + +fn assert_unsupported(bytes: &[u8]) { + match decode_failure::(bytes, budget()) { + DecodeFailure::Unsupported(opaque) => assert_eq!(opaque.retained_bytes(), bytes.len()), + _ => panic!("expected opaque unsupported failure"), + } +} + +fn storage_roundtrip(fixture: &str, expected: D, reference_field: &str) +where + D: WireDetails + PartialEq + std::fmt::Debug, + D: ProjectFor, + D: ProjectFor, +{ + assert_semantic_roundtrip(fixture.as_bytes(), &expected, budget()); + let received = known::(fixture.as_bytes()); + let stored = + project_failure::(received.facts(), &access(true, Language::English)).unwrap(); + let encoded = encode_projected_failure(&stored, budget()).unwrap(); + assert_semantic_roundtrip(encoded.as_bytes(), &expected, budget()); + assert_no_fixture_secrets(encoded.as_bytes()); + + let restored = known::(encoded.as_bytes()); + let public = + project_failure::(restored.facts(), &access(false, Language::Japanese)) + .unwrap(); + let public_bytes = encode_projected_failure(&public, budget()).unwrap(); + let public_json: Value = serde_json::from_slice(public_bytes.as_bytes()).unwrap(); + assert!(public_json["details"][reference_field].is_null()); + assert_eq!(public.identity(), stored.identity()); + assert_ne!(public.explanation(), stored.explanation()); + assert_eq!( + restored.facts().cause(), + &expected, + "reprojection must not redact stored facts in place" + ); + assert_no_fixture_secrets(public_bytes.as_bytes()); +} + +#[test] +fn shared_domain_fixtures_roundtrip_and_storage_uses_current_visibility() { + assert_detail_allowlist( + SUBJECT_FIXTURE.as_bytes(), + &["required_operation", "subject_ref"], + ); + assert_detail_allowlist(WORKDIR_FIXTURE.as_bytes(), &["operation", "workdir_ref"]); + let received = known::(SUBJECT_FIXTURE.as_bytes()); + let mut facts = FailureFacts::new(received.facts().cause().clone()).unwrap(); + assert_context_preserves_details( + &mut facts, + OperationContext::new("worker.create", "register").unwrap(), + ); + storage_roundtrip( + SUBJECT_FIXTURE, + SubjectDenied { + required_operation: RequiredOperation::SessionRecord, + subject_ref: Some("subject-1".into()), + }, + "subject_ref", + ); + storage_roundtrip( + WORKDIR_FIXTURE, + WorkdirInUse { + operation: WorkdirOperation::Open, + workdir_ref: Some("workdir-1".into()), + }, + "workdir_ref", + ); +} + +fn assert_source_preserved(facts: &FailureFacts) { + let cause = facts.source().expect("concrete domain cause"); + assert!(cause.downcast_ref::().is_some()); + let mut private = cause.source().expect("original source chain"); + while let Some(source) = private.source() { + private = source; + } + assert!(private.downcast_ref::().is_some()); + for secret in FIXTURE_SECRETS { + assert!( + private.to_string().contains(secret), + "fixture must exercise private source content" + ); + assert!( + !facts.to_string().contains(secret), + "facts Display must not format source" + ); + } +} + +#[test] +fn projection_and_encoding_preserve_both_concrete_source_chains() { + let subject = subject_facts(); + let workdir = workdir_facts(); + assert_source_preserved(&subject); + assert_source_preserved(&workdir); + let auth = access(false, Language::English); + let subject_projection = project_failure::(&subject, &auth).unwrap(); + let workdir_projection = project_failure::(&workdir, &auth).unwrap(); + assert_no_fixture_secrets( + encode_projected_failure(&subject_projection, budget()) + .unwrap() + .as_bytes(), + ); + assert_no_fixture_secrets( + encode_projected_failure(&workdir_projection, budget()) + .unwrap() + .as_bytes(), + ); + assert_source_preserved(&subject); + assert_source_preserved(&workdir); +} + +fn assert_audience_allowlist() +where + SubjectError: ProjectFor, + WorkdirError: ProjectFor, + SubjectDenied: ProjectFor, + WorkdirInUse: ProjectFor, +{ + for visible in [false, true] { + let auth = access(visible, Language::English); + let subject = project_failure::(&subject_facts(), &auth).unwrap(); + let workdir = project_failure::(&workdir_facts(), &auth).unwrap(); + for bytes in [ + encode_projected_failure(&subject, budget()) + .unwrap() + .as_bytes() + .to_vec(), + encode_projected_failure(&workdir, budget()) + .unwrap() + .as_bytes() + .to_vec(), + ] { + assert_no_fixture_secrets(&bytes); + } + + for (fixture, field, is_subject) in [ + (SUBJECT_FIXTURE, "subject_ref", true), + (WORKDIR_FIXTURE, "workdir_ref", false), + ] { + let mut value: Value = serde_json::from_str(fixture).unwrap(); + value["context"]["operations"][0]["operation_ref"] = json!("visible-operation-ref"); + value["context"]["operations"] + .as_array_mut() + .unwrap() + .push(json!({ + "operation": "internal-source", "stage": "raw-http-body", + "operation_ref": "/private/worktree" + })); + value["context"]["diagnostics"] = json!([{ + "code": "internal-source", "explanation": "credential-secret raw-http-body /private/worktree" + }]); + value["context"]["causes"] = json!([{ + "domain": "internal-source", "reason": "credential-secret", "correlation": "raw-http-body" + }]); + let bytes = serde_json::to_vec(&value).unwrap(); + let output = if is_subject { + let received = known::(&bytes); + let projected = project_failure::(received.facts(), &auth).unwrap(); + encode_projected_failure(&projected, budget()) + .unwrap() + .as_bytes() + .to_vec() + } else { + let received = known::(&bytes); + let projected = project_failure::(received.facts(), &auth).unwrap(); + encode_projected_failure(&projected, budget()) + .unwrap() + .as_bytes() + .to_vec() + }; + assert_no_fixture_secrets(&output); + let output: Value = serde_json::from_slice(&output).unwrap(); + assert_eq!(output["details"][field].is_string(), visible); + assert_eq!( + output["context"]["operations"][0]["operation_ref"].is_string(), + visible + ); + assert_eq!(output["context"]["operations"].as_array().unwrap().len(), 1); + assert_eq!(output["context"]["causes"], json!([])); + assert_eq!(output["context"]["diagnostics"], json!([])); + assert_eq!(output["context"]["supplementary_omitted"], true); + if is_subject { + assert_eq!(output["details"]["required_operation"], "session.record"); + assert!( + output["explanation"] + .as_str() + .unwrap() + .contains("session.record") + ); + } + } + } +} + +#[test] +fn every_audience_allowlists_secrets_without_dropping_required_operation() { + assert_audience_allowlist::(); + assert_audience_allowlist::(); + assert_audience_allowlist::(); + assert_audience_allowlist::(); + assert_audience_allowlist::(); +} + +#[test] +fn telemetry_observation_uses_projected_local_context_not_source_text() { + let facts = subject_facts(); + let projected = + project_failure::(&facts, &access(false, Language::Japanese)).unwrap(); + let fields = observation_fields(&projected); + assert_eq!( + (fields.domain, fields.reason), + ("subject", "operation_denied") + ); + assert_eq!( + (fields.operation, fields.stage), + (Some("worker.create"), Some("register")) + ); + assert_eq!(fields.correlation, Some("correlation-1")); + for secret in FIXTURE_SECRETS { + assert!(!format!("{fields:?}").contains(secret)); + } +} + +#[test] +fn explanation_language_and_supplementary_order_do_not_define_semantics() { + let mut value = subject_json(); + value["explanation"] = json!("An unrelated received translation; not an identity source."); + value["context"]["operations"] + .as_array_mut() + .unwrap() + .push(json!({ + "operation": "workdir.open", "stage": "prepare", "operation_ref": null + })); + value["context"]["diagnostics"] = json!([ + {"code": "first", "explanation": "first detail"}, + {"code": "second", "explanation": "second detail"} + ]); + value["context"]["causes"] = json!([ + {"domain": "workdir", "reason": "in_use", "correlation": "cause-1"}, + {"domain": "subject", "reason": "operation_denied", "correlation": "cause-2"} + ]); + let original = known::(&serde_json::to_vec(&value).unwrap()); + for key in ["operations", "diagnostics", "causes"] { + value["context"][key].as_array_mut().unwrap().reverse(); + } + value["explanation"] = json!("別の言語・順序でも理由を推測しない。"); + let reordered = known::(&serde_json::to_vec(&value).unwrap()); + assert_eq!(original.facts().cause(), reordered.facts().cause()); + assert_ne!( + original.facts().context().causes, + reordered.facts().context().causes + ); + let english = + project_failure::(original.facts(), &access(false, Language::English)).unwrap(); + let japanese = + project_failure::(reordered.facts(), &access(false, Language::Japanese)) + .unwrap(); + assert_eq!(english.identity(), japanese.identity()); + assert_eq!(english.details(), japanese.details()); + assert_ne!(english.explanation(), japanese.explanation()); + assert_eq!( + japanese.context().operations, + reordered.facts().context().operations + ); +} + +#[test] +fn byte_limit_is_checked_before_json_parsing_at_32_kib() { + let mut bytes = SUBJECT_FIXTURE.trim().as_bytes().to_vec(); + bytes.resize(MAX_FAILURE_BYTES, b' '); + known::(&bytes); + bytes.push(b' '); + assert_corrupt::(&bytes, ContractError::TooLarge); + + let invalid = vec![b'x'; MAX_FAILURE_BYTES]; + assert_corrupt::(&invalid, ContractError::Invalid); + let invalid = vec![b'x'; MAX_FAILURE_BYTES + 1]; + assert_corrupt::(&invalid, ContractError::TooLarge); +} + +#[test] +fn depth_16_is_allowed_and_17_is_rejected_before_typed_parsing() { + for (arrays, expected_depth) in [ + (MAX_JSON_DEPTH - 2, MAX_JSON_DEPTH), + (MAX_JSON_DEPTH - 1, MAX_JSON_DEPTH + 1), + ] { + let mut nested = json!(null); + for _ in 0..arrays { + nested = json!([nested]); + } + let mut value = subject_json(); + value["reason"] = json!("future_reason"); + value["details"] = json!({"nested": nested}); + let bytes = serde_json::to_vec(&value).unwrap(); + if expected_depth == MAX_JSON_DEPTH { + assert_unsupported(&bytes); + } else { + assert_corrupt::(&bytes, ContractError::TooDeep); + } + } + let mut value = subject_json(); + value["explanation"] = json!("brackets inside escaped text: \\\" [[{{ }}]] \\"); + known::(&serde_json::to_vec(&value).unwrap()); +} + +#[test] +fn transport_and_enclosing_budgets_both_limit_decoding_and_encoding() { + let projected = + project_failure::(&subject_facts(), &access(false, Language::English)).unwrap(); + let encoded = encode_projected_failure(&projected, budget()).unwrap(); + let len = encoded.as_bytes().len(); + for limits in [(len, usize::MAX), (usize::MAX, len)] { + let exact = Budget::new(limits.0, limits.1); + assert!(matches!( + decode_failure::(encoded.as_bytes(), exact), + DecodeFailure::Known(_) + )); + assert!(encode_projected_failure(&projected, exact).is_ok()); + } + for limits in [ + (len - 1, usize::MAX), + (usize::MAX, len - 1), + (0, len), + (len, 0), + ] { + let short = Budget::new(limits.0, limits.1); + assert!(matches!( + decode_failure::(encoded.as_bytes(), short), + DecodeFailure::Corrupt(ContractError::TooLarge) + )); + assert!(matches!( + encode_projected_failure(&projected, short), + Err(ContractError::TooLarge) + )); + } +} + +#[test] +fn transport_and_enclosing_depth_budgets_limit_both_codecs() { + let projected = browser_subject(); + let encoded = encode_projected_failure(&projected, budget()).unwrap(); + // Envelope/context/operations/operation is the deepest path in this fixture. + for (transport, remaining) in [(4, MAX_JSON_DEPTH), (MAX_JSON_DEPTH, 4)] { + let exact = budget().with_depth(transport, remaining); + assert!(encode_projected_failure(&projected, exact).is_ok()); + assert!(matches!( + decode_failure::(encoded.as_bytes(), exact), + DecodeFailure::Known(_) + )); + } + for (transport, remaining) in [(3, MAX_JSON_DEPTH), (MAX_JSON_DEPTH, 3)] { + let short = budget().with_depth(transport, remaining); + assert!(matches!( + encode_projected_failure(&projected, short), + Err(ContractError::TooDeep) + )); + assert!(matches!( + decode_failure::(encoded.as_bytes(), short), + DecodeFailure::Corrupt(ContractError::TooDeep) + )); + } + // RFC's wrapper consumes another depth level, not a fresh independent allowance. + let response = + problem::to_problem_details(&projected, &WorkerApi, "/requests/1", budget()).unwrap(); + let exact = budget().with_depth(5, 5); + assert!(problem::to_problem_details(&projected, &WorkerApi, "/requests/1", exact).is_ok()); + assert!(matches!( + problem::decode_problem_details::( + response.body(), + 409, + response.content_type, + &WorkerApi, + exact + ), + DecodeFailure::Known(_) + )); + let short = budget().with_depth(4, 4); + assert!(matches!( + problem::to_problem_details(&projected, &WorkerApi, "/requests/1", short), + Err(ContractError::TooDeep) + )); + assert!(matches!( + problem::decode_problem_details::( + response.body(), + 409, + response.content_type, + &WorkerApi, + short + ), + DecodeFailure::Corrupt(ContractError::TooDeep) + )); +} + +#[test] +fn collection_limits_accept_exactly_8_contexts_4_causes_8_diagnostics() { + for (field, limit, entry) in [ + ( + "operations", + MAX_CONTEXTS, + json!({"operation": "worker.create", "stage": "register", "operation_ref": null}), + ), + ( + "causes", + MAX_CAUSES, + json!({"domain": "subject", "reason": "operation_denied", "correlation": "cause-1"}), + ), + ( + "diagnostics", + MAX_DIAGNOSTICS, + json!({"code": "diagnostic", "explanation": "safe explanation"}), + ), + ] { + for count in [limit, limit + 1] { + let mut value = subject_json(); + value["context"][field] = Value::Array(vec![entry.clone(); count]); + if count == limit { + let received = known::(&serde_json::to_vec(&value).unwrap()); + let received_context = serde_json::to_value(received.facts().context()).unwrap(); + assert_eq!(received_context[field], value["context"][field]); + } else { + assert_subject_value_corrupt(&value, ContractError::Invalid); + } + } + } +} + +#[test] +fn rejected_context_and_causal_appends_leave_original_facts_intact() { + let mut facts = subject_facts(); + while facts.context().operations.len() < MAX_CONTEXTS { + facts + .with_context(OperationContext::new("worker.create", "register").unwrap()) + .unwrap(); + } + for index in 0..MAX_CAUSES { + facts + .with_causal_link(CausalLink { + domain: "subject".into(), + reason: "operation_denied".into(), + correlation: CorrelationRef::new(format!("cause-{index}")).unwrap(), + }) + .unwrap(); + } + let before = facts.context().clone(); + assert_eq!( + facts.with_context(OperationContext::new("workdir.open", "prepare").unwrap()), + Err(ContractError::Invalid) + ); + assert_eq!( + facts.with_causal_link(before.causes[0].clone()), + Err(ContractError::Invalid) + ); + assert_eq!( + facts.with_context(OperationContext { + operation: "".into(), + stage: "register".into(), + operation_ref: None + }), + Err(ContractError::Invalid) + ); + assert_eq!(facts.context(), &before); + assert_source_preserved(&facts); +} + +#[test] +fn identifiers_are_bounded_by_128_utf8_bytes_at_every_received_position() { + for pointer in [ + "/domain", + "/reason", + "/context/operations/0/operation", + "/context/operations/0/stage", + "/context/operations/0/operation_ref", + "/context/correlation", + "/context/causes/0/domain", + "/context/causes/0/reason", + "/context/causes/0/correlation", + "/context/diagnostics/0/code", + "/required_extensions/0", + "/details/subject_ref", + ] { + for text in [ + "a".repeat(MAX_IDENTIFIER_BYTES), + "é".repeat(MAX_IDENTIFIER_BYTES / 2), + format!("{}a", "é".repeat(MAX_IDENTIFIER_BYTES / 2)), + "".into(), + "bad\nidentifier".into(), + ] { + let mut value = subject_json(); + value["context"]["causes"] = json!([{"domain": "subject", "reason": "operation_denied", "correlation": "cause-1"}]); + value["context"]["diagnostics"] = + json!([{"code": "diagnostic", "explanation": "safe"}]); + if pointer.starts_with("/required_extensions") { + value["required_extensions"] = json!(["future.extension"]); + } + *value.pointer_mut(pointer).unwrap() = json!(text); + let bytes = serde_json::to_vec(&value).unwrap(); + if text.len() == MAX_IDENTIFIER_BYTES { + if matches!(pointer, "/domain" | "/reason" | "/required_extensions/0") { + assert_unsupported(&bytes); + } else { + known::(&bytes); + } + } else { + assert_corrupt::(&bytes, ContractError::Invalid); + } + } + } +} + +#[test] +fn explanation_limits_use_2048_utf8_bytes_and_never_truncate() { + for pointer in ["/explanation", "/context/diagnostics/0/explanation"] { + for text in [ + "a".repeat(MAX_EXPLANATION_BYTES), + "é".repeat(MAX_EXPLANATION_BYTES / 2), + format!("{}a", "é".repeat(MAX_EXPLANATION_BYTES / 2)), + "".into(), + "bad\0explanation".into(), + ] { + let mut value = subject_json(); + value["context"]["diagnostics"] = + json!([{"code": "diagnostic", "explanation": "safe"}]); + *value.pointer_mut(pointer).unwrap() = json!(text); + if text.len() == MAX_EXPLANATION_BYTES { + known::(&serde_json::to_vec(&value).unwrap()); + } else { + assert_subject_value_corrupt(&value, ContractError::Invalid); + } + } + } +} + +#[test] +fn future_versions_reasons_and_required_extensions_remain_opaque() { + for (field, future) in [ + ("version", json!(SCHEMA_VERSION + 1)), + ("details_version", json!(2)), + ("reason", json!("future_reason")), + ("domain", json!("future_domain")), + ("required_extensions", json!(["future.extension"])), + ( + "required_extensions", + json!(vec!["future.extension"; MAX_DIAGNOSTICS]), + ), + ] { + let mut value = subject_json(); + value[field] = future; + // Unknown data must not be interpreted using the currently known typed details. + value["details"] = json!({"future_payload": [1, 2, 3]}); + assert_unsupported(&serde_json::to_vec(&value).unwrap()); + } + let mut value = subject_json(); + value["required_extensions"] = json!(vec!["future.extension"; MAX_DIAGNOSTICS + 1]); + assert_subject_value_corrupt(&value, ContractError::Invalid); +} + +#[test] +fn invalid_known_typed_details_are_corrupt_not_unsupported() { + for details in [ + json!({}), + json!({"required_operation": "session.delete", "subject_ref": null}), + json!({"required_operation": "session.record", "subject_ref": 7}), + json!({"required_operation": "session.record", "subject_ref": null, "credential": "credential-secret"}), + json!(null), + ] { + let mut value = subject_json(); + value["details"] = details; + assert_subject_value_corrupt(&value, ContractError::InvalidDetails); + } + let mut value = subject_json(); + value["category"] = json!("conflict"); + assert_subject_value_corrupt(&value, ContractError::InvalidDetails); + for field in ["version", "details_version"] { + let mut value = subject_json(); + value[field] = json!(0); + assert_subject_value_corrupt(&value, ContractError::Invalid); + } + let mut value: Value = serde_json::from_str(WORKDIR_FIXTURE).unwrap(); + value["details"]["operation"] = json!("delete"); + assert_corrupt::( + &serde_json::to_vec(&value).unwrap(), + ContractError::InvalidDetails, + ); +} + +fn browser_subject() -> ProjectedFailure { + project_failure::(&subject_facts(), &access(false, Language::English)).unwrap() +} + +fn assert_problem_corrupt(value: &Value, status: u16, content_type: &str, expected: ContractError) { + match problem::decode_problem_details::( + &serde_json::to_vec(value).unwrap(), + status, + content_type, + &WorkerApi, + budget(), + ) { + DecodeFailure::Corrupt(error) => assert_eq!(error, expected), + _ => panic!("expected corrupt problem details"), + } +} + +#[test] +fn api_boundary_chooses_inner_403_and_outer_409_without_forwarding_status() { + let projected = browser_subject(); + let inner = + problem::to_problem_details(&projected, &SubjectApi, "/requests/inner", budget()).unwrap(); + let outer = + problem::to_problem_details(&projected, &WorkerApi, "/requests/outer", budget()).unwrap(); + assert_eq!((inner.status, outer.status), (403, 409)); + for response in [&inner, &outer] { + assert_eq!(response.content_type, "application/problem+json"); + let body: Value = serde_json::from_slice(response.body()).unwrap(); + assert_eq!(body["status"], response.status); + assert_eq!(body["detail"], body["yoi_failure"]["explanation"]); + assert_eq!( + body["yoi_failure"]["details"]["required_operation"], + "session.record" + ); + assert_no_fixture_secrets(response.body()); + } + match problem::decode_problem_details::( + inner.body(), + 403, + inner.content_type, + &SubjectApi, + budget(), + ) { + DecodeFailure::Known(received) => assert_eq!(received.facts().cause(), projected.details()), + _ => panic!("inner API contract must roundtrip"), + } + match problem::decode_problem_details::( + outer.body(), + 409, + outer.content_type, + &WorkerApi, + budget(), + ) { + DecodeFailure::Known(received) => assert_eq!(received.facts().cause(), projected.details()), + _ => panic!("outer API contract must roundtrip"), + } + let inner_json: Value = serde_json::from_slice(inner.body()).unwrap(); + assert_problem_corrupt(&inner_json, 403, inner.content_type, ContractError::Invalid); + + let workdir = + project_failure::(&workdir_facts(), &access(false, Language::English)).unwrap(); + let response = + problem::to_problem_details(&workdir, &WorkdirApi, "/requests/workdir", budget()).unwrap(); + assert!(matches!( + problem::decode_problem_details::( + response.body(), + 409, + response.content_type, + &WorkdirApi, + budget() + ), + DecodeFailure::Known(_) + )); +} + +#[test] +fn rfc_extensions_are_ignored_but_status_content_type_and_classification_must_agree() { + let response = + problem::to_problem_details(&browser_subject(), &WorkerApi, "/requests/1", budget()) + .unwrap(); + let mut value: Value = serde_json::from_slice(response.body()).unwrap(); + value["future_rfc_extension"] = json!({"arbitrary": ["ignored", 1]}); + let bytes = serde_json::to_vec(&value).unwrap(); + assert!(matches!( + problem::decode_problem_details::( + &bytes, + 409, + "Application/Problem+Json; charset=utf-8", + &WorkerApi, + budget() + ), + DecodeFailure::Known(_) + )); + for (status, content_type) in [ + (403, "application/problem+json"), + (409, "application/json"), + (409, "text/plain"), + ] { + assert_problem_corrupt(&value, status, content_type, ContractError::Invalid); + } + for (pointer, replacement) in [ + ("/status", json!(200)), + ("/status", json!(403)), + ("/type", json!("urn:yoi:problem:authorization-denied")), + ("/title", json!("Authorization denied")), + ("/detail", json!("disagrees with failure explanation")), + ("/instance", json!("not a URI reference")), + ] { + let mut corrupt = value.clone(); + *corrupt.pointer_mut(pointer).unwrap() = replacement; + assert_problem_corrupt( + &corrupt, + 409, + "application/problem+json", + ContractError::Invalid, + ); + } + let mut unknown = value.clone(); + unknown["yoi_failure"]["required_extensions"] = json!(["future.extension"]); + assert!(matches!( + problem::decode_problem_details::( + &serde_json::to_vec(&unknown).unwrap(), + 409, + response.content_type, + &WorkerApi, + budget() + ), + DecodeFailure::Unsupported(_) + )); + let mut missing = value; + missing.as_object_mut().unwrap().remove("yoi_failure"); + assert_problem_corrupt(&missing, 409, response.content_type, ContractError::Invalid); +} + +#[test] +fn problem_encoding_and_decoding_obey_the_remaining_transport_budget() { + let projected = browser_subject(); + let response = + problem::to_problem_details(&projected, &WorkerApi, "/requests/1", budget()).unwrap(); + let len = response.body().len(); + for limits in [(len, usize::MAX), (usize::MAX, len)] { + let exact = Budget::new(limits.0, limits.1); + assert!(problem::to_problem_details(&projected, &WorkerApi, "/requests/1", exact).is_ok()); + assert!(matches!( + problem::decode_problem_details::( + response.body(), + 409, + response.content_type, + &WorkerApi, + exact + ), + DecodeFailure::Known(_) + )); + } + for limits in [(len - 1, usize::MAX), (usize::MAX, len - 1)] { + let short = Budget::new(limits.0, limits.1); + assert!(matches!( + problem::to_problem_details(&projected, &WorkerApi, "/requests/1", short), + Err(ContractError::TooLarge) + )); + assert!(matches!( + problem::decode_problem_details::( + response.body(), + 409, + response.content_type, + &WorkerApi, + short + ), + DecodeFailure::Corrupt(ContractError::TooLarge) + )); + } +} + +#[test] +fn duplicate_envelope_context_and_typed_detail_fields_are_rejected() { + let input = SUBJECT_FIXTURE.trim(); + for (needle, duplicate, expected) in [ + ( + "\"version\":1", + "\"version\":1,\"version\":1", + ContractError::Invalid, + ), + ( + "\"required_extensions\":[]", + "\"required_extensions\":[],\"required_extensions\":[]", + ContractError::Invalid, + ), + ( + "\"diagnostics\":[]", + "\"diagnostics\":[],\"diagnostics\":[]", + ContractError::Invalid, + ), + ( + "\"reason\":\"operation_denied\"", + "\"reason\":\"operation_denied\",\"reason\":\"operation_denied\"", + ContractError::Invalid, + ), + ( + "\"stage\":\"register\"", + "\"stage\":\"register\",\"stage\":\"register\"", + ContractError::Invalid, + ), + ( + "\"required_operation\":\"session.record\"", + "\"required_operation\":\"session.record\",\"required_operation\":\"session.record\"", + ContractError::InvalidDetails, + ), + ] { + assert!(input.contains(needle)); + assert_corrupt::(input.replacen(needle, duplicate, 1).as_bytes(), expected); + } + let input = WORKDIR_FIXTURE.replace( + "\"operation\":\"open\"", + "\"operation\":\"open\",\"operation\":\"open\"", + ); + assert_corrupt::(input.as_bytes(), ContractError::InvalidDetails); +} + +#[test] +fn duplicate_problem_fields_and_nested_typed_fields_are_rejected() { + let response = + problem::to_problem_details(&browser_subject(), &WorkerApi, "/requests/1", budget()) + .unwrap(); + let input = std::str::from_utf8(response.body()).unwrap(); + for (needle, duplicate, expected) in [ + ( + "\"status\":409", + "\"status\":409,\"status\":409", + ContractError::Invalid, + ), + ( + "\"required_operation\":\"session.record\"", + "\"required_operation\":\"session.record\",\"required_operation\":\"session.record\"", + ContractError::InvalidDetails, + ), + ] { + assert!(input.contains(needle)); + let bytes = input.replacen(needle, duplicate, 1); + match problem::decode_problem_details::( + bytes.as_bytes(), + 409, + response.content_type, + &WorkerApi, + budget(), + ) { + DecodeFailure::Corrupt(error) => assert_eq!(error, expected), + _ => panic!("duplicate fields must not be normalized away"), + } + } +} + +struct DisclosureFixture { + explanation: String, + context: FailureContext, + details: SubjectDenied, + replace_identity: bool, +} + +impl FailureCause for DisclosureFixture { + fn identity(&self) -> FailureIdentity { + if self.replace_identity { + WorkdirInUse { + operation: WorkdirOperation::Open, + workdir_ref: None, + } + .identity() + } else { + self.details.identity() + } + } + + fn validate(&self) -> Result<(), ContractError> { + Ok(()) + } +} + +impl ProjectFor for DisclosureFixture { + type Authorization = (); + type Details = SubjectDenied; + + fn disclose( + &self, + _: &FailureFacts, + _: &(), + ) -> Result, ContractError> { + Ok(Disclosure { + details: self.details.clone(), + explanation: self.explanation.clone(), + context: self.context.clone(), + }) + } +} + +fn disclosure_fixture() -> DisclosureFixture { + DisclosureFixture { + explanation: "safe explanation".into(), + context: FailureContext::default(), + details: SubjectDenied { + required_operation: RequiredOperation::SessionRecord, + subject_ref: None, + }, + replace_identity: false, + } +} + +#[test] +fn domain_projection_cannot_change_identity_or_bypass_disclosure_validation() { + for case in [ + "identity", + "details", + "explanation", + "context", + "causes", + "diagnostics", + ] { + let mut fixture = disclosure_fixture(); + match case { + "identity" => fixture.replace_identity = true, + "details" => { + fixture.details.subject_ref = Some("é".repeat(MAX_IDENTIFIER_BYTES / 2 + 1)) + } + "explanation" => fixture.explanation = "é".repeat(MAX_EXPLANATION_BYTES / 2 + 1), + "context" => { + fixture.context.operations = vec![ + OperationContext::new("worker.create", "register") + .unwrap(); + MAX_CONTEXTS + 1 + ] + } + "causes" => { + fixture.context.causes = vec![ + CausalLink { + domain: "subject".into(), + reason: "operation_denied".into(), + correlation: CorrelationRef::new("safe-correlation").unwrap(), + }; + MAX_CAUSES + 1 + ] + } + "diagnostics" => { + fixture.context.diagnostics = vec![ + Diagnostic { + code: "safe".into(), + explanation: "safe explanation".into(), + }; + MAX_DIAGNOSTICS + 1 + ] + } + _ => unreachable!(), + } + let facts = FailureFacts::new(fixture).unwrap(); + let expected = if case == "identity" { + ContractError::InvalidDetails + } else { + ContractError::Invalid + }; + assert!( + matches!(project_failure::(&facts, &()), Err(error) if error == expected), + "case {case}" + ); + } + let mut fixture = disclosure_fixture(); + fixture.explanation = "é".repeat(MAX_EXPLANATION_BYTES / 2); + fixture.details.subject_ref = Some("é".repeat(MAX_IDENTIFIER_BYTES / 2)); + let facts = FailureFacts::new(fixture).unwrap(); + let projected = project_failure::(&facts, &()).unwrap(); + let encoded = encode_projected_failure(&projected, budget()).unwrap(); + assert_semantic_roundtrip(encoded.as_bytes(), projected.details(), budget()); + let value: Value = serde_json::from_slice(encoded.as_bytes()).unwrap(); + assert_eq!(value["explanation"], projected.explanation()); +} + +#[test] +fn json_escaping_cannot_exceed_32_kib_or_silently_drop_required_details() { + let mut fixture = disclosure_fixture(); + fixture.context.diagnostics = vec![ + Diagnostic { + code: "safe".into(), + explanation: "\u{1}".repeat(MAX_EXPLANATION_BYTES), + }; + MAX_DIAGNOSTICS + ]; + let facts = FailureFacts::new(fixture).unwrap(); + let projected = project_failure::(&facts, &()).unwrap(); + assert!(matches!( + encode_projected_failure(&projected, budget()), + Err(ContractError::TooLarge) + )); + let fallback = codec::encode_or_fallback(Ok(&projected), budget(), None).unwrap(); + assert_eq!(fallback.conversion_error, Some(ContractError::TooLarge)); + assert_eq!(fallback.encoded.as_bytes(), codec::STATIC_FALLBACK); + assert_eq!( + projected.context().diagnostics, + facts.cause().context.diagnostics + ); +} + +#[derive(Deserialize)] +#[serde(transparent)] +struct UnencodableDetails { + details: SubjectDenied, + #[serde(skip)] + attempts: Rc>, +} + +impl Serialize for UnencodableDetails { + fn serialize(&self, _: S) -> Result { + self.attempts.set(self.attempts.get() + 1); + Err(serde::ser::Error::custom( + "internal-source credential-secret raw-http-body /private/worktree", + )) + } +} + +impl FailureCause for UnencodableDetails { + fn identity(&self) -> FailureIdentity { + self.details.identity() + } + fn validate(&self) -> Result<(), ContractError> { + self.details.validate() + } +} + +impl WireDetails for UnencodableDetails { + fn supports(domain: &str, reason: &str, version: u16) -> bool { + SubjectDenied::supports(domain, reason, version) + } +} + +#[derive(Debug, thiserror::Error)] +#[error("fixture operation denied")] +struct BrokenAdapter { + #[source] + source: SubjectError, + projection_fails: bool, + projection_attempts: Rc>, + encoding_attempts: Rc>, +} + +impl FailureCause for BrokenAdapter { + fn identity(&self) -> FailureIdentity { + self.source.identity() + } + fn validate(&self) -> Result<(), ContractError> { + self.source.validate() + } +} + +impl ProjectFor for BrokenAdapter { + type Authorization = (); + type Details = UnencodableDetails; + fn disclose( + &self, + _: &FailureFacts, + _: &(), + ) -> Result, ContractError> { + self.projection_attempts + .set(self.projection_attempts.get() + 1); + if self.projection_fails { + Err(ContractError::ProjectionUnavailable) + } else { + Ok(Disclosure::new( + UnencodableDetails { + details: self.source.details.clone(), + attempts: self.encoding_attempts.clone(), + }, + "session.record denied", + )) + } + } +} + +#[test] +fn conversion_failures_fall_back_without_changing_operation_or_secondary_results() { + // These are domain-owned results, deliberately outside the failure contract's causal chain. + struct OperationFixture { + primary: Result<&'static str, FailureFacts>, + committed_effects: Vec<&'static str>, + cleanup: Result<(), &'static str>, + audit: Result<(), &'static str>, + delivery: Result<(), &'static str>, + } + for projection_fails in [true, false] { + let projection_attempts = Rc::new(Cell::new(0)); + let encoding_attempts = Rc::new(Cell::new(0)); + let mut primary = FailureFacts::new(BrokenAdapter { + source: SubjectError { + details: subject_facts().cause().details.clone(), + source: PrivateSource, + }, + projection_fails, + projection_attempts: projection_attempts.clone(), + encoding_attempts: encoding_attempts.clone(), + }) + .unwrap(); + primary + .with_context(OperationContext::new("worker.create", "register").unwrap()) + .unwrap(); + primary + .set_correlation(CorrelationRef::new("safe-correlation").unwrap()) + .unwrap(); + let operation = OperationFixture { + primary: Err(primary), + committed_effects: vec!["session-created"], + cleanup: Err("cleanup-failed"), + audit: Err("audit-failed"), + delivery: Err("response-lost"), + }; + let primary = operation.primary.as_ref().unwrap_err(); + let original_context = primary.context().clone(); + let projection = project_failure::(primary, &()); + let encoded = codec::encode_or_fallback( + projection.as_ref().map_err(|error| *error), + budget(), + primary.context().correlation.clone(), + ) + .unwrap(); + assert_eq!(encoded.encoded.as_bytes(), codec::STATIC_FALLBACK); + assert_eq!( + encoded.conversion_error, + Some(if projection_fails { + ContractError::ProjectionUnavailable + } else { + ContractError::Encoding + }) + ); + assert_eq!(encoded.correlation, primary.context().correlation); + assert_no_fixture_secrets(encoded.encoded.as_bytes()); + assert_eq!( + projection_attempts.get(), + 1, + "fallback must not recursively reproject" + ); + assert_eq!( + encoding_attempts.get(), + usize::from(!projection_fails), + "fallback must not retry serialization" + ); + assert_eq!(operation.committed_effects, ["session-created"]); + assert_eq!( + (operation.cleanup, operation.audit, operation.delivery), + ( + Err("cleanup-failed"), + Err("audit-failed"), + Err("response-lost") + ) + ); + assert_eq!(primary.context(), &original_context); + assert!( + primary.context().causes.is_empty(), + "secondary failures are not replacement root causes" + ); + assert_source_preserved(primary); + } +} + +#[test] +fn fallback_and_response_loss_do_not_turn_committed_success_into_failure() { + let primary = Ok::<_, &'static str>("resource-created"); + let committed_effects = vec!["resource-created"]; + let delivery = Err::<(), _>("response-lost"); + let boundary = codec::encode_or_fallback::( + Err(ContractError::ProjectionUnavailable), + budget(), + None, + ) + .unwrap(); + assert_eq!(boundary.encoded.as_bytes(), codec::STATIC_FALLBACK); + assert_eq!(primary, Ok("resource-created")); + assert_eq!(committed_effects, ["resource-created"]); + assert_eq!(delivery, Err("response-lost")); +} + +#[test] +fn static_fallbacks_cannot_bypass_an_exhausted_enclosing_depth_budget() { + for (transport, remaining) in [(1, MAX_JSON_DEPTH), (MAX_JSON_DEPTH, 1)] { + let exact = budget().with_depth(transport, remaining); + let encoded = codec::encode_or_fallback::( + Err(ContractError::ProjectionUnavailable), + exact, + None, + ) + .unwrap(); + assert_eq!(encoded.encoded.as_bytes(), codec::STATIC_FALLBACK); + assert!(problem::problem_fallback(exact).is_ok()); + } + for (transport, remaining) in [(0, MAX_JSON_DEPTH), (MAX_JSON_DEPTH, 0)] { + let exhausted = budget().with_depth(transport, remaining); + assert!(matches!( + codec::encode_or_fallback::( + Err(ContractError::ProjectionUnavailable), + exhausted, + None, + ), + Err(ContractError::TooDeep) + )); + assert!(matches!( + problem::problem_fallback(exhausted), + Err(ContractError::TooDeep) + )); + } +} + +#[test] +fn fallback_is_static_and_fails_closed_when_even_its_exact_budget_does_not_fit() { + let projected = browser_subject(); + let ordinary = codec::encode_or_fallback(Ok(&projected), budget(), None).unwrap(); + assert!(ordinary.conversion_error.is_none()); + assert_semantic_roundtrip(ordinary.encoded.as_bytes(), projected.details(), budget()); + for limits in [ + (codec::STATIC_FALLBACK.len(), usize::MAX), + (usize::MAX, codec::STATIC_FALLBACK.len()), + ] { + let exact = Budget::new(limits.0, limits.1); + let result = codec::encode_or_fallback( + Ok(&projected), + exact, + Some(CorrelationRef::new("safe-correlation").unwrap()), + ) + .unwrap(); + assert_eq!(result.encoded.as_bytes(), codec::STATIC_FALLBACK); + assert_eq!(result.conversion_error, Some(ContractError::TooLarge)); + assert!( + !std::str::from_utf8(result.encoded.as_bytes()) + .unwrap() + .contains("safe-correlation") + ); + } + for limits in [ + (codec::STATIC_FALLBACK.len() - 1, usize::MAX), + (usize::MAX, codec::STATIC_FALLBACK.len() - 1), + ] { + assert!(matches!( + codec::encode_or_fallback::( + Err(ContractError::ProjectionUnavailable), + Budget::new(limits.0, limits.1), + None + ), + Err(ContractError::TooLarge) + )); + } + let len = problem::STATIC_PROBLEM_FALLBACK.len(); + for limits in [(len, usize::MAX), (usize::MAX, len)] { + let response = problem::problem_fallback(Budget::new(limits.0, limits.1)).unwrap(); + assert_eq!(response.status, 500); + assert_eq!(response.content_type, "application/problem+json"); + assert_eq!(response.body(), problem::STATIC_PROBLEM_FALLBACK); + assert_no_fixture_secrets(response.body()); + } + assert!(matches!( + problem::problem_fallback(Budget::new(len - 1, len)), + Err(ContractError::TooLarge) + )); + assert!(matches!( + problem::problem_fallback(Budget::new(len, len - 1)), + Err(ContractError::TooLarge) + )); +} + +#[test] +fn reader_first_attachment_distinguishes_absence_from_corruption_without_parsing_legacy_text() { + use error_contract::compatibility::*; + let legacy = + json!({"message": "403 session.record unknown", "diagnostics": ["arbitrary old text"]}); + assert!(legacy.get("failure").is_none()); + assert!(matches!( + read_failure_attachment::(None, budget()), + StoredFailureRead::LegacyWithoutFacts + )); + assert!(matches!( + read_failure_attachment::(Some(b"old arbitrary error text"), budget()), + StoredFailureRead::Structured(DecodeFailure::Corrupt(_)) + )); + assert!(matches!( + read_failure_attachment::(Some(SUBJECT_FIXTURE.as_bytes()), budget()), + StoredFailureRead::Structured(DecodeFailure::Known(_)) + )); + let mut future = subject_json(); + future["version"] = json!(2); + future["category"] = json!("future_category"); + assert_unsupported(&serde_json::to_vec(&future).unwrap()); + let mut future_problem: Value = serde_json::from_slice( + problem::to_problem_details( + &browser_subject(), + &WorkerApi, + "urn:yoi:occurrence:test", + budget(), + ) + .unwrap() + .body(), + ) + .unwrap(); + future_problem["yoi_failure"] = future; + assert!(matches!( + problem::decode_problem_details::( + &serde_json::to_vec(&future_problem).unwrap(), + 409, + "application/problem+json", + &WorkerApi, + budget() + ), + DecodeFailure::Unsupported(_) + )); +} + +#[test] +fn untrusted_wire_references_and_correlations_are_not_local_authority_even_when_visible() { + let mut wire = subject_json(); + wire["details"]["subject_ref"] = json!("credential-secret"); + wire["context"]["correlation"] = json!("/private/worktree"); + wire["context"]["operations"][0]["operation_ref"] = json!("raw-http-body"); + let received = known::(&serde_json::to_vec(&wire).unwrap()); + let auth = access(true, Language::English); + let projected = project_failure::(received.facts(), &auth).unwrap(); + let bytes = encode_projected_failure(&projected, budget()).unwrap(); + assert_no_fixture_secrets(bytes.as_bytes()); + assert!(projected.details().subject_ref.is_none()); + assert!(projected.context().correlation.is_none()); + assert!(projected.context().operations[0].operation_ref.is_none()); + assert_eq!( + projected.details().required_operation, + RequiredOperation::SessionRecord + ); +} + +#[test] +fn explanation_composition_and_unknown_local_presentation_keep_required_information_bounded() { + let explanation = compose_explanation("session.record denied", "Worker cannot start").unwrap(); + assert!(explanation.contains("session.record")); + assert!(compose_explanation(&"x".repeat(MAX_EXPLANATION_BYTES), "impact").is_err()); + let presentation = unavailable_presentation( + "register", + Some(CorrelationRef::new("correlation-1").unwrap()), + ) + .unwrap(); + assert_eq!(presentation.stage, "register"); + assert_eq!(presentation.correlation.unwrap().as_str(), "correlation-1"); +} + +#[cfg(feature = "json-schema")] +#[test] +fn schemas_derive_typed_details_and_shared_context_bounds_from_the_same_wire_types() { + let subject = + serde_json::to_value(error_contract::schema::failure_schema::()).unwrap(); + let workdir = + serde_json::to_value(error_contract::schema::problem_schema::()).unwrap(); + assert_eq!(subject["properties"]["version"]["minimum"], 1); + assert_eq!( + subject["$defs"]["FailureContext"]["properties"]["operations"]["maxItems"], + MAX_CONTEXTS + ); + assert_eq!( + subject["$defs"]["SubjectDenied"]["additionalProperties"], + false + ); + assert_eq!( + subject["$defs"]["RequiredOperation"]["enum"], + json!(["session.record"]) + ); + assert!(workdir["$defs"]["WorkdirInUse"]["properties"]["operation"].is_object()); +} + +struct UriBoundary(&'static str); +impl problem::ApiBoundary for UriBoundary { + fn classify(&self, _: &SubjectDenied) -> problem::ProblemClass { + problem::ProblemClass { + status: problem::HttpFailureStatus::Conflict, + type_uri: self.0, + title: "Operation conflict", + } + } +} + +#[test] +fn rfc9457_uri_references_accept_relative_absolute_and_component_syntax_in_both_directions() { + let projected = browser_subject(); + // RFC 3986 section 5.4 references, plus authorities and escaped delimiters. Syntax only: + // no base resolution, normalization, DNS, HTTP scheme restriction or implicit execution. + for reference in [ + "../requests/1", + "?occurrence=1", + "", + "g:h", + "g", + "./g", + "g/", + "/g", + "//g", + "?y", + "g?y", + "#s", + "g#s", + "g?y#s", + ";x", + "g;x", + "g;x?y#s", + ".", + "./", + "..", + "../", + "../g", + "../..", + "../../", + "../../g", + "a/b:c", + "a//b", + "///path", + "?", + "#", + "urn:yoi:occurrence:one", + "mailto:user@example.com", + "https://user:pass@example.test:443/a?b#c", + "//[2001:db8::1]:443/a", + "//[::ffff:192.0.2.128]/", + "//[v1.a:b]/x", + "//host:/", + "a%20b/%5B?x=%25&y=?/#%2f", + "http://host/path??query#fragment?with/slash", + ] { + let boundary = UriBoundary(reference); + let response = problem::to_problem_details(&projected, &boundary, reference, budget()) + .unwrap_or_else(|error| panic!("valid reference {reference:?}: {error}")); + let wire: Value = serde_json::from_slice(response.body()).unwrap(); + assert_eq!(wire["type"], reference); + assert_eq!( + wire["instance"], reference, + "syntax validation must not normalize the reference" + ); + assert!( + matches!( + problem::decode_problem_details::( + response.body(), + response.status, + response.content_type, + &boundary, + budget() + ), + DecodeFailure::Known(_) + ), + "{reference:?}" + ); + } + let exact = format!("/{}", "a".repeat(MAX_EXPLANATION_BYTES - 1)); + assert!(problem::to_problem_details(&projected, &WorkerApi, &exact, budget()).is_ok()); + assert!(problem::to_problem_details(&projected, &WorkerApi, &(exact + "a"), budget()).is_err()); +} + +#[test] +fn rfc9457_uri_references_reject_malformed_components_in_both_directions() { + let projected = browser_subject(); + let response = + problem::to_problem_details(&projected, &WorkerApi, "urn:yoi:occurrence:test", budget()) + .unwrap(); + let original: Value = serde_json::from_slice(response.body()).unwrap(); + for reference in [ + "/requests/%GG", + "/requests/[invalid", + "foo%", + "foo%2", + "?%GG", + "#%GG", + "//user@@host/x", + "//host:wrongport/x", + "//[::not-ipv6]/", + "//[v.a]/", + "//[vG.foo]/", + "//[v1.]/", + "//[::1]tail/", + "//[::1", + "//host]/", + "bad space", + "1invalid:rest", + ":rest", + "a{b", + "a#b#c", + "a\\b", + "日本語", + "/a\n", + "?x=\0", + ] { + let invalid_class = UriBoundary(reference); + assert!( + problem::to_problem_details( + &projected, + &invalid_class, + "urn:yoi:occurrence:test", + budget() + ) + .is_err(), + "type {reference:?}" + ); + assert!( + problem::to_problem_details(&projected, &WorkerApi, reference, budget()).is_err(), + "instance {reference:?}" + ); + let mut instance_wire = original.clone(); + instance_wire["instance"] = json!(reference); + assert!( + matches!( + problem::decode_problem_details::( + &serde_json::to_vec(&instance_wire).unwrap(), + 409, + problem::PROBLEM_CONTENT_TYPE, + &WorkerApi, + budget() + ), + DecodeFailure::Corrupt(_) + ), + "received instance {reference:?}" + ); + let mut type_wire = original.clone(); + type_wire["type"] = json!(reference); + // Use a matching invalid mapping too, so a classification mismatch cannot mask a + // syntax-validation bypass on receive. + assert!( + matches!( + problem::decode_problem_details::( + &serde_json::to_vec(&type_wire).unwrap(), + 409, + problem::PROBLEM_CONTENT_TYPE, + &invalid_class, + budget() + ), + DecodeFailure::Corrupt(_) + ), + "received type {reference:?}" + ); + } +} diff --git a/crates/protocol/Cargo.toml b/crates/protocol/Cargo.toml index f0f705d35..3e8432567 100644 --- a/crates/protocol/Cargo.toml +++ b/crates/protocol/Cargo.toml @@ -7,10 +7,11 @@ license.workspace = true [features] default = ["stream"] stream = ["dep:tokio"] -typescript = ["dep:ts-rs", "json-schema"] -json-schema = ["dep:schemars"] +typescript = ["dep:ts-rs", "json-schema", "error-contract/typescript"] +json-schema = ["dep:schemars", "error-contract/json-schema"] [dependencies] +error-contract.workspace = true schemars = { workspace = true, optional = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/crates/protocol/examples/generate_typescript.rs b/crates/protocol/examples/generate_typescript.rs index dd90ec0db..87eff54dc 100644 --- a/crates/protocol/examples/generate_typescript.rs +++ b/crates/protocol/examples/generate_typescript.rs @@ -3,7 +3,7 @@ fn main() -> Result<(), Box> { let check = std::env::args() .skip(1) .any(|argument| argument == "--check"); - let outputs = [ + let mut outputs = vec![ ( protocol::typescript::generated_typescript_path(), protocol::typescript::generated_protocol_types(), @@ -13,6 +13,7 @@ fn main() -> Result<(), Box> { protocol::typescript::generated_protocol_validator(), ), ]; + outputs.extend(protocol::typescript::generated_failure_contract_artifacts()); for (path, expected) in outputs { if check { diff --git a/crates/protocol/src/typescript.rs b/crates/protocol/src/typescript.rs index 192cf2a41..1fc420916 100644 --- a/crates/protocol/src/typescript.rs +++ b/crates/protocol/src/typescript.rs @@ -175,6 +175,42 @@ pub fn generated_protocol_types() -> String { normalize_typescript(output) } +/// Shared failure artifacts use the existing protocol generator. They do not change any +/// production Worker/HTTP payload; those boundaries opt in with domain-specific details. +pub fn generated_failure_contract_artifacts() -> Vec<(PathBuf, String)> { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let types = format!( + "// @generated by `cargo run -p protocol --example generate_typescript --features typescript`\n// Untrusted wire DTOs; validation and current-context reprojection are required.\n\nexport const FAILURE_LIMITS = {{ bytes: {}, depth: {}, contexts: {}, causes: {}, diagnostics: {}, explanationBytes: {}, identifierBytes: {} }} as const;\n\n{}", + error_contract::MAX_FAILURE_BYTES, + error_contract::MAX_JSON_DEPTH, + error_contract::MAX_CONTEXTS, + error_contract::MAX_CAUSES, + error_contract::MAX_DIAGNOSTICS, + error_contract::MAX_EXPLANATION_BYTES, + error_contract::MAX_IDENTIFIER_BYTES, + error_contract::schema::typescript_declarations(), + ); + let render = |schema: schemars::Schema| { + let mut text = serde_json::to_string_pretty(&schema).expect("failure schema serializes"); + text.push('\n'); + text + }; + vec![ + ( + root.join("web/workspace/src/lib/generated/error-contract.ts"), + normalize_typescript(types), + ), + ( + root.join("openapi/failure.schema.json"), + render(error_contract::schema::failure_schema::()), + ), + ( + root.join("openapi/problem-details.schema.json"), + render(error_contract::schema::problem_schema::()), + ), + ] +} + /// Generates the Browser receive validator paired with TypeScript declarations. /// /// Normalizes schemas reachable from [`SubscriptionFrame`] to Rust's actual wire shape. @@ -599,6 +635,18 @@ mod tests { close_object_shapes(&mut unsupported); } + #[test] + fn shared_failure_artifacts_are_current() { + for (path, expected) in generated_failure_contract_artifacts() { + let actual = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("{}: {error}", path.display())); + assert_eq!( + actual, expected, + "regenerate with protocol generate_typescript" + ); + } + } + #[test] fn generated_protocol_types_are_current() { let expected = generated_protocol_types(); diff --git a/docs/report/2026-10-31-t810-error-contract-migration.md b/docs/report/2026-10-31-t810-error-contract-migration.md new file mode 100644 index 000000000..4a2cd10b3 --- /dev/null +++ b/docs/report/2026-10-31-t810-error-contract-migration.md @@ -0,0 +1,47 @@ +# T-810 共通エラー契約の移行棚卸し(2026-10-31) + +本記録は `develop@d0f22d871ab3403943d6bcfc71bfa5a0e1f01614`(tree `d3a114568adf8d10fb8e784870efa3537092ba4c`)を対象にした棚卸しであり、本番移行済み一覧ではない。T-810は共通utilityと契約fixtureを提供し、下記の本番接続はT-811〜815に残す。旧基点 `e8477882b` からの更新は専用work branchでfast-forwardし、他担当のclone・permissions変更・稼働Server/Runtimeを変更していない。 + +## 残存callsiteと所有境界 + +| 所有 | 観測した入口・現契約 | 残す理由と移行時に守る点 | +| --- | --- | --- | +| T-811 | `workspace-server/src/server/subject_authorization.rs::authorize_scope` と `server.rs::scoped_record_subjektiv_session` の `session.record` 拒否 | 原因発生domainでoperationとresourceを型付きにする。bind前の認可順序・policyを共通core導入で変えない。 | +| T-811 | `worker/src/worker.rs::finalize_subjektiv_session_attribution` のHTTP status/body文字列化、Host分岐の `to_string()`、`worker/src/subjektiv.rs` のHost error変換 | attributionの既存永続結果・受理不明と原因を分離する。raw bodyを共通detailsに格納しない。HTTP直結だけでなくHost経由も対象。 | +| T-811 | `worker-runtime/src/worker_backend.rs::spawn_controller` のbootstrap/Session attributionの `Result<_, String>` | 原因にcontextを加え、起動結果・cleanup結果を別に保つ。 | +| T-812 | `workspace-server/src/hosts.rs` の `embedded_worker_execution_rejected`、embeddedメッセージの語/path redaction、remote診断変換 | Warning/severity・文言から原因を再構成せず、同じtyped causeを認可済みaudienceへ投影する。 | +| T-812 | `workspace-server/src/server.rs` の `workspace_worker_create_not_accepted` / `workspace_worker_create_failed` と `sanitize_backend_error`、Runtime `http_server.rs` の手書きJSON/status・fingerprint文言分類 | 外側操作結果を保持し、内側403の単純転送も一律502も避ける。HTTP境界でtyped mappingと既存tracing sinkに接続する。 | +| T-813 | `web/workspace/src/lib/workspace/api/{http,workers}.ts` の汎用Error/既存 `error,message,diagnostics` parser | 生成型だけでは受信検証・権限を保証しない。bounded parserで未知と破損を区別する。 | +| T-813 | `sidebar/worker-launch.ts::workerLaunchOutcomeUnknown` のregex、Worker新規画面の文字列合成、`sidebar/worker-actions.ts` のseverity優先表示 | regex・診断順は原因/操作結果の正本ではない。一方restoreの既存typed state/status判定は失わせない。 | +| T-814 | `agen/src/engine.rs` の `ToolResult::error(error.to_string())`、`worker/src/wip.rs` の結果encode失敗、`feature/builtin/ticket.rs` のHTTP文言再解析 | Tool dispatchと実行失敗、実行後応答喪失、cancel/unknownを区別する。共通codecは実行/再送をしない。 | +| T-814 | Worker `LogEntry::RunErrored` / controller `Event::Error`、Job/Serviceの保存・通知・照会境界 | 履歴とlive通知の正本を一組で移行する。監査・通知・sink失敗を元結果やsourceに偽装しない。 | +| T-815 | `workdir/src/lib.rs::{WorkdirDenialReason,WorkdirFailureReason,WorkdirFailureStage}`、Repository手書きHTTP応答、`workspace-drive/src/lib.rs::Error`、`config-source/src/lib.rs::ConfigTreeError` | 既存typed reason・Unknown・補償を再実装せずadapterに接続する。configの構文診断等を機械的にすべて禁止しない。 | + +`to_string()`全廃lintは追加しない。型付き数値の表示、構文診断、sourceを保持した内部Displayなどまで禁止すると、実行結果や公開可否との境界を逆に失うためである。 + +## transport照合 + +| 既存source | 観測上限・注意 | 接続時の扱い | +| --- | --- | --- | +| `api-macros/src/reqwest.rs::DEFAULT_RESPONSE_BODY_LIMIT` | HTTP応答既定1MiB、route別overrideあり | failure単体上限と外枠残budgetの小さい方。bounded body readerを経ずに全bodyを先に読む実装へ戻さない。 | +| `workspace-server/src/hosts.rs` Runtime ping client | 8KiB | 最大32KiB failureはそのまま送れない。必須fieldを切り詰めず変換失敗/fallbackを分離。 | +| `protocol/src/subscription.rs` | frame16MiB/depth64、拒否message1KiB | failure自身の32KiB/depth16も検査。message文字列を構造化failureの格納先として流用しない。 | +| `protocol/src/stream.rs` | JSONL行の明示byte上限なし | transportがfailure上限を保証するとは扱わない。 | +| `manifest/src/lib.rs` / `agen/src/tool.rs` | Tool content既定64KiB、個別overrideと後段文字列truncateあり | envelope/JSON escaping後のbyte予算を計算し、必須JSONを後段で壊さない。 | +| `worker/src/feature/mcp.rs` / `mcp/src/stdio.rs` | 結果64KiB、JSON depth12、stdout/protocol line1MiB | coreのdepth16より小さい12と、wrapper後の残depthを渡す。既存の部分置換へfailureを無条件に流さない。 | +| `plugin-pdk/src/lib.rs` / `workspace-server/src/plugin_receipt.rs` | guest error message / effect error JSONは4KiB | messageとJSON envelopeは異なる制約であり、full failureの転送可否とは別に判定。 | + +coreの `Budget::new` はtransport byteと残envelope byteを必須にし、32KiBとのminを取る。`with_depth` はtransport depth/外枠残depthを16とのminにする。深さはobject/arrayを1段として数え、文字列中の括弧は数えない。JSON文字列への二重符号化は境界でescaped byteを別途予算化する。core Problem Details primitiveは外側body全体にも同じ保守的なbudgetを適用する。 + +## reader先行とwriter切替 + +1. **既存保存形式**: operation/Job/Restore等の既存結果と旧diagnostics/messageを正本のまま読む。旧文章から新reasonを捏造しない。`compatibility::read_failure_attachment(None, budget)` は `LegacyWithoutFacts` を返す。欠落とpresent-but-corruptを区別する。 +2. **先行reader**: T-810が提供するversion付きenvelopeのreaderとoptional attachment readerを先に配備する。presentな値は `Known` / `Unsupported` / `Corrupt` を分け、後二者を業務上の確定拒否や未実行へ変えない。受信文面・publicラベルはauthorityではない。 +3. **domainごとの移行単位**: T-811〜815の所有store/consumerが追加fieldまたは既存versioned migrationを選ぶ。古い結果を保持したままreader・照会・公開再投影を先に検証する。T-810自体はDB/本番writer/consumer切替を有効化しない。 +4. **新writer**: audience型で区別した投影済み値だけを共通encoderへ渡す。domain結果、operation identity、必須detailsは黙ってtruncateしない。本件encoderはbudget不足を変換失敗として返し、static fallbackは別の公開不能表示であって新しい業務原因ではない。 +5. **保存再読込み**: typed validation後も現在の認可とresource可視性で再投影する。`UnsupportedFailure` は元受信境界内にopaqueに保持し、他audienceへの無条件forward APIを提供しない。 +6. **rollback**: writer切替を止めても、新形式を保存した結果が残る間はreaderを残す。旧診断へ逆変換して原因を復元したことにしない。DB移行・旧形式保持期間・実展開条件は所有storeの移行で確定する。 + +共通sourceは `crates/error-contract`、二domain fixtureはその `fixtures/` と `test_support`、実行例は `examples/two_domains.rs` に一元化した。schema/TSは同じRust DTOのoptional deriveから既存 `protocol --example generate_typescript --features typescript` が出力する。Worker HTTP型は引き続きcanonical OpenAPI入力の `server-api --example generate_worker_launch_api_types` が正本であり、将来の実HTTP接続は境界crateのローカルwrapperと明示 `OpenApiSchema` 実装を通す。generic schemaのdetailsは未確定のtyped引数であり、domain接続時には `schema::{failure_schema,problem_schema}::()` に具体型を渡す。TS宣言・JSON Schemaだけでbyte/depth/意味検証や認可済み投影を代替しない。 + +本棚卸しとcore契約試験は実認可・本番HTTP/Tool/背景処理の縦断成功を主張しない。その試験とsink記録点、domain別runbookは各接続Ticketが所有する。 diff --git a/openapi/failure.schema.json b/openapi/failure.schema.json new file mode 100644 index 000000000..e0226145c --- /dev/null +++ b/openapi/failure.schema.json @@ -0,0 +1,197 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "FailureEnvelope", + "description": "DTO/schema source only. Deserialization does not produce a ProjectedFailure.", + "type": "object", + "properties": { + "category": { + "$ref": "#/$defs/Category" + }, + "context": { + "$ref": "#/$defs/FailureContext" + }, + "details": true, + "details_version": { + "type": "integer", + "format": "uint16", + "maximum": 65535, + "minimum": 1 + }, + "domain": { + "type": "string", + "maxLength": 128, + "minLength": 1 + }, + "explanation": { + "type": "string", + "maxLength": 2048, + "minLength": 1 + }, + "reason": { + "type": "string", + "maxLength": 128, + "minLength": 1 + }, + "required_extensions": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 8 + }, + "version": { + "type": "integer", + "format": "uint16", + "maximum": 65535, + "minimum": 1 + } + }, + "required": [ + "version", + "required_extensions", + "domain", + "reason", + "category", + "details_version", + "details", + "explanation", + "context" + ], + "$defs": { + "Category": { + "type": "string", + "enum": [ + "authorization", + "invalid_input", + "conflict", + "dependency", + "internal", + "unknown" + ] + }, + "CausalLink": { + "description": "Only explicit causal references belong here. Cleanup/audit failures stay in domain results.", + "type": "object", + "properties": { + "correlation": { + "$ref": "#/$defs/CorrelationRef" + }, + "domain": { + "type": "string", + "maxLength": 128, + "minLength": 1 + }, + "reason": { + "type": "string", + "maxLength": 128, + "minLength": 1 + } + }, + "required": [ + "domain", + "reason", + "correlation" + ] + }, + "CorrelationRef": { + "description": "Correlation is deliberately a distinct field/type from operation identity.", + "type": "string", + "maxLength": 128, + "minLength": 1 + }, + "Diagnostic": { + "type": "object", + "properties": { + "code": { + "type": "string", + "maxLength": 128, + "minLength": 1 + }, + "explanation": { + "type": "string", + "maxLength": 2048, + "minLength": 1 + } + }, + "required": [ + "code", + "explanation" + ] + }, + "FailureContext": { + "description": "Common metadata is untrusted after deserialization. No audience label confers trust.", + "type": "object", + "properties": { + "causes": { + "description": "Ordered immediate-to-root chain, at most four references; never recursive payloads.", + "type": "array", + "items": { + "$ref": "#/$defs/CausalLink" + }, + "maxItems": 4 + }, + "correlation": { + "anyOf": [ + { + "$ref": "#/$defs/CorrelationRef" + }, + { + "type": "null" + } + ] + }, + "diagnostics": { + "type": "array", + "items": { + "$ref": "#/$defs/Diagnostic" + }, + "maxItems": 8 + }, + "operations": { + "type": "array", + "items": { + "$ref": "#/$defs/OperationContext" + }, + "maxItems": 8 + }, + "supplementary_omitted": { + "type": "boolean" + } + }, + "required": [ + "operations", + "causes", + "diagnostics", + "supplementary_omitted" + ] + }, + "OperationContext": { + "type": "object", + "properties": { + "operation": { + "type": "string", + "maxLength": 128, + "minLength": 1 + }, + "operation_ref": { + "description": "A reference, not an idempotency/retry grant. Disclosure remains domain-owned.", + "type": [ + "string", + "null" + ], + "maxLength": 128, + "minLength": 1 + }, + "stage": { + "type": "string", + "maxLength": 128, + "minLength": 1 + } + }, + "required": [ + "operation", + "stage" + ] + } + } +} diff --git a/openapi/problem-details.schema.json b/openapi/problem-details.schema.json new file mode 100644 index 000000000..91788cbfb --- /dev/null +++ b/openapi/problem-details.schema.json @@ -0,0 +1,238 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ProblemDetails", + "description": "Untrusted wire DTO. Unknown RFC extensions are ignored; yoi_failure is mandatory here.", + "type": "object", + "properties": { + "detail": { + "type": "string", + "maxLength": 2048, + "minLength": 1 + }, + "instance": { + "type": "string", + "maxLength": 2048 + }, + "status": { + "type": "integer", + "format": "uint16", + "maximum": 599, + "minimum": 400 + }, + "title": { + "type": "string", + "maxLength": 2048, + "minLength": 1 + }, + "type": { + "type": "string", + "maxLength": 2048 + }, + "yoi_failure": { + "$ref": "#/$defs/FailureEnvelope" + } + }, + "required": [ + "type", + "title", + "status", + "detail", + "instance", + "yoi_failure" + ], + "$defs": { + "Category": { + "type": "string", + "enum": [ + "authorization", + "invalid_input", + "conflict", + "dependency", + "internal", + "unknown" + ] + }, + "CausalLink": { + "description": "Only explicit causal references belong here. Cleanup/audit failures stay in domain results.", + "type": "object", + "properties": { + "correlation": { + "$ref": "#/$defs/CorrelationRef" + }, + "domain": { + "type": "string", + "maxLength": 128, + "minLength": 1 + }, + "reason": { + "type": "string", + "maxLength": 128, + "minLength": 1 + } + }, + "required": [ + "domain", + "reason", + "correlation" + ] + }, + "CorrelationRef": { + "description": "Correlation is deliberately a distinct field/type from operation identity.", + "type": "string", + "maxLength": 128, + "minLength": 1 + }, + "Diagnostic": { + "type": "object", + "properties": { + "code": { + "type": "string", + "maxLength": 128, + "minLength": 1 + }, + "explanation": { + "type": "string", + "maxLength": 2048, + "minLength": 1 + } + }, + "required": [ + "code", + "explanation" + ] + }, + "FailureContext": { + "description": "Common metadata is untrusted after deserialization. No audience label confers trust.", + "type": "object", + "properties": { + "causes": { + "description": "Ordered immediate-to-root chain, at most four references; never recursive payloads.", + "type": "array", + "items": { + "$ref": "#/$defs/CausalLink" + }, + "maxItems": 4 + }, + "correlation": { + "anyOf": [ + { + "$ref": "#/$defs/CorrelationRef" + }, + { + "type": "null" + } + ] + }, + "diagnostics": { + "type": "array", + "items": { + "$ref": "#/$defs/Diagnostic" + }, + "maxItems": 8 + }, + "operations": { + "type": "array", + "items": { + "$ref": "#/$defs/OperationContext" + }, + "maxItems": 8 + }, + "supplementary_omitted": { + "type": "boolean" + } + }, + "required": [ + "operations", + "causes", + "diagnostics", + "supplementary_omitted" + ] + }, + "FailureEnvelope": { + "description": "DTO/schema source only. Deserialization does not produce a ProjectedFailure.", + "type": "object", + "properties": { + "category": { + "$ref": "#/$defs/Category" + }, + "context": { + "$ref": "#/$defs/FailureContext" + }, + "details": true, + "details_version": { + "type": "integer", + "format": "uint16", + "maximum": 65535, + "minimum": 1 + }, + "domain": { + "type": "string", + "maxLength": 128, + "minLength": 1 + }, + "explanation": { + "type": "string", + "maxLength": 2048, + "minLength": 1 + }, + "reason": { + "type": "string", + "maxLength": 128, + "minLength": 1 + }, + "required_extensions": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 8 + }, + "version": { + "type": "integer", + "format": "uint16", + "maximum": 65535, + "minimum": 1 + } + }, + "required": [ + "version", + "required_extensions", + "domain", + "reason", + "category", + "details_version", + "details", + "explanation", + "context" + ] + }, + "OperationContext": { + "type": "object", + "properties": { + "operation": { + "type": "string", + "maxLength": 128, + "minLength": 1 + }, + "operation_ref": { + "description": "A reference, not an idempotency/retry grant. Disclosure remains domain-owned.", + "type": [ + "string", + "null" + ], + "maxLength": 128, + "minLength": 1 + }, + "stage": { + "type": "string", + "maxLength": 128, + "minLength": 1 + } + }, + "required": [ + "operation", + "stage" + ] + } + } +} diff --git a/web/workspace/src/lib/generated/error-contract.ts b/web/workspace/src/lib/generated/error-contract.ts new file mode 100644 index 000000000..7fbb0b193 --- /dev/null +++ b/web/workspace/src/lib/generated/error-contract.ts @@ -0,0 +1,21 @@ +// @generated by `cargo run -p protocol --example generate_typescript --features typescript` +// Untrusted wire DTOs; validation and current-context reprojection are required. + +export const FAILURE_LIMITS = { bytes: 32768, depth: 16, contexts: 8, causes: 4, diagnostics: 8, explanationBytes: 2048, identifierBytes: 128 } as const; + +export type Category = "authorization" | "invalid_input" | "conflict" | "dependency" | "internal" | "unknown"; +export type OperationContext = { operation: string, stage: string, +/** + * A reference, not an idempotency/retry grant. Disclosure remains domain-owned. + */ +operation_ref: string | null, }; +export type CorrelationRef = string; +export type CausalLink = { domain: string, reason: string, correlation: CorrelationRef, }; +export type Diagnostic = { code: string, explanation: string, }; +export type FailureContext = { operations: Array, +/** + * Ordered immediate-to-root chain, at most four references; never recursive payloads. + */ +causes: Array, diagnostics: Array, correlation: CorrelationRef | null, supplementary_omitted: boolean, }; +export type FailureEnvelope = { version: number, required_extensions: Array, domain: string, reason: string, category: Category, details_version: number, details: D, explanation: string, context: FailureContext, }; +export type ProblemDetails = { type: string, title: string, status: number, detail: string, instance: string, yoi_failure: FailureEnvelope, };