1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
use std::collections::BTreeSet;
use std::fmt::{Display, Formatter};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DispatchError {
/// The caller failed authentication (no credential matched any
/// configured method). Adapters render this as HTTP 401.
Unauthorized(String),
/// The caller authenticated successfully but lacks one or more scopes
/// required by the invoked function. Adapters render this as HTTP 403
/// with a structured body that reports `required` vs `granted` so the
/// client can prompt for the right credential.
Forbidden {
required: BTreeSet<String>,
granted: BTreeSet<String>,
},
/// The caller authenticated and carries the required scopes, but their
/// principal `kind` is not in the route's `@policy(kinds: ...)`
/// allow-set. Adapters render this as HTTP 403. The body reports the
/// route's `allowed_kinds` (route configuration, not tenant data) but
/// never echoes the caller's own kind, keeping the denial tenant-safe.
ForbiddenPrincipalKind {
allowed: BTreeSet<String>,
},
/// One of the route's declared rate-limit buckets (per-route /
/// per-tenant / per-scope) or the backpressure watermark rejected
/// this dispatch. Adapters render this as HTTP 429 with a
/// `Retry-After` header derived from `retry_after_ms`. `scope`
/// identifies which bucket dimension fired so callers can attribute
/// the rejection (e.g. "your tenant quota" vs "global route ceiling").
RateLimited {
scope: String,
retry_after_ms: u64,
},
/// A `@budget(...)` ceiling declared on the route was exhausted
/// mid-call (e.g. accumulated LLM cost rose above `llm_cost_usd`).
/// Adapters render this as HTTP 429 with `code = "budget_exceeded"`.
BudgetExceeded {
category: String,
message: String,
},
Validation(String),
MissingExport(String),
Cancelled(String),
Execution(String),
Io(String),
Cache(String),
/// The process has no usable secret backend, or the one it was told to
/// use could not be built.
///
/// Deliberately separate from a credential that is simply absent. A
/// connector that cannot resolve a key because nothing is wired up and one
/// that cannot resolve a key because the key was never stored need
/// different answers — configure the host, versus store the credential —
/// and collapsing them sends the operator to the wrong place. This variant
/// only ever carries backend configuration detail; a secret's value never
/// reaches it.
SecretBackend(String),
}
impl DispatchError {
/// Human-readable message describing this dispatch error. The
/// `Forbidden` variant flattens its scope sets into a stable
/// `missing required scope(s): a, b` form so existing string-based
/// log/metric sinks pick up scope context without restructuring.
pub fn message(&self) -> String {
match self {
Self::Unauthorized(message)
| Self::Validation(message)
| Self::MissingExport(message)
| Self::Cancelled(message)
| Self::Execution(message)
| Self::Io(message)
| Self::Cache(message) => message.clone(),
Self::SecretBackend(message) => {
format!("no usable secret backend: {message}")
}
Self::Forbidden { required, granted } => forbidden_message(required, granted),
Self::ForbiddenPrincipalKind { allowed } => forbidden_principal_kind_message(allowed),
Self::RateLimited {
scope,
retry_after_ms,
} => format!("rate limit exceeded ({scope}); retry after {retry_after_ms} ms"),
Self::BudgetExceeded { category, message } => {
format!("budget exceeded ({category}): {message}")
}
}
}
}
/// Render a stable diagnostic for a scope-mismatch decision. Used both
/// by `DispatchError::Forbidden::message()` and by adapter-layer error
/// envelopes (JSON-RPC `data.message`, HTTP body, ACP error reply) so
/// callers see the same text everywhere.
pub fn forbidden_message(required: &BTreeSet<String>, granted: &BTreeSet<String>) -> String {
let missing: Vec<&str> = required.difference(granted).map(String::as_str).collect();
if missing.is_empty() {
"missing required scope".to_string()
} else {
format!("missing required scope(s): {}", missing.join(", "))
}
}
/// Render a stable, tenant-safe diagnostic for a `@policy(kinds: ...)`
/// principal-kind denial. Names the route's allowed kinds (route config)
/// but never the caller's own kind, so the text is safe to surface in
/// logs, receipts, and the HTTP body without leaking identity detail.
pub fn forbidden_principal_kind_message(allowed: &BTreeSet<String>) -> String {
if allowed.is_empty() {
"principal kind not permitted for this route".to_string()
} else {
let allowed: Vec<&str> = allowed.iter().map(String::as_str).collect();
format!(
"principal kind not permitted for this route; allowed: {}",
allowed.join(", ")
)
}
}
/// Structured `forbidden` payload shared across adapter error envelopes
/// (MCP JSON-RPC `error.data`, A2A JSON-RPC `error.data`, REST `error`
/// body). Producing it from one place keeps the field layout
/// (`kind`/`required_scopes`/`granted_scopes`/`missing_scopes`) stable
/// for clients that parse it programmatically.
pub fn forbidden_data_payload(
required: &BTreeSet<String>,
granted: &BTreeSet<String>,
) -> serde_json::Value {
let missing: Vec<&str> = required.difference(granted).map(String::as_str).collect();
serde_json::json!({
"kind": "forbidden",
"required_scopes": required.iter().collect::<Vec<_>>(),
"granted_scopes": granted.iter().collect::<Vec<_>>(),
"missing_scopes": missing,
})
}
impl Display for DispatchError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message())
}
}
impl std::error::Error for DispatchError {}