use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub mod host_assets;
pub mod protocol;
pub mod router;
pub mod source_files;
pub mod telemetry;
pub const SCHEMA_VERSION: &str = "lifeloop.v0.1";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IntegrationMode {
ManualSkill,
LauncherWrapper,
NativeHook,
ReferenceAdapter,
TelemetryOnly,
}
impl IntegrationMode {
pub const ALL: &'static [Self] = &[
Self::ManualSkill,
Self::LauncherWrapper,
Self::NativeHook,
Self::ReferenceAdapter,
Self::TelemetryOnly,
];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SupportState {
Native,
Synthesized,
Manual,
Partial,
Unavailable,
}
impl SupportState {
pub const ALL: &'static [Self] = &[
Self::Native,
Self::Synthesized,
Self::Manual,
Self::Partial,
Self::Unavailable,
];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AdapterRole {
PrimaryWorker,
Worker,
Supervisor,
Observer,
}
impl AdapterRole {
pub const ALL: &'static [Self] = &[
Self::PrimaryWorker,
Self::Worker,
Self::Supervisor,
Self::Observer,
];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
pub enum LifecycleEventKind {
#[serde(rename = "session.starting")]
SessionStarting,
#[serde(rename = "session.started")]
SessionStarted,
#[serde(rename = "frame.opening")]
FrameOpening,
#[serde(rename = "frame.opened")]
FrameOpened,
#[serde(rename = "context.pressure_observed")]
ContextPressureObserved,
#[serde(rename = "context.compacted")]
ContextCompacted,
#[serde(rename = "frame.ending")]
FrameEnding,
#[serde(rename = "frame.ended")]
FrameEnded,
#[serde(rename = "session.ending")]
SessionEnding,
#[serde(rename = "session.ended")]
SessionEnded,
#[serde(rename = "supervisor.tick")]
SupervisorTick,
#[serde(rename = "capability.degraded")]
CapabilityDegraded,
#[serde(rename = "receipt.emitted")]
ReceiptEmitted,
#[serde(rename = "receipt.gap_detected")]
ReceiptGapDetected,
}
impl LifecycleEventKind {
pub const ALL: &'static [Self] = &[
Self::SessionStarting,
Self::SessionStarted,
Self::FrameOpening,
Self::FrameOpened,
Self::ContextPressureObserved,
Self::ContextCompacted,
Self::FrameEnding,
Self::FrameEnded,
Self::SessionEnding,
Self::SessionEnded,
Self::SupervisorTick,
Self::CapabilityDegraded,
Self::ReceiptEmitted,
Self::ReceiptGapDetected,
];
}
pub fn lifecycle_event_kinds() -> Vec<LifecycleEventKind> {
LifecycleEventKind::ALL.to_vec()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReceiptStatus {
Observed,
Delivered,
Skipped,
Degraded,
Failed,
}
impl ReceiptStatus {
pub const ALL: &'static [Self] = &[
Self::Observed,
Self::Delivered,
Self::Skipped,
Self::Degraded,
Self::Failed,
];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FailureClass {
AdapterUnavailable,
CapabilityUnsupported,
CapabilityDegraded,
PlacementUnavailable,
PayloadTooLarge,
PayloadRejected,
IdentityUnavailable,
TransportError,
Timeout,
OperatorRequired,
StateConflict,
InvalidRequest,
InternalError,
}
impl FailureClass {
pub const ALL: &'static [Self] = &[
Self::AdapterUnavailable,
Self::CapabilityUnsupported,
Self::CapabilityDegraded,
Self::PlacementUnavailable,
Self::PayloadTooLarge,
Self::PayloadRejected,
Self::IdentityUnavailable,
Self::TransportError,
Self::Timeout,
Self::OperatorRequired,
Self::StateConflict,
Self::InvalidRequest,
Self::InternalError,
];
pub fn default_retry(self) -> RetryClass {
match self {
Self::AdapterUnavailable => RetryClass::RetryAfterReconfigure,
Self::CapabilityUnsupported => RetryClass::DoNotRetry,
Self::CapabilityDegraded => RetryClass::RetryAfterReread,
Self::PlacementUnavailable => RetryClass::RetryAfterReconfigure,
Self::PayloadTooLarge => RetryClass::DoNotRetry,
Self::PayloadRejected => RetryClass::RetryAfterReconfigure,
Self::IdentityUnavailable => RetryClass::RetryAfterReconfigure,
Self::TransportError => RetryClass::SafeRetry,
Self::Timeout => RetryClass::SafeRetry,
Self::OperatorRequired => RetryClass::RetryAfterOperator,
Self::StateConflict => RetryClass::RetryAfterReread,
Self::InvalidRequest => RetryClass::DoNotRetry,
Self::InternalError => RetryClass::RetryAfterReread,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RetryClass {
SafeRetry,
RetryAfterReread,
RetryAfterReconfigure,
RetryAfterOperator,
DoNotRetry,
}
impl RetryClass {
pub const ALL: &'static [Self] = &[
Self::SafeRetry,
Self::RetryAfterReread,
Self::RetryAfterReconfigure,
Self::RetryAfterOperator,
Self::DoNotRetry,
];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlacementClass {
DeveloperEquivalentFrame,
PrePromptFrame,
SideChannelContext,
ReceiptOnly,
}
impl PlacementClass {
pub const ALL: &'static [Self] = &[
Self::DeveloperEquivalentFrame,
Self::PrePromptFrame,
Self::SideChannelContext,
Self::ReceiptOnly,
];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlacementOutcome {
Delivered,
Skipped,
Degraded,
Failed,
}
impl PlacementOutcome {
pub const ALL: &'static [Self] =
&[Self::Delivered, Self::Skipped, Self::Degraded, Self::Failed];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RequirementLevel {
Required,
Preferred,
Optional,
}
impl RequirementLevel {
pub const ALL: &'static [Self] = &[Self::Required, Self::Preferred, Self::Optional];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NegotiationOutcome {
Satisfied,
Degraded,
Unsupported,
RequiresOperator,
}
impl NegotiationOutcome {
pub const ALL: &'static [Self] = &[
Self::Satisfied,
Self::Degraded,
Self::Unsupported,
Self::RequiresOperator,
];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FrameClass {
TopLevel,
Subcall,
}
impl FrameClass {
pub const ALL: &'static [Self] = &[Self::TopLevel, Self::Subcall];
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind", content = "detail")]
pub enum ValidationError {
EmptyField(String),
SchemaVersionMismatch { expected: String, found: String },
InvalidFrameContext(String),
InvalidPayload(String),
InvalidReceipt(String),
InvalidRequest(String),
InvalidResponse(String),
InvalidManifest(String),
}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::EmptyField(name) => write!(f, "empty sentinel string in field `{name}`"),
Self::SchemaVersionMismatch { expected, found } => write!(
f,
"schema_version mismatch: expected `{expected}`, found `{found}`"
),
Self::InvalidFrameContext(msg) => write!(f, "invalid frame_context: {msg}"),
Self::InvalidPayload(msg) => write!(f, "invalid payload: {msg}"),
Self::InvalidReceipt(msg) => write!(f, "invalid receipt: {msg}"),
Self::InvalidRequest(msg) => write!(f, "invalid callback request: {msg}"),
Self::InvalidResponse(msg) => write!(f, "invalid callback response: {msg}"),
Self::InvalidManifest(msg) => write!(f, "invalid adapter manifest: {msg}"),
}
}
}
impl std::error::Error for ValidationError {}
fn require_non_empty(value: &str, field: &'static str) -> Result<(), ValidationError> {
if value.is_empty() {
return Err(ValidationError::EmptyField(field.to_string()));
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FrameContext {
pub frame_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_frame_id: Option<String>,
pub frame_class: FrameClass,
}
impl FrameContext {
pub fn top_level(frame_id: impl Into<String>) -> Self {
Self {
frame_id: frame_id.into(),
parent_frame_id: None,
frame_class: FrameClass::TopLevel,
}
}
pub fn subcall(frame_id: impl Into<String>, parent_frame_id: impl Into<String>) -> Self {
Self {
frame_id: frame_id.into(),
parent_frame_id: Some(parent_frame_id.into()),
frame_class: FrameClass::Subcall,
}
}
pub fn validate(&self) -> Result<(), ValidationError> {
require_non_empty(&self.frame_id, "frame_context.frame_id")?;
if let Some(parent) = &self.parent_frame_id {
require_non_empty(parent, "frame_context.parent_frame_id")?;
}
match (self.frame_class, &self.parent_frame_id) {
(FrameClass::TopLevel, Some(_)) => Err(ValidationError::InvalidFrameContext(
"frame_class=top_level must not carry parent_frame_id".into(),
)),
(FrameClass::Subcall, None) => Err(ValidationError::InvalidFrameContext(
"frame_class=subcall requires parent_frame_id".into(),
)),
_ => Ok(()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AcceptablePlacement {
pub placement: PlacementClass,
pub requirement: RequirementLevel,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PayloadRef {
pub payload_id: String,
pub payload_kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_digest: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub byte_size: Option<u64>,
}
impl PayloadRef {
pub fn validate(&self) -> Result<(), ValidationError> {
require_non_empty(&self.payload_id, "payload_ref.payload_id")?;
require_non_empty(&self.payload_kind, "payload_ref.payload_kind")?;
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PayloadEnvelope {
pub schema_version: String,
pub payload_id: String,
pub client_id: String,
pub payload_kind: String,
pub format: String,
pub content_encoding: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body_ref: Option<String>,
pub byte_size: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_digest: Option<String>,
pub acceptable_placements: Vec<AcceptablePlacement>,
#[serde(skip_serializing_if = "Option::is_none")]
pub idempotency_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at_epoch_s: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub redaction: Option<String>,
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub metadata: serde_json::Map<String, serde_json::Value>,
}
impl PayloadEnvelope {
pub fn validate(&self) -> Result<(), ValidationError> {
if self.schema_version != SCHEMA_VERSION {
return Err(ValidationError::SchemaVersionMismatch {
expected: SCHEMA_VERSION.to_string(),
found: self.schema_version.clone(),
});
}
require_non_empty(&self.payload_id, "payload.payload_id")?;
require_non_empty(&self.client_id, "payload.client_id")?;
require_non_empty(&self.payload_kind, "payload.payload_kind")?;
require_non_empty(&self.format, "payload.format")?;
require_non_empty(&self.content_encoding, "payload.content_encoding")?;
match (self.body.is_some(), self.body_ref.is_some()) {
(true, true) => Err(ValidationError::InvalidPayload(
"body and body_ref are mutually exclusive".into(),
)),
(false, false) => Err(ValidationError::InvalidPayload(
"exactly one of body or body_ref must be present".into(),
)),
_ => Ok(()),
}?;
if let Some(idem) = &self.idempotency_key {
require_non_empty(idem, "payload.idempotency_key")?;
}
if self.acceptable_placements.is_empty() {
return Err(ValidationError::InvalidPayload(
"acceptable_placements must list at least one placement".into(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PayloadReceipt {
pub payload_id: String,
pub placement: PlacementClass,
pub status: PlacementOutcome,
pub byte_size: u64,
}
impl PayloadReceipt {
pub fn validate(&self) -> Result<(), ValidationError> {
require_non_empty(&self.payload_id, "payload_receipt.payload_id")?;
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CapabilityDegradation {
pub capability: String,
pub previous_support: SupportState,
pub current_support: SupportState,
#[serde(skip_serializing_if = "Option::is_none")]
pub evidence: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub retry_class: Option<RetryClass>,
}
impl CapabilityDegradation {
pub fn validate(&self) -> Result<(), ValidationError> {
require_non_empty(&self.capability, "capability_degradation.capability")?;
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Warning {
pub code: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub capability: Option<String>,
}
impl Warning {
pub fn validate(&self) -> Result<(), ValidationError> {
require_non_empty(&self.code, "warning.code")?;
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct LifecycleReceipt {
pub schema_version: String,
pub receipt_id: String,
pub idempotency_key: Option<String>,
pub client_id: String,
pub adapter_id: String,
pub invocation_id: String,
pub event: LifecycleEventKind,
pub event_id: String,
pub sequence: Option<u64>,
pub parent_receipt_id: Option<String>,
pub integration_mode: IntegrationMode,
pub status: ReceiptStatus,
pub at_epoch_s: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub harness_session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub harness_run_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub harness_task_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub payload_receipts: Vec<PayloadReceipt>,
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub telemetry_summary: serde_json::Map<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub capability_degradations: Vec<CapabilityDegradation>,
pub failure_class: Option<FailureClass>,
pub retry_class: Option<RetryClass>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<Warning>,
}
impl LifecycleReceipt {
pub const REQUIRED_NULLABLE_FIELDS: &'static [&'static str] = &[
"idempotency_key",
"sequence",
"parent_receipt_id",
"failure_class",
"retry_class",
];
}
impl<'de> Deserialize<'de> for LifecycleReceipt {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
deserializer.deserialize_struct(
"LifecycleReceipt",
LIFECYCLE_RECEIPT_FIELDS,
LifecycleReceiptVisitor,
)
}
}
const LIFECYCLE_RECEIPT_FIELDS: &[&str] = &[
"schema_version",
"receipt_id",
"idempotency_key",
"client_id",
"adapter_id",
"invocation_id",
"event",
"event_id",
"sequence",
"parent_receipt_id",
"integration_mode",
"status",
"at_epoch_s",
"harness_session_id",
"harness_run_id",
"harness_task_id",
"payload_receipts",
"telemetry_summary",
"capability_degradations",
"failure_class",
"retry_class",
"warnings",
];
#[derive(Deserialize)]
#[serde(field_identifier, rename_all = "snake_case")]
enum LifecycleReceiptField {
SchemaVersion,
ReceiptId,
IdempotencyKey,
ClientId,
AdapterId,
InvocationId,
Event,
EventId,
Sequence,
ParentReceiptId,
IntegrationMode,
Status,
AtEpochS,
HarnessSessionId,
HarnessRunId,
HarnessTaskId,
PayloadReceipts,
TelemetrySummary,
CapabilityDegradations,
FailureClass,
RetryClass,
Warnings,
}
struct LifecycleReceiptVisitor;
impl<'de> serde::de::Visitor<'de> for LifecycleReceiptVisitor {
type Value = LifecycleReceipt;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a LifecycleReceipt object")
}
fn visit_map<A>(self, mut map: A) -> Result<LifecycleReceipt, A::Error>
where
A: serde::de::MapAccess<'de>,
{
let mut schema_version: Option<String> = None;
let mut receipt_id: Option<String> = None;
let mut idempotency_key: Option<Option<String>> = None;
let mut client_id: Option<String> = None;
let mut adapter_id: Option<String> = None;
let mut invocation_id: Option<String> = None;
let mut event: Option<LifecycleEventKind> = None;
let mut event_id: Option<String> = None;
let mut sequence: Option<Option<u64>> = None;
let mut parent_receipt_id: Option<Option<String>> = None;
let mut integration_mode: Option<IntegrationMode> = None;
let mut status: Option<ReceiptStatus> = None;
let mut at_epoch_s: Option<u64> = None;
let mut harness_session_id: Option<Option<String>> = None;
let mut harness_run_id: Option<Option<String>> = None;
let mut harness_task_id: Option<Option<String>> = None;
let mut payload_receipts: Option<Vec<PayloadReceipt>> = None;
let mut telemetry_summary: Option<serde_json::Map<String, serde_json::Value>> = None;
let mut capability_degradations: Option<Vec<CapabilityDegradation>> = None;
let mut failure_class: Option<Option<FailureClass>> = None;
let mut retry_class: Option<Option<RetryClass>> = None;
let mut warnings: Option<Vec<Warning>> = None;
while let Some(field) = map.next_key::<LifecycleReceiptField>()? {
match field {
LifecycleReceiptField::SchemaVersion => {
set_once(&mut schema_version, &mut map, "schema_version")?;
}
LifecycleReceiptField::ReceiptId => {
set_once(&mut receipt_id, &mut map, "receipt_id")?;
}
LifecycleReceiptField::IdempotencyKey => {
set_once(&mut idempotency_key, &mut map, "idempotency_key")?;
}
LifecycleReceiptField::ClientId => {
set_once(&mut client_id, &mut map, "client_id")?;
}
LifecycleReceiptField::AdapterId => {
set_once(&mut adapter_id, &mut map, "adapter_id")?;
}
LifecycleReceiptField::InvocationId => {
set_once(&mut invocation_id, &mut map, "invocation_id")?;
}
LifecycleReceiptField::Event => {
set_once(&mut event, &mut map, "event")?;
}
LifecycleReceiptField::EventId => {
set_once(&mut event_id, &mut map, "event_id")?;
}
LifecycleReceiptField::Sequence => {
set_once(&mut sequence, &mut map, "sequence")?;
}
LifecycleReceiptField::ParentReceiptId => {
set_once(&mut parent_receipt_id, &mut map, "parent_receipt_id")?;
}
LifecycleReceiptField::IntegrationMode => {
set_once(&mut integration_mode, &mut map, "integration_mode")?;
}
LifecycleReceiptField::Status => {
set_once(&mut status, &mut map, "status")?;
}
LifecycleReceiptField::AtEpochS => {
set_once(&mut at_epoch_s, &mut map, "at_epoch_s")?;
}
LifecycleReceiptField::HarnessSessionId => {
set_once(&mut harness_session_id, &mut map, "harness_session_id")?;
}
LifecycleReceiptField::HarnessRunId => {
set_once(&mut harness_run_id, &mut map, "harness_run_id")?;
}
LifecycleReceiptField::HarnessTaskId => {
set_once(&mut harness_task_id, &mut map, "harness_task_id")?;
}
LifecycleReceiptField::PayloadReceipts => {
set_once(&mut payload_receipts, &mut map, "payload_receipts")?;
}
LifecycleReceiptField::TelemetrySummary => {
set_once(&mut telemetry_summary, &mut map, "telemetry_summary")?;
}
LifecycleReceiptField::CapabilityDegradations => {
set_once(
&mut capability_degradations,
&mut map,
"capability_degradations",
)?;
}
LifecycleReceiptField::FailureClass => {
set_once(&mut failure_class, &mut map, "failure_class")?;
}
LifecycleReceiptField::RetryClass => {
set_once(&mut retry_class, &mut map, "retry_class")?;
}
LifecycleReceiptField::Warnings => {
set_once(&mut warnings, &mut map, "warnings")?;
}
}
}
let mut missing_required_nullable: Vec<&'static str> = Vec::new();
if idempotency_key.is_none() {
missing_required_nullable.push("idempotency_key");
}
if sequence.is_none() {
missing_required_nullable.push("sequence");
}
if parent_receipt_id.is_none() {
missing_required_nullable.push("parent_receipt_id");
}
if failure_class.is_none() {
missing_required_nullable.push("failure_class");
}
if retry_class.is_none() {
missing_required_nullable.push("retry_class");
}
if !missing_required_nullable.is_empty() {
return Err(serde::de::Error::custom(format!(
"LifecycleReceipt is missing required-nullable field(s): {}; \
these keys MUST be present even when their value is null",
missing_required_nullable.join(", ")
)));
}
Ok(LifecycleReceipt {
schema_version: schema_version
.ok_or_else(|| serde::de::Error::missing_field("schema_version"))?,
receipt_id: receipt_id.ok_or_else(|| serde::de::Error::missing_field("receipt_id"))?,
idempotency_key: idempotency_key.expect("checked above"),
client_id: client_id.ok_or_else(|| serde::de::Error::missing_field("client_id"))?,
adapter_id: adapter_id.ok_or_else(|| serde::de::Error::missing_field("adapter_id"))?,
invocation_id: invocation_id
.ok_or_else(|| serde::de::Error::missing_field("invocation_id"))?,
event: event.ok_or_else(|| serde::de::Error::missing_field("event"))?,
event_id: event_id.ok_or_else(|| serde::de::Error::missing_field("event_id"))?,
sequence: sequence.expect("checked above"),
parent_receipt_id: parent_receipt_id.expect("checked above"),
integration_mode: integration_mode
.ok_or_else(|| serde::de::Error::missing_field("integration_mode"))?,
status: status.ok_or_else(|| serde::de::Error::missing_field("status"))?,
at_epoch_s: at_epoch_s.ok_or_else(|| serde::de::Error::missing_field("at_epoch_s"))?,
harness_session_id: harness_session_id.unwrap_or(None),
harness_run_id: harness_run_id.unwrap_or(None),
harness_task_id: harness_task_id.unwrap_or(None),
payload_receipts: payload_receipts.unwrap_or_default(),
telemetry_summary: telemetry_summary.unwrap_or_default(),
capability_degradations: capability_degradations.unwrap_or_default(),
failure_class: failure_class.expect("checked above"),
retry_class: retry_class.expect("checked above"),
warnings: warnings.unwrap_or_default(),
})
}
}
fn set_once<'de, T, A>(
slot: &mut Option<T>,
map: &mut A,
field: &'static str,
) -> Result<(), A::Error>
where
T: serde::Deserialize<'de>,
A: serde::de::MapAccess<'de>,
{
if slot.is_some() {
return Err(serde::de::Error::duplicate_field(field));
}
*slot = Some(map.next_value()?);
Ok(())
}
impl LifecycleReceipt {
pub fn validate(&self) -> Result<(), ValidationError> {
if self.schema_version != SCHEMA_VERSION {
return Err(ValidationError::SchemaVersionMismatch {
expected: SCHEMA_VERSION.to_string(),
found: self.schema_version.clone(),
});
}
require_non_empty(&self.receipt_id, "receipt.receipt_id")?;
require_non_empty(&self.client_id, "receipt.client_id")?;
require_non_empty(&self.adapter_id, "receipt.adapter_id")?;
require_non_empty(&self.invocation_id, "receipt.invocation_id")?;
require_non_empty(&self.event_id, "receipt.event_id")?;
if let Some(idem) = &self.idempotency_key {
require_non_empty(idem, "receipt.idempotency_key")?;
}
if let Some(parent) = &self.parent_receipt_id {
require_non_empty(parent, "receipt.parent_receipt_id")?;
}
if matches!(self.event, LifecycleEventKind::ReceiptEmitted) {
return Err(ValidationError::InvalidReceipt(
"receipt.emitted is a notification event and must not itself produce a receipt"
.into(),
));
}
for pr in &self.payload_receipts {
pr.validate()?;
}
for deg in &self.capability_degradations {
deg.validate()?;
}
for w in &self.warnings {
w.validate()?;
}
match (
matches!(self.status, ReceiptStatus::Failed),
self.failure_class.is_some(),
) {
(true, false) => {
return Err(ValidationError::InvalidReceipt(
"status=failed requires failure_class".into(),
));
}
(false, true) => {
return Err(ValidationError::InvalidReceipt(
"failure_class is only valid on status=failed receipts".into(),
));
}
_ => {}
}
if matches!(self.status, ReceiptStatus::Failed) && self.retry_class.is_none() {
return Err(ValidationError::InvalidReceipt(
"status=failed requires retry_class (clients must declare retry posture)".into(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ManifestPlacementClass {
PreSession,
PreFrameLeading,
PreFrameTrailing,
ToolResult,
ManualOperator,
}
impl ManifestPlacementClass {
pub const ALL: &'static [Self] = &[
Self::PreSession,
Self::PreFrameLeading,
Self::PreFrameTrailing,
Self::ToolResult,
Self::ManualOperator,
];
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestLifecycleEventSupport {
pub support: SupportState,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub modes: Vec<IntegrationMode>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestPlacementSupport {
pub support: SupportState,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_bytes: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestContextPressure {
pub support: SupportState,
#[serde(skip_serializing_if = "Option::is_none")]
pub evidence: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestReceipts {
pub native: bool,
pub lifeloop_synthesized: bool,
pub receipt_ledger: SupportState,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestSessionIdentity {
pub harness_session_id: SupportState,
pub harness_run_id: SupportState,
pub harness_task_id: SupportState,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestSessionRename {
pub support: SupportState,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestApprovalSurface {
pub support: SupportState,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestTelemetrySource {
pub source: String,
pub support: SupportState,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestKnownDegradation {
pub capability: String,
pub previous_support: SupportState,
pub current_support: SupportState,
#[serde(skip_serializing_if = "Option::is_none")]
pub evidence: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AdapterManifest {
pub contract_version: String,
pub adapter_id: String,
pub adapter_version: String,
pub display_name: String,
pub role: AdapterRole,
pub integration_modes: Vec<IntegrationMode>,
pub lifecycle_events: BTreeMap<LifecycleEventKind, ManifestLifecycleEventSupport>,
pub placement: BTreeMap<ManifestPlacementClass, ManifestPlacementSupport>,
pub context_pressure: ManifestContextPressure,
pub receipts: ManifestReceipts,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_identity: Option<ManifestSessionIdentity>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_rename: Option<ManifestSessionRename>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approval_surface: Option<ManifestApprovalSurface>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub failure_modes: Vec<FailureClass>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub telemetry_sources: Vec<ManifestTelemetrySource>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub known_degradations: Vec<ManifestKnownDegradation>,
}
impl AdapterManifest {
pub fn validate(&self) -> Result<(), ValidationError> {
if self.contract_version != SCHEMA_VERSION {
return Err(ValidationError::SchemaVersionMismatch {
expected: SCHEMA_VERSION.to_string(),
found: self.contract_version.clone(),
});
}
require_non_empty(&self.adapter_id, "manifest.adapter_id")?;
require_non_empty(&self.adapter_version, "manifest.adapter_version")?;
require_non_empty(&self.display_name, "manifest.display_name")?;
if self.integration_modes.is_empty() {
return Err(ValidationError::InvalidManifest(
"manifest.integration_modes must declare at least one integration mode".into(),
));
}
for deg in &self.known_degradations {
require_non_empty(°.capability, "manifest.known_degradations[].capability")?;
}
for src in &self.telemetry_sources {
require_non_empty(&src.source, "manifest.telemetry_sources[].source")?;
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConformanceLevel {
V1Conformance,
PreConformance,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RegisteredAdapter {
pub manifest: AdapterManifest,
pub conformance: ConformanceLevel,
}
pub fn manifest_registry() -> Vec<RegisteredAdapter> {
vec![
RegisteredAdapter {
manifest: codex_manifest(),
conformance: ConformanceLevel::V1Conformance,
},
RegisteredAdapter {
manifest: claude_manifest(),
conformance: ConformanceLevel::V1Conformance,
},
RegisteredAdapter {
manifest: hermes_manifest(),
conformance: ConformanceLevel::PreConformance,
},
RegisteredAdapter {
manifest: openclaw_manifest(),
conformance: ConformanceLevel::PreConformance,
},
RegisteredAdapter {
manifest: gemini_manifest(),
conformance: ConformanceLevel::PreConformance,
},
RegisteredAdapter {
manifest: opencode_manifest(),
conformance: ConformanceLevel::PreConformance,
},
]
}
pub fn lookup_manifest(adapter_id: &str) -> Option<RegisteredAdapter> {
manifest_registry()
.into_iter()
.find(|entry| entry.manifest.adapter_id == adapter_id)
}
fn synthesized() -> SupportState {
SupportState::Synthesized
}
fn native() -> SupportState {
SupportState::Native
}
fn unavailable() -> SupportState {
SupportState::Unavailable
}
fn manual() -> SupportState {
SupportState::Manual
}
pub fn codex_manifest() -> AdapterManifest {
let lifecycle_events = BTreeMap::from([
(
LifecycleEventKind::SessionStarting,
ManifestLifecycleEventSupport {
support: native(),
modes: vec![IntegrationMode::NativeHook],
},
),
(
LifecycleEventKind::SessionStarted,
ManifestLifecycleEventSupport {
support: native(),
modes: vec![IntegrationMode::NativeHook],
},
),
(
LifecycleEventKind::FrameOpening,
ManifestLifecycleEventSupport {
support: native(),
modes: vec![IntegrationMode::NativeHook],
},
),
(
LifecycleEventKind::FrameOpened,
ManifestLifecycleEventSupport {
support: synthesized(),
modes: vec![IntegrationMode::NativeHook],
},
),
(
LifecycleEventKind::ContextPressureObserved,
ManifestLifecycleEventSupport {
support: native(),
modes: vec![IntegrationMode::NativeHook],
},
),
(
LifecycleEventKind::ContextCompacted,
ManifestLifecycleEventSupport {
support: native(),
modes: vec![IntegrationMode::NativeHook],
},
),
(
LifecycleEventKind::FrameEnding,
ManifestLifecycleEventSupport {
support: native(),
modes: vec![IntegrationMode::NativeHook],
},
),
(
LifecycleEventKind::FrameEnded,
ManifestLifecycleEventSupport {
support: native(),
modes: vec![IntegrationMode::NativeHook],
},
),
(
LifecycleEventKind::SessionEnding,
ManifestLifecycleEventSupport {
support: unavailable(),
modes: Vec::new(),
},
),
(
LifecycleEventKind::SessionEnded,
ManifestLifecycleEventSupport {
support: unavailable(),
modes: Vec::new(),
},
),
(
LifecycleEventKind::SupervisorTick,
ManifestLifecycleEventSupport {
support: unavailable(),
modes: Vec::new(),
},
),
(
LifecycleEventKind::CapabilityDegraded,
ManifestLifecycleEventSupport {
support: synthesized(),
modes: vec![IntegrationMode::NativeHook],
},
),
(
LifecycleEventKind::ReceiptEmitted,
ManifestLifecycleEventSupport {
support: synthesized(),
modes: vec![IntegrationMode::NativeHook],
},
),
(
LifecycleEventKind::ReceiptGapDetected,
ManifestLifecycleEventSupport {
support: unavailable(),
modes: Vec::new(),
},
),
]);
let placement = BTreeMap::from([
(
ManifestPlacementClass::PreSession,
ManifestPlacementSupport {
support: native(),
max_bytes: Some(8192),
},
),
(
ManifestPlacementClass::PreFrameLeading,
ManifestPlacementSupport {
support: native(),
max_bytes: Some(8192),
},
),
(
ManifestPlacementClass::PreFrameTrailing,
ManifestPlacementSupport {
support: unavailable(),
max_bytes: None,
},
),
(
ManifestPlacementClass::ToolResult,
ManifestPlacementSupport {
support: unavailable(),
max_bytes: None,
},
),
(
ManifestPlacementClass::ManualOperator,
ManifestPlacementSupport {
support: manual(),
max_bytes: None,
},
),
]);
AdapterManifest {
contract_version: SCHEMA_VERSION.to_string(),
adapter_id: "codex".into(),
adapter_version: "0.1.0".into(),
display_name: "Codex".into(),
role: AdapterRole::PrimaryWorker,
integration_modes: vec![IntegrationMode::NativeHook, IntegrationMode::ManualSkill],
lifecycle_events,
placement,
context_pressure: ManifestContextPressure {
support: native(),
evidence: Some(
"Codex CLI 0.129 exposes PreCompact before context pressure handling and PostCompact after context compacts"
.into(),
),
},
receipts: ManifestReceipts {
native: false,
lifeloop_synthesized: true,
receipt_ledger: unavailable(),
},
session_identity: Some(ManifestSessionIdentity {
harness_session_id: native(),
harness_run_id: synthesized(),
harness_task_id: unavailable(),
}),
session_rename: None,
approval_surface: None,
failure_modes: vec![FailureClass::TransportError, FailureClass::PayloadTooLarge],
telemetry_sources: Vec::new(),
known_degradations: Vec::new(),
}
}
pub fn claude_manifest() -> AdapterManifest {
let lifecycle_events = BTreeMap::from([
(
LifecycleEventKind::SessionStarting,
ManifestLifecycleEventSupport {
support: native(),
modes: vec![IntegrationMode::NativeHook],
},
),
(
LifecycleEventKind::SessionStarted,
ManifestLifecycleEventSupport {
support: native(),
modes: vec![IntegrationMode::NativeHook],
},
),
(
LifecycleEventKind::FrameOpening,
ManifestLifecycleEventSupport {
support: native(),
modes: vec![IntegrationMode::NativeHook],
},
),
(
LifecycleEventKind::FrameOpened,
ManifestLifecycleEventSupport {
support: native(),
modes: vec![IntegrationMode::NativeHook],
},
),
(
LifecycleEventKind::ContextPressureObserved,
ManifestLifecycleEventSupport {
support: native(),
modes: vec![IntegrationMode::NativeHook],
},
),
(
LifecycleEventKind::ContextCompacted,
ManifestLifecycleEventSupport {
support: unavailable(),
modes: Vec::new(),
},
),
(
LifecycleEventKind::FrameEnding,
ManifestLifecycleEventSupport {
support: native(),
modes: vec![IntegrationMode::NativeHook],
},
),
(
LifecycleEventKind::FrameEnded,
ManifestLifecycleEventSupport {
support: native(),
modes: vec![IntegrationMode::NativeHook],
},
),
(
LifecycleEventKind::SessionEnding,
ManifestLifecycleEventSupport {
support: native(),
modes: vec![IntegrationMode::NativeHook],
},
),
(
LifecycleEventKind::SessionEnded,
ManifestLifecycleEventSupport {
support: native(),
modes: vec![IntegrationMode::NativeHook],
},
),
(
LifecycleEventKind::SupervisorTick,
ManifestLifecycleEventSupport {
support: unavailable(),
modes: Vec::new(),
},
),
(
LifecycleEventKind::CapabilityDegraded,
ManifestLifecycleEventSupport {
support: synthesized(),
modes: vec![IntegrationMode::NativeHook],
},
),
(
LifecycleEventKind::ReceiptEmitted,
ManifestLifecycleEventSupport {
support: synthesized(),
modes: vec![IntegrationMode::NativeHook],
},
),
(
LifecycleEventKind::ReceiptGapDetected,
ManifestLifecycleEventSupport {
support: unavailable(),
modes: Vec::new(),
},
),
]);
let placement = BTreeMap::from([
(
ManifestPlacementClass::PreSession,
ManifestPlacementSupport {
support: native(),
max_bytes: Some(16_384),
},
),
(
ManifestPlacementClass::PreFrameLeading,
ManifestPlacementSupport {
support: native(),
max_bytes: Some(16_384),
},
),
(
ManifestPlacementClass::PreFrameTrailing,
ManifestPlacementSupport {
support: unavailable(),
max_bytes: None,
},
),
(
ManifestPlacementClass::ToolResult,
ManifestPlacementSupport {
support: unavailable(),
max_bytes: None,
},
),
(
ManifestPlacementClass::ManualOperator,
ManifestPlacementSupport {
support: manual(),
max_bytes: None,
},
),
]);
AdapterManifest {
contract_version: SCHEMA_VERSION.to_string(),
adapter_id: "claude".into(),
adapter_version: "0.1.0".into(),
display_name: "Claude".into(),
role: AdapterRole::PrimaryWorker,
integration_modes: vec![IntegrationMode::NativeHook],
lifecycle_events,
placement,
context_pressure: ManifestContextPressure {
support: native(),
evidence: Some(
"Claude emits PreCompact and SessionEnd events that map directly to context.pressure_observed"
.into(),
),
},
receipts: ManifestReceipts {
native: false,
lifeloop_synthesized: true,
receipt_ledger: unavailable(),
},
session_identity: Some(ManifestSessionIdentity {
harness_session_id: native(),
harness_run_id: synthesized(),
harness_task_id: unavailable(),
}),
session_rename: None,
approval_surface: None,
failure_modes: vec![FailureClass::TransportError, FailureClass::PayloadTooLarge],
telemetry_sources: Vec::new(),
known_degradations: Vec::new(),
}
}
pub fn hermes_manifest() -> AdapterManifest {
pre_conformance_reference_adapter_manifest("hermes", "Hermes")
}
pub fn openclaw_manifest() -> AdapterManifest {
pre_conformance_reference_adapter_manifest("openclaw", "OpenClaw")
}
pub fn gemini_manifest() -> AdapterManifest {
pre_conformance_telemetry_only_manifest("gemini", "Gemini")
}
pub fn opencode_manifest() -> AdapterManifest {
pre_conformance_telemetry_only_manifest("opencode", "OpenCode")
}
fn pre_conformance_reference_adapter_manifest(
adapter_id: &str,
display_name: &str,
) -> AdapterManifest {
let lifecycle_events = BTreeMap::from([
(
LifecycleEventKind::SessionStarting,
ManifestLifecycleEventSupport {
support: SupportState::Partial,
modes: vec![IntegrationMode::ReferenceAdapter],
},
),
(
LifecycleEventKind::SessionStarted,
ManifestLifecycleEventSupport {
support: SupportState::Partial,
modes: vec![IntegrationMode::ReferenceAdapter],
},
),
(
LifecycleEventKind::FrameOpening,
ManifestLifecycleEventSupport {
support: SupportState::Partial,
modes: vec![IntegrationMode::ReferenceAdapter],
},
),
(
LifecycleEventKind::FrameEnded,
ManifestLifecycleEventSupport {
support: SupportState::Partial,
modes: vec![IntegrationMode::ReferenceAdapter],
},
),
(
LifecycleEventKind::SessionEnded,
ManifestLifecycleEventSupport {
support: SupportState::Partial,
modes: vec![IntegrationMode::ReferenceAdapter],
},
),
]);
let placement = BTreeMap::from([
(
ManifestPlacementClass::PreSession,
ManifestPlacementSupport {
support: SupportState::Partial,
max_bytes: None,
},
),
(
ManifestPlacementClass::PreFrameLeading,
ManifestPlacementSupport {
support: SupportState::Partial,
max_bytes: None,
},
),
(
ManifestPlacementClass::ManualOperator,
ManifestPlacementSupport {
support: SupportState::Manual,
max_bytes: None,
},
),
]);
AdapterManifest {
contract_version: SCHEMA_VERSION.to_string(),
adapter_id: adapter_id.to_string(),
adapter_version: "0.0.1-pre".into(),
display_name: display_name.to_string(),
role: AdapterRole::Worker,
integration_modes: vec![IntegrationMode::ReferenceAdapter],
lifecycle_events,
placement,
context_pressure: ManifestContextPressure {
support: SupportState::Partial,
evidence: None,
},
receipts: ManifestReceipts {
native: false,
lifeloop_synthesized: true,
receipt_ledger: SupportState::Unavailable,
},
session_identity: None,
session_rename: None,
approval_surface: None,
failure_modes: Vec::new(),
telemetry_sources: Vec::new(),
known_degradations: Vec::new(),
}
}
fn pre_conformance_telemetry_only_manifest(
adapter_id: &str,
display_name: &str,
) -> AdapterManifest {
let lifecycle_events = BTreeMap::from([
(
LifecycleEventKind::SessionStarting,
ManifestLifecycleEventSupport {
support: SupportState::Partial,
modes: vec![IntegrationMode::TelemetryOnly],
},
),
(
LifecycleEventKind::ContextPressureObserved,
ManifestLifecycleEventSupport {
support: SupportState::Partial,
modes: vec![IntegrationMode::TelemetryOnly],
},
),
(
LifecycleEventKind::SessionEnded,
ManifestLifecycleEventSupport {
support: SupportState::Partial,
modes: vec![IntegrationMode::TelemetryOnly],
},
),
]);
let placement = BTreeMap::from([(
ManifestPlacementClass::ManualOperator,
ManifestPlacementSupport {
support: SupportState::Manual,
max_bytes: None,
},
)]);
AdapterManifest {
contract_version: SCHEMA_VERSION.to_string(),
adapter_id: adapter_id.to_string(),
adapter_version: "0.0.1-pre".into(),
display_name: display_name.to_string(),
role: AdapterRole::Observer,
integration_modes: vec![IntegrationMode::TelemetryOnly],
lifecycle_events,
placement,
context_pressure: ManifestContextPressure {
support: SupportState::Partial,
evidence: None,
},
receipts: ManifestReceipts {
native: false,
lifeloop_synthesized: true,
receipt_ledger: SupportState::Unavailable,
},
session_identity: None,
session_rename: None,
approval_surface: None,
failure_modes: Vec::new(),
telemetry_sources: Vec::new(),
known_degradations: Vec::new(),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CallbackRequest {
pub schema_version: String,
pub event: LifecycleEventKind,
pub event_id: String,
pub adapter_id: String,
pub adapter_version: String,
pub integration_mode: IntegrationMode,
pub invocation_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub harness_session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub harness_run_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub harness_task_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub frame_context: Option<FrameContext>,
#[serde(skip_serializing_if = "Option::is_none")]
pub capability_snapshot_ref: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub payload_refs: Vec<PayloadRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sequence: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub idempotency_key: Option<String>,
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub metadata: serde_json::Map<String, serde_json::Value>,
}
impl CallbackRequest {
pub fn validate(&self) -> Result<(), ValidationError> {
if self.schema_version != SCHEMA_VERSION {
return Err(ValidationError::SchemaVersionMismatch {
expected: SCHEMA_VERSION.to_string(),
found: self.schema_version.clone(),
});
}
require_non_empty(&self.event_id, "request.event_id")?;
require_non_empty(&self.adapter_id, "request.adapter_id")?;
require_non_empty(&self.adapter_version, "request.adapter_version")?;
require_non_empty(&self.invocation_id, "request.invocation_id")?;
if let Some(s) = &self.harness_session_id {
require_non_empty(s, "request.harness_session_id")?;
}
if let Some(s) = &self.harness_run_id {
require_non_empty(s, "request.harness_run_id")?;
}
if let Some(s) = &self.harness_task_id {
require_non_empty(s, "request.harness_task_id")?;
}
if let Some(s) = &self.capability_snapshot_ref {
require_non_empty(s, "request.capability_snapshot_ref")?;
}
if let Some(s) = &self.idempotency_key {
require_non_empty(s, "request.idempotency_key")?;
}
if let Some(fc) = &self.frame_context {
fc.validate()?;
}
for r in &self.payload_refs {
r.validate()?;
}
match self.event {
LifecycleEventKind::FrameOpening
| LifecycleEventKind::FrameOpened
| LifecycleEventKind::FrameEnding
| LifecycleEventKind::FrameEnded
if self.frame_context.is_none() =>
{
Err(ValidationError::InvalidRequest(
"frame.* events require frame_context".into(),
))
}
LifecycleEventKind::ReceiptEmitted if self.idempotency_key.is_some() => {
Err(ValidationError::InvalidRequest(
"receipt.emitted is a notification event and must not carry an idempotency_key"
.into(),
))
}
_ => Ok(()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CallbackResponse {
pub schema_version: String,
pub status: ReceiptStatus,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub client_payloads: Vec<PayloadEnvelope>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub receipt_refs: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<Warning>,
pub failure_class: Option<FailureClass>,
pub retry_class: Option<RetryClass>,
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub metadata: serde_json::Map<String, serde_json::Value>,
}
impl CallbackResponse {
pub fn ok(status: ReceiptStatus) -> Self {
Self {
schema_version: SCHEMA_VERSION.to_string(),
status,
client_payloads: Vec::new(),
receipt_refs: Vec::new(),
warnings: Vec::new(),
failure_class: None,
retry_class: None,
metadata: serde_json::Map::new(),
}
}
pub fn failed(failure: FailureClass) -> Self {
Self {
schema_version: SCHEMA_VERSION.to_string(),
status: ReceiptStatus::Failed,
client_payloads: Vec::new(),
receipt_refs: Vec::new(),
warnings: Vec::new(),
failure_class: Some(failure),
retry_class: Some(failure.default_retry()),
metadata: serde_json::Map::new(),
}
}
pub fn validate(&self) -> Result<(), ValidationError> {
if self.schema_version != SCHEMA_VERSION {
return Err(ValidationError::SchemaVersionMismatch {
expected: SCHEMA_VERSION.to_string(),
found: self.schema_version.clone(),
});
}
for p in &self.client_payloads {
p.validate()?;
}
for r in &self.receipt_refs {
require_non_empty(r, "response.receipt_refs[]")?;
}
for w in &self.warnings {
w.validate()?;
}
match (
matches!(self.status, ReceiptStatus::Failed),
self.failure_class.is_some(),
) {
(true, false) => {
return Err(ValidationError::InvalidResponse(
"status=failed requires failure_class".into(),
));
}
(false, true) => {
return Err(ValidationError::InvalidResponse(
"failure_class is only valid on status=failed responses".into(),
));
}
_ => {}
}
if matches!(self.status, ReceiptStatus::Failed) && self.retry_class.is_none() {
return Err(ValidationError::InvalidResponse(
"status=failed requires retry_class (clients must declare retry posture)".into(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DispatchEnvelope {
pub schema_version: String,
pub request: CallbackRequest,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub payloads: Vec<PayloadEnvelope>,
}
impl DispatchEnvelope {
pub fn new(request: CallbackRequest, payloads: Vec<PayloadEnvelope>) -> Self {
Self {
schema_version: SCHEMA_VERSION.to_string(),
request,
payloads,
}
}
pub fn validate(&self) -> Result<(), ValidationError> {
if self.schema_version != SCHEMA_VERSION {
return Err(ValidationError::SchemaVersionMismatch {
expected: SCHEMA_VERSION.to_string(),
found: self.schema_version.clone(),
});
}
self.request.validate()?;
for p in &self.payloads {
p.validate()?;
}
Ok(())
}
}