feat(error-contract): add typed projections and bounded failure utilities
This commit is contained in:
Generated
+12
@@ -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",
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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(), ¤t_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}}
|
||||||
@@ -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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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が所有する。
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>, };
|
||||||
Reference in New Issue
Block a user