feat(error-contract): add typed projections and bounded failure utilities

This commit is contained in:
2026-11-01 06:02:10 +09:00
parent d0f22d871a
commit eb05300a32
23 changed files with 3482 additions and 3 deletions
Generated
+12
View File
@@ -1514,6 +1514,17 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "error-contract"
version = "0.1.0"
dependencies = [
"schemars",
"serde",
"serde_json",
"thiserror 2.0.18",
"ts-rs",
]
[[package]] [[package]]
name = "euclid" name = "euclid"
version = "0.20.14" version = "0.20.14"
@@ -3798,6 +3809,7 @@ version = "0.1.0"
name = "protocol" name = "protocol"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"error-contract",
"schemars", "schemars",
"serde", "serde",
"serde_json", "serde_json",
+3
View File
@@ -26,6 +26,7 @@ members = [
"crates/lint-common", "crates/lint-common",
"crates/tools", "crates/tools",
"crates/fs-operation", "crates/fs-operation",
"crates/error-contract",
"crates/flow", "crates/flow",
"crates/builtin-source", "crates/builtin-source",
"crates/config-source", "crates/config-source",
@@ -68,6 +69,7 @@ default-members = [
"crates/lint-common", "crates/lint-common",
"crates/tools", "crates/tools",
"crates/fs-operation", "crates/fs-operation",
"crates/error-contract",
"crates/flow", "crates/flow",
"crates/builtin-source", "crates/builtin-source",
"crates/config-source", "crates/config-source",
@@ -138,6 +140,7 @@ secrets = { path = "crates/secrets" }
tools = { path = "crates/tools" } tools = { path = "crates/tools" }
builtin-source = { path = "crates/builtin-source" } builtin-source = { path = "crates/builtin-source" }
config-source = { path = "crates/config-source" } config-source = { path = "crates/config-source" }
error-contract = { path = "crates/error-contract" }
fs-operation = { path = "crates/fs-operation" } fs-operation = { path = "crates/fs-operation" }
workdir = { path = "crates/workdir" } workdir = { path = "crates/workdir" }
tui = { path = "crates/tui" } tui = { path = "crates/tui" }
+22
View File
@@ -0,0 +1,22 @@
[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]
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"]
+18
View File
@@ -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.
@@ -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::<Browser, _>(&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::<Tool, _>(&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::<SubjectDenied>(None, budget),
StoredFailureRead::LegacyWithoutFacts
));
let storage = project_failure::<Storage, _>(&subject, &access)?;
let stored = encode_projected_failure(&storage, budget)?;
let received = match read_failure_attachment::<SubjectDenied>(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::<Browser, _>(received.facts(), &current_access)?;
assert_eq!(
reread.details().required_operation,
RequiredOperation::SessionRecord
);
assert!(reread.details().subject_ref.is_none());
let telemetry = project_failure::<Telemetry, _>(&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(())
}
@@ -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}}
@@ -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}}
+296
View File
@@ -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<D> {
#[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<String>,
#[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<String>,
domain: String,
reason: String,
category: String,
details_version: u16,
#[serde(rename = "details")]
_details: serde_json::Value,
explanation: String,
context: FailureContext,
}
pub struct ReceivedFailure<D> {
facts: FailureFacts<D>,
}
impl<D: FailureCause> ReceivedFailure<D> {
/// Reprojection with current authorization is required before any encoder can use this.
pub fn facts(&self) -> &FailureFacts<D> {
&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<u8>,
}
impl UnsupportedFailure {
pub fn retained_bytes(&self) -> usize {
self.payload.len()
}
}
pub enum DecodeFailure<D> {
Known(ReceivedFailure<D>),
Unsupported(UnsupportedFailure),
Corrupt(ContractError),
}
pub fn decode_failure<D: WireDetails>(input: &[u8], budget: Budget) -> DecodeFailure<D> {
match decode_checked(input, budget) {
Ok(value) => value,
Err(error) => DecodeFailure::Corrupt(error),
}
}
pub(crate) fn decode_checked<D: WireDetails>(
input: &[u8],
budget: Budget,
) -> Result<DecodeFailure<D>, 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<D> =
serde_json::from_slice(input).map_err(|_| ContractError::InvalidDetails)?;
accept_typed_envelope(typed).map(DecodeFailure::Known)
}
pub(crate) fn accept_typed_envelope<D: WireDetails>(
typed: FailureEnvelope<D>,
) -> Result<ReceivedFailure<D>, 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<u8>,
budget: usize,
exceeded: bool,
}
impl Write for BoundedWriter {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
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<T: Serialize>(
value: &T,
budget: Budget,
) -> Result<Vec<u8>, 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<A> {
bytes: Vec<u8>,
audience: PhantomData<A>,
}
impl<A: Audience> EncodedFailure<A> {
pub fn as_bytes(&self) -> &[u8] {
&self.bytes
}
}
pub fn encode_projected_failure<A: Audience, D: WireDetails>(
failure: &ProjectedFailure<A, D>,
budget: Budget,
) -> Result<EncodedFailure<A>, 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<A> {
pub encoded: EncodedFailure<A>,
pub conversion_error: Option<ContractError>,
/// Kept separate from static payload; the boundary must allowlist it for disclosure.
pub correlation: Option<CorrelationRef>,
}
/// 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<A: Audience, D: WireDetails>(
projection: Result<&ProjectedFailure<A, D>, ContractError>,
budget: Budget,
correlation: Option<CorrelationRef>,
) -> Result<BoundaryEncoding<A>, 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,
})
}
@@ -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<D> {
LegacyWithoutFacts,
Structured(DecodeFailure<D>),
}
pub fn read_failure_attachment<D: WireDetails>(
attachment: Option<&[u8]>,
budget: Budget,
) -> StoredFailureRead<D> {
match attachment {
None => StoredFailureRead::LegacyWithoutFacts,
Some(bytes) => StoredFailureRead::Structured(decode_failure(bytes, budget)),
}
}
+232
View File
@@ -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<String>,
}
impl OperationContext {
pub fn new(
operation: impl Into<String>,
stage: impl Into<String>,
) -> Result<Self, ContractError> {
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<String>) -> Result<Self, ContractError> {
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<OperationContext>,
/// Ordered immediate-to-root chain, at most four references; never recursive payloads.
#[cfg_attr(feature = "json-schema", schemars(length(max = 4)))]
pub causes: Vec<CausalLink>,
#[cfg_attr(feature = "json-schema", schemars(length(max = 8)))]
pub diagnostics: Vec<Diagnostic>,
pub correlation: Option<CorrelationRef>,
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<D> {
cause: D,
context: FailureContext,
}
impl<D: FailureCause> FailureFacts<D> {
pub fn new(cause: D) -> Result<Self, ContractError> {
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<D: FailureCause>(facts: &FailureFacts<D>) -> 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<D: FailureCause> fmt::Display for FailureFacts<D> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let identity = self.cause.identity();
write!(f, "{}.{}", identity.domain, identity.reason)
}
}
impl<D: FailureCause + Error + 'static> Error for FailureFacts<D> {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&self.cause)
}
}
+56
View File
@@ -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(())
}
+248
View File
@@ -0,0 +1,248 @@
//! 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<D> {
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<D> {
#[serde(rename = "type")]
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, 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(min = 1, max = 2048)))]
pub instance: String,
pub yoi_failure: FailureEnvelope<D>,
}
#[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<u8>,
}
impl ProblemResponse {
pub fn body(&self) -> &[u8] {
&self.body
}
}
fn validate_uri(value: &str) -> Result<(), ContractError> {
validate_explanation(value)?;
if !value.is_ascii()
|| value.bytes().any(|byte| {
byte <= b' ' || byte == b'"' || byte == b'<' || byte == b'>' || byte == b'\\'
})
{
return Err(ContractError::Invalid);
}
// Accept absolute URI and RFC URI-reference instances, not arbitrary prose.
if !(value.contains(':') || value.starts_with('/') || value.starts_with('#')) {
return Err(ContractError::Invalid);
}
Ok(())
}
pub fn to_problem_details<D: WireDetails>(
projected: &ProjectedFailure<Browser, D>,
api_boundary: &impl ApiBoundary<D>,
instance: &str,
budget: Budget,
) -> Result<ProblemResponse, ContractError> {
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<D: WireDetails>(
input: &[u8],
actual_status: u16,
content_type: &str,
api_boundary: &impl ApiBoundary<D>,
budget: Budget,
) -> DecodeFailure<D> {
match decode_problem_checked(input, actual_status, content_type, api_boundary, budget) {
Ok(result) => result,
Err(error) => DecodeFailure::Corrupt(error),
}
}
fn decode_problem_checked<D: WireDetails>(
input: &[u8],
actual_status: u16,
content_type: &str,
api_boundary: &impl ApiBoundary<D>,
budget: Budget,
) -> Result<DecodeFailure<D>, 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::<D>(&failure_bytes, budget)? {
DecodeFailure::Known(_) => {
// Use original JSON for typed parsing, preserving duplicate-field rejection.
let typed: ProblemDetails<D> =
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<ProblemResponse, ContractError> {
codec::check_json_bounds(STATIC_PROBLEM_FALLBACK, budget)?;
Ok(ProblemResponse {
status: 500,
content_type: PROBLEM_CONTENT_TYPE,
body: STATIC_PROBLEM_FALLBACK.to_vec(),
})
}
+174
View File
@@ -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<D> {
pub details: D,
pub explanation: String,
pub context: FailureContext,
}
impl<D> Disclosure<D> {
pub fn new(details: D, explanation: impl Into<String>) -> 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<A: Audience>: FailureCause + Sized {
type Authorization: ?Sized;
type Details: WireDetails;
fn disclose(
&self,
facts: &FailureFacts<Self>,
authorization: &Self::Authorization,
) -> Result<Disclosure<Self::Details>, ContractError>;
}
/// No Deserialize/Serialize implementation: only projection creates this trust marker.
///
/// ```compile_fail
/// use error_contract::{Browser, Storage, ProjectedFailure};
/// fn public_only<D>(_: &ProjectedFailure<Browser, D>) {}
/// fn cannot_relabel<D>(stored: &ProjectedFailure<Storage, D>) { public_only(stored); }
/// ```
///
/// ```compile_fail
/// use error_contract::{Browser, ProjectedFailure};
/// let _: ProjectedFailure<Browser, String> = serde_json::from_str("{}").unwrap();
/// ```
///
/// ```compile_fail
/// use error_contract::{Browser, Budget, ReceivedFailure, WireDetails, encode_projected_failure};
/// fn received_is_not_projected<D: WireDetails>(received: &ReceivedFailure<D>) {
/// encode_projected_failure::<Browser, D>(received, Budget::new(1024, 1024));
/// }
/// ```
pub struct ProjectedFailure<A, D> {
pub(crate) envelope: FailureEnvelope<D>,
pub(crate) audience: PhantomData<A>,
}
impl<A: Audience, D: FailureCause> ProjectedFailure<A, D> {
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<A: Audience, D: ProjectFor<A>>(
facts: &FailureFacts<D>,
authorization: &D::Authorization,
) -> Result<ProjectedFailure<A, D::Details>, 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<String, ContractError> {
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<CorrelationRef>,
}
pub fn unavailable_presentation(
stage: &'static str,
correlation: Option<CorrelationRef>,
) -> Result<UnavailablePresentation, ContractError> {
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<D: FailureCause>(
failure: &ProjectedFailure<Telemetry, D>,
) -> 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),
}
}
+32
View File
@@ -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<D: schemars::JsonSchema>() -> schemars::Schema {
schemars::schema_for!(FailureEnvelope<D>)
}
pub fn problem_schema<D: schemars::JsonSchema>() -> schemars::Schema {
schemars::schema_for!(ProblemDetails<D>)
}
/// 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::<String>::decl(&config),
ProblemDetails::<String>::decl(&config),
]
.into_iter()
.map(|declaration| format!("export {declaration}\n"))
.collect()
}
+354
View File
@@ -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<String> {
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<String>,
}
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<String>,
}
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<Disclosure<SubjectDenied>, 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<WorkdirInUse> {
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<Self>, access: &CurrentAccess) -> Result<Disclosure<Self::Details>, ContractError> {
subject_disclosure(self, facts.context(), access)
}
}
impl ProjectFor<$audience> for WorkdirInUse {
type Authorization = CurrentAccess; type Details = WorkdirInUse;
fn disclose(&self, facts: &FailureFacts<Self>, access: &CurrentAccess) -> Result<Disclosure<Self::Details>, ContractError> {
Ok(workdir_disclosure(self, facts.context(), access))
}
}
impl ProjectFor<$audience> for SubjectError {
type Authorization = CurrentAccess; type Details = SubjectDenied;
fn disclose(&self, facts: &FailureFacts<Self>, access: &CurrentAccess) -> Result<Disclosure<Self::Details>, ContractError> {
subject_disclosure(&self.details, facts.context(), access)
}
}
impl ProjectFor<$audience> for WorkdirError {
type Authorization = CurrentAccess; type Details = WorkdirInUse;
fn disclose(&self, facts: &FailureFacts<Self>, access: &CurrentAccess) -> Result<Disclosure<Self::Details>, ContractError> {
Ok(workdir_disclosure(&self.details, facts.context(), access))
}
}
)* };
}
adapters!(Browser, Tool, Storage, InternalTransfer, Telemetry);
pub fn subject_facts() -> FailureFacts<SubjectError> {
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<WorkdirError> {
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<SubjectDenied> for SubjectApi {
fn classify(&self, _: &SubjectDenied) -> crate::problem::ProblemClass {
crate::problem::ProblemClass::authorization_denied()
}
}
impl crate::problem::ApiBoundary<SubjectDenied> for WorkerApi {
fn classify(&self, _: &SubjectDenied) -> crate::problem::ProblemClass {
crate::problem::ProblemClass::conflict()
}
}
impl crate::problem::ApiBoundary<WorkdirInUse> 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<D: WireDetails + Clone + PartialEq + std::fmt::Debug>(
facts: &mut FailureFacts<D>,
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<D: WireDetails + PartialEq + std::fmt::Debug>(
bytes: &[u8],
expected: &D,
budget: Budget,
) {
match decode_failure::<D>(bytes, budget) {
DecodeFailure::Known(received) => assert_eq!(received.facts().cause(), expected),
_ => panic!("expected a known validated fixture"),
}
}
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -7,10 +7,11 @@ license.workspace = true
[features] [features]
default = ["stream"] default = ["stream"]
stream = ["dep:tokio"] stream = ["dep:tokio"]
typescript = ["dep:ts-rs", "json-schema"] typescript = ["dep:ts-rs", "json-schema", "error-contract/typescript"]
json-schema = ["dep:schemars"] json-schema = ["dep:schemars", "error-contract/json-schema"]
[dependencies] [dependencies]
error-contract.workspace = true
schemars = { workspace = true, optional = true } schemars = { workspace = true, optional = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
@@ -3,7 +3,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let check = std::env::args() let check = std::env::args()
.skip(1) .skip(1)
.any(|argument| argument == "--check"); .any(|argument| argument == "--check");
let outputs = [ let mut outputs = vec![
( (
protocol::typescript::generated_typescript_path(), protocol::typescript::generated_typescript_path(),
protocol::typescript::generated_protocol_types(), protocol::typescript::generated_protocol_types(),
@@ -13,6 +13,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
protocol::typescript::generated_protocol_validator(), protocol::typescript::generated_protocol_validator(),
), ),
]; ];
outputs.extend(protocol::typescript::generated_failure_contract_artifacts());
for (path, expected) in outputs { for (path, expected) in outputs {
if check { if check {
+48
View File
@@ -175,6 +175,42 @@ pub fn generated_protocol_types() -> String {
normalize_typescript(output) 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::<Value>()),
),
(
root.join("openapi/problem-details.schema.json"),
render(error_contract::schema::problem_schema::<Value>()),
),
]
}
/// Generates the Browser receive validator paired with TypeScript declarations. /// Generates the Browser receive validator paired with TypeScript declarations.
/// ///
/// Normalizes schemas reachable from [`SubscriptionFrame`] to Rust's actual wire shape. /// Normalizes schemas reachable from [`SubscriptionFrame`] to Rust's actual wire shape.
@@ -599,6 +635,18 @@ mod tests {
close_object_shapes(&mut unsupported); 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] #[test]
fn generated_protocol_types_are_current() { fn generated_protocol_types_are_current() {
let expected = generated_protocol_types(); let expected = generated_protocol_types();
@@ -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}::<D>()` に具体型を渡す。TS宣言・JSON Schemaだけでbyte/depth/意味検証や認可済み投影を代替しない。
本棚卸しとcore契約試験は実認可・本番HTTP/Tool/背景処理の縦断成功を主張しない。その試験とsink記録点、domain別runbookは各接続Ticketが所有する。
+197
View File
@@ -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"
]
}
}
}
+240
View File
@@ -0,0 +1,240 @@
{
"$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,
"minLength": 1
},
"status": {
"type": "integer",
"format": "uint16",
"maximum": 599,
"minimum": 400
},
"title": {
"type": "string",
"maxLength": 2048,
"minLength": 1
},
"type": {
"type": "string",
"maxLength": 2048,
"minLength": 1
},
"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"
]
}
}
}
@@ -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<OperationContext>,
/**
* Ordered immediate-to-root chain, at most four references; never recursive payloads.
*/
causes: Array<CausalLink>, diagnostics: Array<Diagnostic>, correlation: CorrelationRef | null, supplementary_omitted: boolean, };
export type FailureEnvelope<D> = { version: number, required_extensions: Array<string>, domain: string, reason: string, category: Category, details_version: number, details: D, explanation: string, context: FailureContext, };
export type ProblemDetails<D> = { type: string, title: string, status: number, detail: string, instance: string, yoi_failure: FailureEnvelope<D>, };