use std::fmt;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::canonical::{CanonicalError, canonical_bytes, tagged_digest};
use crate::definition::Operation;
use crate::ids::{ArtifactId, ContentDigest, PlanId, RequestId, SnapshotId};
use crate::limits::REPLAY_V1;
use crate::replay::{CaptureStatus, ReplayBundle};
use crate::retention::Availability;
use crate::run::{
AttemptEnd, Cancellation, Charge, CostBound, CostSummary, RemoteState, RunRecord, Termination,
};
use crate::scope::{Handle, Scope};
use crate::time::Timestamp;
use crate::{Document, schema};
pub const MAX_REFERENCE_BYTES: usize = 256;
pub const MAX_EFFECTS: usize = 4096;
pub const MAX_NOTES: usize = 64;
pub const MAX_TEXT_BYTES: usize = 256;
pub const MAX_HISTORICAL_ITEMS: usize = 3 * crate::replay::MAX_BUNDLE_ITEMS + 4;
pub const AUTHORIZATION_REQUEST_TAG: &str = "rustev.live-authorization/1";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReferenceError {
pub len: usize,
}
impl fmt::Display for ReferenceError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"an authorization reference is 1 to {MAX_REFERENCE_BYTES} bytes, not {}",
self.len
)
}
}
impl std::error::Error for ReferenceError {}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct AuthorizationReference(String);
impl AuthorizationReference {
pub fn new(s: impl Into<String>) -> Result<Self, ReferenceError> {
let s = s.into();
if s.is_empty() || s.len() > MAX_REFERENCE_BYTES {
return Err(ReferenceError { len: s.len() });
}
Ok(Self(s))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Serialize for AuthorizationReference {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.0)
}
}
impl<'de> Deserialize<'de> for AuthorizationReference {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
AuthorizationReference::new(String::deserialize(d)?).map_err(serde::de::Error::custom)
}
}
impl schemars::JsonSchema for AuthorizationReference {
fn schema_name() -> std::borrow::Cow<'static, str> {
"AuthorizationReference".into()
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"type": "string",
"minLength": 1,
"maxLength": MAX_REFERENCE_BYTES
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum AttemptRole {
Primary,
Retry,
Fallback,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Exposure {
pub bound: CostBound,
pub reserved_units: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AuthorizationRequest {
pub decision_id: String,
pub subject: Handle,
pub scope: Scope,
pub step: String,
pub instance: Vec<String>,
pub operation: Operation,
pub target: u32,
pub role: AttemptRole,
pub backend_id: String,
pub artifact: ArtifactId,
pub request: RequestId,
pub attempt_id: String,
pub exposure: Exposure,
pub remaining_ms: u64,
}
impl AuthorizationRequest {
pub fn digest(&self) -> Result<ContentDigest, CanonicalError> {
Ok(ContentDigest::from_digest(tagged_digest(
AUTHORIZATION_REQUEST_TAG,
&canonical_bytes(self)?,
)))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum AuthorizationRefusal {
Missing,
Expired,
Mismatched,
Errored,
Refused,
}
impl AuthorizationRefusal {
pub const CODE: &'static str = "authorization_refused";
pub fn name(self) -> &'static str {
match self {
AuthorizationRefusal::Missing => "missing",
AuthorizationRefusal::Expired => "expired",
AuthorizationRefusal::Mismatched => "mismatched",
AuthorizationRefusal::Errored => "errored",
AuthorizationRefusal::Refused => "refused",
}
}
}
impl fmt::Display for AuthorizationRefusal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", Self::CODE, self.name())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum AuthorizationOutcome {
Allowed {
reference: AuthorizationReference,
},
Refused {
refusal: AuthorizationRefusal,
reference: Option<AuthorizationReference>,
detail: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum Dispatch {
NotDispatched,
Dispatched {
end: AttemptEnd,
cancellation: Cancellation,
remote: RemoteState,
charge: Charge,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct LiveEffect {
pub step: String,
pub instance: Vec<String>,
pub target: u32,
pub role: AttemptRole,
pub attempt_id: String,
pub backend_id: String,
pub artifact: ArtifactId,
pub request: RequestId,
pub presented: ContentDigest,
pub exposure: Exposure,
pub remaining_ms: u64,
pub authorization: AuthorizationOutcome,
pub dispatch: Dispatch,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct HistoricalLink {
pub decision_id: String,
pub plan_id: PlanId,
pub snapshot_id: SnapshotId,
pub evaluation_time_ms: Timestamp,
pub bundle_digest: ContentDigest,
}
impl HistoricalLink {
pub fn of(bundle: &ReplayBundle) -> Result<Self, CanonicalError> {
Ok(HistoricalLink {
decision_id: bundle.decision_id.clone(),
plan_id: bundle.plan_id.clone(),
snapshot_id: bundle.snapshot_id.clone(),
evaluation_time_ms: bundle.evaluation_time_ms,
bundle_digest: bundle.record_digest()?,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum Historical {
None,
Linked(HistoricalLink),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct RunLink {
pub termination: Termination,
pub cost: CostSummary,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Admission {
pub queued_ms: u64,
pub deadline_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum DeliveryObservation {
Acknowledged {
receipt: String,
},
Queued,
Dropped {
dropped_total: u64,
},
NotDelivered {
detail: String,
uncertain: bool,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum CapturePosture {
Off,
On {
scope: Scope,
max_bytes: u64,
status: CaptureStatus,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ItemObservation {
pub location: String,
pub availability: Availability,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum HistoricalRetention {
NoHistory,
NotObserved,
Observed {
observed_at_ms: Timestamp,
bundle_expires_at_ms: Timestamp,
unavailable: Vec<ItemObservation>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct RetentionPosture {
pub capture: CapturePosture,
pub historical: HistoricalRetention,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum Change {
Unchanged,
Changed,
Unknown,
}
impl Change {
pub fn of<T: PartialEq>(historical: &T, live: &T) -> Change {
if historical == live {
Change::Unchanged
} else {
Change::Changed
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ComparisonDefinition {
pub adapter: String,
pub adapter_version: String,
pub fields: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum ComparisonOutcome {
Agreement,
LiveDiverged,
Unavailable {
location: String,
availability: Availability,
},
Incomparable { reason: String },
}
impl ComparisonOutcome {
pub fn name(&self) -> &'static str {
match self {
ComparisonOutcome::Agreement => "agreement",
ComparisonOutcome::LiveDiverged => "live-diverged",
ComparisonOutcome::Unavailable { .. } => "unavailable",
ComparisonOutcome::Incomparable { .. } => "incomparable",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ResultChange {
Unchanged,
ProposalChanged,
UnresolvedCategoryChanged,
NotCompared,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct IdentityChanges {
pub snapshot: Change,
pub plan: Change,
pub evaluation_time: Change,
pub artifacts: Change,
pub served_identity: Change,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct RuntimeObservations {
pub attempts: u64,
pub retries: u64,
pub fallbacks: u64,
pub cancellations_requested: u64,
pub possibly_continuing: u64,
pub deadline_expired: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ObservationChanges {
pub backends: Change,
pub policy: Change,
pub execution_policy: Change,
pub calibration: Change,
pub historical_runtime: Option<RuntimeObservations>,
pub live_runtime: RuntimeObservations,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CostComparison {
pub historical: Option<CostSummary>,
pub live: CostSummary,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct OtherChanges {
pub termination: Change,
pub cancellation: Change,
pub remote_state: Change,
pub retention: Change,
pub delivery: Change,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct LiveComparison {
pub definition: ComparisonDefinition,
pub compared_at_ms: Timestamp,
pub outcome: ComparisonOutcome,
pub historical_result: Option<String>,
pub live_result: Option<String>,
pub result: ResultChange,
pub identities: IdentityChanges,
pub observations: ObservationChanges,
pub cost: CostComparison,
pub other: OtherChanges,
pub exclusions: Vec<String>,
pub limitations: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum ComparisonSection {
NotRequested,
Completed(Box<LiveComparison>),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct LiveReexecution {
pub schema: String,
pub decision_id: String,
pub plan_id: PlanId,
pub snapshot_id: SnapshotId,
pub evaluation_time_ms: Timestamp,
pub subject: Handle,
pub scope: Scope,
pub historical: Historical,
pub admission: Admission,
pub run_digest: Option<ContentDigest>,
pub run: RunLink,
pub effects: Vec<LiveEffect>,
pub unreconciled: Vec<String>,
pub delivery: DeliveryObservation,
pub retention: RetentionPosture,
pub comparison: ComparisonSection,
}
crate::document::document!(LiveReexecution, schema::LIVE_REEXECUTION, REPLAY_V1);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LiveDocError {
Schema(String),
DecisionId,
TooMany {
what: &'static str,
count: usize,
},
TextTooLong {
what: &'static str,
},
Attribution {
attempt_id: String,
},
}
impl fmt::Display for LiveDocError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
LiveDocError::Schema(s) => write!(f, "schema {s:?} is not rustev.live-reexecution/1"),
LiveDocError::DecisionId => {
f.write_str("the decision id is not a new decision id of 1 to 256 bytes")
}
LiveDocError::TooMany { what, count } => write!(f, "{count} {what} is too many"),
LiveDocError::TextTooLong { what } => write!(f, "{what} exceeds 256 bytes"),
LiveDocError::Attribution { attempt_id } => {
write!(f, "attempt {attempt_id:?} is not the new decision's")
}
}
}
}
impl std::error::Error for LiveDocError {}
fn count(what: &'static str, n: usize, max: usize) -> Result<(), LiveDocError> {
if n > max {
return Err(LiveDocError::TooMany { what, count: n });
}
Ok(())
}
fn text(what: &'static str, s: &str) -> Result<(), LiveDocError> {
if s.len() > MAX_TEXT_BYTES {
return Err(LiveDocError::TextTooLong { what });
}
Ok(())
}
pub fn attempt_prefix(decision_id: &str) -> String {
let mut out = String::with_capacity(decision_id.len() + 1);
for c in decision_id.chars() {
match c {
'%' => out.push_str("%25"),
'/' => out.push_str("%2F"),
',' => out.push_str("%2C"),
c => out.push(c),
}
}
out.push('/');
out
}
impl LiveReexecution {
pub fn check(&self) -> Result<(), LiveDocError> {
if self.schema != schema::LIVE_REEXECUTION {
return Err(LiveDocError::Schema(self.schema.chars().take(80).collect()));
}
if self.decision_id.is_empty() || self.decision_id.len() > 256 {
return Err(LiveDocError::DecisionId);
}
if let Historical::Linked(h) = &self.historical
&& (h.decision_id == self.decision_id
|| h.decision_id.is_empty()
|| h.decision_id.len() > 256)
{
return Err(LiveDocError::DecisionId);
}
count("effects", self.effects.len(), MAX_EFFECTS)?;
count(
"unreconciled attempts",
self.unreconciled.len(),
MAX_EFFECTS,
)?;
let prefix = attempt_prefix(&self.decision_id);
for e in &self.effects {
if !e.attempt_id.starts_with(&prefix) || e.attempt_id.len() > 4 * MAX_TEXT_BYTES {
return Err(LiveDocError::Attribution {
attempt_id: e.attempt_id.chars().take(80).collect(),
});
}
if let AuthorizationOutcome::Refused { detail, .. } = &e.authorization {
text("an authorization detail", detail)?;
}
if let Dispatch::Dispatched {
end: AttemptEnd::Failed { detail, .. },
..
} = &e.dispatch
{
text("an attempt detail", detail)?;
}
}
for a in &self.unreconciled {
if !a.starts_with(&prefix) || a.len() > 4 * MAX_TEXT_BYTES {
return Err(LiveDocError::Attribution {
attempt_id: a.chars().take(80).collect(),
});
}
}
match &self.delivery {
DeliveryObservation::Acknowledged { receipt } => text("a receipt", receipt)?,
DeliveryObservation::NotDelivered { detail, .. } => text("a delivery detail", detail)?,
DeliveryObservation::Queued | DeliveryObservation::Dropped { .. } => {}
}
if let HistoricalRetention::Observed { unavailable, .. } = &self.retention.historical {
count(
"historical item observations",
unavailable.len(),
MAX_HISTORICAL_ITEMS,
)?;
for i in unavailable {
text("an item location", &i.location)?;
}
}
if let ComparisonSection::Completed(c) = &self.comparison {
c.check()?;
}
Ok(())
}
pub fn binds(&self, run: &RunRecord) -> bool {
run.decision_id == self.decision_id
&& self.run_digest.is_some()
&& run.record_digest().ok() == self.run_digest
}
}
impl LiveComparison {
pub fn check(&self) -> Result<(), LiveDocError> {
let d = &self.definition;
text("the adapter name", &d.adapter)?;
text("the adapter version", &d.adapter_version)?;
count("compared fields", d.fields.len(), MAX_NOTES)?;
for f in &d.fields {
text("a compared field", f)?;
}
count("exclusions", self.exclusions.len(), MAX_NOTES)?;
count("limitations", self.limitations.len(), MAX_NOTES)?;
for n in self.exclusions.iter().chain(&self.limitations) {
text("a note", n)?;
}
for r in [&self.historical_result, &self.live_result]
.into_iter()
.flatten()
{
if r.len() > 64 * MAX_TEXT_BYTES {
return Err(LiveDocError::TextTooLong {
what: "an outcome name",
});
}
}
match &self.outcome {
ComparisonOutcome::Unavailable { location, .. } => text("a location", location),
ComparisonOutcome::Incomparable { reason } => text("a reason", reason),
ComparisonOutcome::Agreement | ComparisonOutcome::LiveDiverged => Ok(()),
}
}
}