use serde::{Deserialize, Serialize};
use crate::core::Spend;
use crate::core::{EffectKey, Sensitivity, Seq};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum RuntimeError {
#[error("policy denied: {0}")]
PolicyDenied(#[from] PolicyError),
#[error("plan contract violation: {0}")]
PlanContract(String),
#[error(
"policy bundle changed while resuming an open run: recorded {recorded:?}, configured {configured:?}"
)]
PolicyBundleChanged {
recorded: Option<crate::core::Digest>,
configured: Option<crate::core::Digest>,
},
#[error("no skill provides capability '{0}'")]
NoProvider(String),
#[error("quota: {0}")]
QuotaExceeded(#[from] crate::quota::QuotaError),
#[error("journal integrity broken at seq {seq}: {detail}")]
ChainBroken { seq: Seq, detail: String },
#[error("fenced at run {run}: held epoch {held}, store is at {current}")]
Fenced {
run: String,
held: u64,
current: u64,
},
#[error("run {run} is leased by '{owner}' for another {remaining_secs}s")]
LeaseHeld {
run: String,
owner: String,
remaining_secs: u64,
},
#[error(transparent)]
Store(#[from] StoreError),
#[error(transparent)]
Encoding(#[from] serde_json::Error),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Disposition {
DidNotHappen,
InDoubt,
Landed,
}
impl Disposition {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::DidNotHappen => "did_not_happen",
Self::InDoubt => "in_doubt",
Self::Landed => "landed",
}
}
#[must_use]
pub fn is_definitely_safe_to_repeat(self) -> bool {
matches!(self, Self::DidNotHappen)
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum EffectError {
#[error("driver '{driver}' unavailable: {detail}")]
Unavailable { driver: String, detail: String },
#[error("effect rejected: {0}")]
Rejected(String),
#[error("driver '{driver}' did not answer within {waited_ms}ms")]
Timeout { driver: String, waited_ms: u64 },
#[error("driver '{driver}' interrupted: {detail}")]
Interrupted { driver: String, detail: String },
#[error("effect consumed resources and failed: {detail}")]
Metered {
detail: String,
spend: Spend,
disposition: Disposition,
},
#[error("effect performed and failed: {0}")]
Performed(String),
#[error("effect output did not match its declared type: {0}")]
OutputShape(#[from] serde_json::Error),
#[error("{detail}")]
Final {
detail: String,
disposition: Disposition,
},
#[error("{0}")]
Other(String),
}
impl EffectError {
#[must_use]
pub fn spend(&self) -> Spend {
match self {
Self::Metered { spend, .. } => *spend,
_ => Spend::default(),
}
}
#[must_use]
pub fn disposition(&self) -> Disposition {
match self {
Self::Metered { disposition, .. } | Self::Final { disposition, .. } => *disposition,
Self::Unavailable { .. } | Self::Rejected(_) => Disposition::DidNotHappen,
Self::OutputShape(_) | Self::Performed(_) => Disposition::Landed,
Self::Timeout { .. } | Self::Interrupted { .. } | Self::Other(_) => {
Disposition::InDoubt
}
}
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum SkillError {
#[error("input did not match the declared schema: {0}")]
Input(String),
#[error(transparent)]
Step(#[from] StepError),
#[error(transparent)]
Tool(#[from] crate::tools::ToolError),
#[error("{0}")]
Other(String),
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum StepError {
#[error(transparent)]
Effect(#[from] EffectError),
#[error(transparent)]
Policy(#[from] PolicyError),
#[error(transparent)]
Store(#[from] StoreError),
#[error("{0}")]
Encoding(#[from] serde_json::Error),
#[error(
"effect {key} is undecidable ({detail}); recovery mode {recovery:?} forbids \
guessing — run quarantined"
)]
Undecidable {
key: EffectKey,
recovery: crate::core::Recovery,
detail: String,
},
#[error("non-determinism at seq {seq}: expected {expected}, recomputed {actual}")]
NonDeterminism {
seq: Seq,
expected: EffectKey,
actual: EffectKey,
},
#[error(transparent)]
Budget(#[from] crate::core::BudgetExceeded),
#[error("suspended: {0}")]
Suspended(crate::core::SuspendReason),
#[error("policy denied '{action}' on '{resource}': {reason}")]
Denied {
action: String,
resource: String,
reason: String,
},
#[error("effect group '{group}': {detail}")]
GroupFootprint { group: String, detail: String },
#[error("effect group aborted and fully reversed: {what}")]
GroupAborted { what: String },
#[error("effect group '{group}' could not be settled: {detail} — run quarantined")]
GroupUnsettled { group: String, detail: String },
#[error(
"replay overrun: journal is exhausted but the run requested {actual} — \
this build performs more effects than the recorded one"
)]
ReplayOverrun { actual: EffectKey },
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum PolicyError {
#[error("principal '{principal}' may not '{action}' on '{resource}'")]
Denied {
principal: String,
action: String,
resource: String,
},
#[error("untrusted data may not reach mutating sink '{sink}' without an authorized release")]
TaintGate { sink: String },
#[error("sink '{sink}' does not bind the arguments it sends to the value checked by policy")]
UnboundSinkArguments { sink: String },
#[error("sink '{sink}' must be dispatched with StepCtx::sink so its outbound value is checked")]
SinkGateRequired { sink: String },
#[error(
"sink '{sink}' attempted to send arguments other than the labeled value policy checked"
)]
SinkArgumentsMismatch { sink: String },
#[error("sink '{sink}' requires protected field '{path}', but the argument is absent")]
ProtectedFieldMissing { sink: String, path: String },
#[error("untrusted data may not select protected field '{path}' of sink '{sink}'")]
ProtectedFieldTaint { sink: String, path: String },
#[error(
"protected field '{path}' of sink '{sink}' derives from undeclared source '{actual_source}'"
)]
ProtectedFieldSource {
sink: String,
path: String,
actual_source: String,
},
#[error(
"protected field '{path}' sensitivity {actual:?} exceeds sink '{sink}' field ceiling {ceiling:?}"
)]
ProtectedFieldSensitivity {
sink: String,
path: String,
actual: Sensitivity,
ceiling: Sensitivity,
},
#[error(
"release scope contains a missing or untracked field; use Tainted::object/array before releasing selected fields"
)]
UntrackedReleaseField,
#[error("invalid release: {detail}")]
InvalidRelease { detail: String },
#[error(
"sensitivity {actual:?} exceeds the journal ceiling {ceiling:?} for sink \
'{sink}' — the journal is append-only, so this argument could not be \
removed afterwards. Put the bytes in a blob and pass the digest, or \
configure a key ring so payloads are sealed under a key erasure destroys"
)]
JournalCeiling {
sink: String,
actual: crate::core::Sensitivity,
ceiling: crate::core::Sensitivity,
},
#[error("sensitivity {actual:?} exceeds sink '{sink}' ceiling {ceiling:?}")]
EgressCeiling {
sink: String,
actual: Sensitivity,
ceiling: Sensitivity,
},
#[error("delegation depth {actual} exceeds sink '{sink}' ceiling {ceiling}")]
DelegationDepth {
sink: String,
actual: usize,
ceiling: usize,
},
}
pub const REFUSED: &str = "this action was not permitted";
impl PolicyError {
#[must_use]
pub const fn for_model(&self) -> &'static str {
REFUSED
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum StoreError {
#[error("backend: {0}")]
Backend(String),
#[error("not found: {0}")]
NotFound(String),
#[error(
"record of {bytes} bytes exceeds the {limit}-byte journal limit — \
journal a digest and keep the bytes outside the chain"
)]
RecordTooLarge { bytes: usize, limit: usize },
#[error("effect {0} already started in this run")]
DuplicateEffect(EffectKey),
#[error("case {case} has moved to {current}; the write was made against {expected}")]
CaseConflict {
case: String,
expected: u64,
current: u64,
},
#[error("fenced: run {run} is owned at epoch {current}, writer held {held}")]
Fenced {
run: String,
held: u64,
current: u64,
},
#[error(
"the transaction's outcome is unknown — COMMIT may or may not have \
been applied: {detail}"
)]
CommitUnknown { detail: String },
#[error("run {run} is sealed as '{outcome}'; a sealed journal accepts no appends")]
RunSealed { run: String, outcome: String },
#[error("run {run} is leased by '{owner}' at epoch {epoch} for another {remaining_secs}s")]
LeaseHeld {
run: String,
owner: String,
epoch: u64,
remaining_secs: u64,
},
#[error("corrupt record at seq {seq}: {detail}")]
Corrupt { seq: Seq, detail: String },
#[error(transparent)]
Encoding(#[from] serde_json::Error),
}
impl RuntimeError {
#[must_use]
pub fn from_store(e: StoreError) -> Self {
match e {
StoreError::Fenced { run, held, current } => Self::Fenced { run, held, current },
StoreError::LeaseHeld {
run,
owner,
remaining_secs,
..
} => Self::LeaseHeld {
run,
owner,
remaining_secs,
},
StoreError::Corrupt { seq, detail } => Self::ChainBroken { seq, detail },
other => Self::Store(other),
}
}
#[must_use]
pub fn is_terminal_for_owner(&self) -> bool {
matches!(self, Self::Fenced { .. } | Self::ChainBroken { .. })
}
}
#[cfg(test)]
mod tests {
use super::{Disposition, RuntimeError};
#[test]
fn only_a_call_that_never_left_is_safe_to_repeat_on_its_own_terms() {
assert!(Disposition::DidNotHappen.is_definitely_safe_to_repeat());
assert!(!Disposition::InDoubt.is_definitely_safe_to_repeat());
assert!(!Disposition::Landed.is_definitely_safe_to_repeat());
}
#[test]
fn an_owner_abandons_a_fenced_run_and_a_broken_chain_and_nothing_else() {
assert!(
RuntimeError::Fenced {
run: "run-1".into(),
held: 1,
current: 2,
}
.is_terminal_for_owner()
);
assert!(
RuntimeError::ChainBroken {
seq: 1,
detail: "hash mismatch".into(),
}
.is_terminal_for_owner()
);
assert!(
!RuntimeError::Store(crate::core::StoreError::Backend("timeout".into()))
.is_terminal_for_owner()
);
}
}