use std::collections::BTreeMap;
use crate::envelope::{Actor, PayloadRef};
use crate::fsm::{
AttemptState, CommandState, GateVerdict, LeaseMode, LeaseState, MessageState, Outcome,
TaskState,
};
use crate::ids::{
AttemptId, AttentionItemId, AuthorityGrantId, ByteCount, CommandId, CorrelationId, CostMicros,
DispatchNodeId, EngineId, EngineSessionId, EvidenceId, FenceToken, GateId, IdempotencyKey,
IngestedRecordId, LeaseId, MessageId, ReceiptId, Seq, TaskId, Timestamp, WorktreeId,
};
use crate::ingestion::IngestionKind;
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(deny_unknown_fields)]
pub struct Task {
pub id: TaskId,
pub version: u32,
pub state: TaskState,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub spec_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub project: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub priority: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub tracker_ref: Option<String>,
pub created_at: Timestamp,
pub updated_at: Timestamp,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(deny_unknown_fields)]
pub struct Budget {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub max_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub max_tool_calls: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub max_wall_ms: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub max_cost_micros: Option<CostMicros>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(deny_unknown_fields)]
pub struct Attempt {
pub id: AttemptId,
pub version: u32,
pub state: AttemptState,
pub task_id: TaskId,
pub engine: EngineId,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub capability: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub role: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub model_lane: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub permission_profile: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub worktree_lease_id: Option<LeaseId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub base_sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub budget: Option<Budget>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub result_schema_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub provider_session_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub runtime_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub runtime_started_at: Option<Timestamp>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub exit_code: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub provider_terminal_event: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub result_valid: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub evidence_manifest_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub gate_result: Option<String>,
pub created_at: Timestamp,
pub updated_at: Timestamp,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(deny_unknown_fields)]
pub struct EngineSession {
pub id: EngineSessionId,
pub attempt_id: AttemptId,
pub engine: EngineId,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub provider_session_ref: Option<String>,
pub started_at: Timestamp,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub ended_at: Option<Timestamp>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(deny_unknown_fields)]
pub struct Message {
pub id: MessageId,
pub version: u32,
pub state: MessageState,
pub idempotency_key: IdempotencyKey,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub correlation_id: Option<CorrelationId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub reply_to: Option<MessageId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub sender: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub recipient: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub channel: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional, type = Option<crate::envelope::JsonValue>)]
pub payload: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub deadline: Option<Timestamp>,
pub delivery_attempts: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub dead_letter_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub delivery_refs: Option<BTreeMap<String, String>>,
pub created_at: Timestamp,
pub updated_at: Timestamp,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(deny_unknown_fields)]
pub struct Command {
pub id: CommandId,
pub version: u32,
pub state: CommandState,
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub target: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub actor: Option<Actor>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub idempotency_key: Option<IdempotencyKey>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub outcome: Option<Outcome>,
pub created_at: Timestamp,
pub updated_at: Timestamp,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(deny_unknown_fields)]
pub struct Gate {
pub id: GateId,
pub version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub attempt_id: Option<AttemptId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub phase_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub kind: Option<String>,
pub verdict: GateVerdict,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub evidence_ref: Option<String>,
pub created_at: Timestamp,
pub updated_at: Timestamp,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(deny_unknown_fields)]
pub struct AuthorityGrant {
pub id: AuthorityGrantId,
pub grantee: Actor,
pub action_class: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub scope: Option<String>,
pub granted_at: Timestamp,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub expires_at: Option<Timestamp>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub revoked_at: Option<Timestamp>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub revoke_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub receipt_id: Option<ReceiptId>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(deny_unknown_fields)]
pub struct Receipt {
pub id: ReceiptId,
pub actor: Actor,
pub action: String,
pub subject_type: String,
pub subject_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub from: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub to: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub observed_basis: Option<String>,
pub ts: Timestamp,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(deny_unknown_fields)]
pub struct Evidence {
pub id: EvidenceId,
pub kind: String,
pub r#ref: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub digest: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub byte_size: Option<ByteCount>,
pub created_at: Timestamp,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(deny_unknown_fields)]
pub struct IngestedRecord {
pub id: IngestedRecordId,
pub kind: IngestionKind,
#[specta(type = crate::envelope::JsonValue)]
pub payload: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub payload_ref: Option<PayloadRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub ingested_by: Option<Actor>,
pub event_seq: Seq,
pub ingested_at: Timestamp,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(deny_unknown_fields)]
pub struct AttentionItem {
pub id: AttentionItemId,
pub kind: String,
pub summary: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub subject_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub raised_by: Option<Actor>,
pub raised_at: Timestamp,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub resolved_at: Option<Timestamp>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub resolution: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(deny_unknown_fields)]
pub struct Worktree {
pub id: WorktreeId,
pub repo: String,
pub path: String,
pub branch: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub base_sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub lease_id: Option<LeaseId>,
pub dirty: bool,
pub unpushed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub released_at: Option<Timestamp>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub disposition: Option<String>,
pub created_at: Timestamp,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(deny_unknown_fields)]
pub struct Lease {
pub id: LeaseId,
pub version: u32,
pub state: LeaseState,
pub mode: LeaseMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub holder: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub scope: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub repo: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub base_sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub fence_token: Option<FenceToken>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub heartbeat_at: Option<Timestamp>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub expires_at: Option<Timestamp>,
pub dirty: bool,
pub unpushed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub disposition: Option<String>,
pub created_at: Timestamp,
pub updated_at: Timestamp,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(deny_unknown_fields)]
pub struct DispatchNode {
pub id: DispatchNodeId,
pub version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub parent_id: Option<DispatchNodeId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub attempt_id: Option<AttemptId>,
pub kind: String,
pub state: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub label: Option<String>,
pub created_at: Timestamp,
pub updated_at: Timestamp,
}
pub const DISPATCH_NODE_INITIAL_STATE: &str = "registered";
#[cfg(test)]
mod tests {
use super::*;
use crate::fsm::StateMachine;
#[test]
fn command_outcome_rides_the_terminal_only() {
let cmd = Command {
id: CommandId::new("cmd-1"),
version: 4,
state: CommandState::VerificationComplete,
kind: "stop_attempt".into(),
target: None,
actor: None,
idempotency_key: None,
outcome: Some(Outcome::Clean),
created_at: Timestamp::new("2026-07-27T00:00:00Z"),
updated_at: Timestamp::new("2026-07-27T00:00:05Z"),
};
assert!(cmd.state.is_terminal());
let json = serde_json::to_string(&cmd).expect("serialize");
assert!(json.contains("\"outcome\":\"clean\""));
assert!(json.contains("\"state\":\"verification_complete\""));
}
#[test]
fn message_delivery_refs_is_a_channel_map() {
let mut refs = BTreeMap::new();
refs.insert("chat".to_string(), "msg-ref-1".to_string());
let msg = Message {
id: MessageId::new("m-1"),
version: 1,
state: MessageState::Accepted,
idempotency_key: IdempotencyKey::new("k-1"),
correlation_id: None,
reply_to: None,
sender: None,
recipient: None,
channel: None,
kind: None,
payload: None,
deadline: None,
delivery_attempts: 0,
dead_letter_reason: None,
delivery_refs: Some(refs),
created_at: Timestamp::new("2026-07-27T00:00:00Z"),
updated_at: Timestamp::new("2026-07-27T00:00:00Z"),
};
let json = serde_json::to_string(&msg).expect("serialize");
assert!(json.contains("\"delivery_refs\":{\"chat\":\"msg-ref-1\"}"));
let back: Message = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, msg);
}
}