1use std::collections::BTreeMap;
10
11use crate::envelope::{Actor, PayloadRef};
12use crate::fsm::{
13 AttemptState, CommandState, GateVerdict, LeaseMode, LeaseState, MessageState, Outcome,
14 TaskState,
15};
16use crate::ids::{
17 AttemptId, AttentionItemId, AuthorityGrantId, ByteCount, CommandId, CorrelationId, CostMicros,
18 DispatchNodeId, EngineId, EngineSessionId, EvidenceId, FenceToken, GateId, IdempotencyKey,
19 IngestedRecordId, LeaseId, MessageId, ReceiptId, Seq, TaskId, Timestamp, WorktreeId,
20};
21use crate::ingestion::IngestionKind;
22
23#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, specta::Type)]
25#[serde(deny_unknown_fields)]
26pub struct Task {
27 pub id: TaskId,
28 pub version: u32,
29 pub state: TaskState,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
31 #[specta(optional)]
32 pub kind: Option<String>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 #[specta(optional)]
35 pub title: Option<String>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
37 #[specta(optional)]
38 pub spec_ref: Option<String>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 #[specta(optional)]
41 pub project: Option<String>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 #[specta(optional)]
44 pub priority: Option<i32>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
47 #[specta(optional)]
48 pub tracker_ref: Option<String>,
49 pub created_at: Timestamp,
50 pub updated_at: Timestamp,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
56#[serde(deny_unknown_fields)]
57pub struct Budget {
58 #[serde(default, skip_serializing_if = "Option::is_none")]
59 #[specta(optional)]
60 pub max_tokens: Option<u32>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 #[specta(optional)]
63 pub max_tool_calls: Option<u32>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 #[specta(optional)]
66 pub max_wall_ms: Option<u32>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 #[specta(optional)]
69 pub max_cost_micros: Option<CostMicros>,
70}
71
72#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, specta::Type)]
74#[serde(deny_unknown_fields)]
75pub struct Attempt {
76 pub id: AttemptId,
77 pub version: u32,
78 pub state: AttemptState,
79 pub task_id: TaskId,
80 pub engine: EngineId,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 #[specta(optional)]
83 pub capability: Option<String>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 #[specta(optional)]
86 pub role: Option<String>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 #[specta(optional)]
90 pub model_lane: Option<String>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 #[specta(optional)]
93 pub permission_profile: Option<String>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
95 #[specta(optional)]
96 pub worktree_lease_id: Option<LeaseId>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 #[specta(optional)]
99 pub base_sha: Option<String>,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
101 #[specta(optional)]
102 pub budget: Option<Budget>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 #[specta(optional)]
105 pub result_schema_ref: Option<String>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
107 #[specta(optional)]
108 pub provider_session_ref: Option<String>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 #[specta(optional)]
111 pub runtime_ref: Option<String>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 #[specta(optional)]
114 pub runtime_started_at: Option<Timestamp>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 #[specta(optional)]
117 pub exit_code: Option<i32>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 #[specta(optional)]
120 pub provider_terminal_event: Option<String>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 #[specta(optional)]
123 pub result_valid: Option<bool>,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
125 #[specta(optional)]
126 pub evidence_manifest_ref: Option<String>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 #[specta(optional)]
129 pub gate_result: Option<String>,
130 pub created_at: Timestamp,
131 pub updated_at: Timestamp,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
137#[serde(deny_unknown_fields)]
138pub struct EngineSession {
139 pub id: EngineSessionId,
140 pub attempt_id: AttemptId,
141 pub engine: EngineId,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
143 #[specta(optional)]
144 pub provider_session_ref: Option<String>,
145 pub started_at: Timestamp,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 #[specta(optional)]
148 pub ended_at: Option<Timestamp>,
149}
150
151#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, specta::Type)]
154#[serde(deny_unknown_fields)]
155pub struct Message {
156 pub id: MessageId,
157 pub version: u32,
158 pub state: MessageState,
159 pub idempotency_key: IdempotencyKey,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
161 #[specta(optional)]
162 pub correlation_id: Option<CorrelationId>,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
164 #[specta(optional)]
165 pub reply_to: Option<MessageId>,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
167 #[specta(optional)]
168 pub sender: Option<String>,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
170 #[specta(optional)]
171 pub recipient: Option<String>,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
173 #[specta(optional)]
174 pub channel: Option<String>,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
176 #[specta(optional)]
177 pub kind: Option<String>,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
179 #[specta(optional, type = Option<crate::envelope::JsonValue>)]
180 pub payload: Option<serde_json::Value>,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
182 #[specta(optional)]
183 pub deadline: Option<Timestamp>,
184 pub delivery_attempts: u32,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
186 #[specta(optional)]
187 pub dead_letter_reason: Option<String>,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 #[specta(optional)]
190 pub delivery_refs: Option<BTreeMap<String, String>>,
191 pub created_at: Timestamp,
192 pub updated_at: Timestamp,
193}
194
195#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, specta::Type)]
199#[serde(deny_unknown_fields)]
200pub struct Command {
201 pub id: CommandId,
202 pub version: u32,
203 pub state: CommandState,
204 pub kind: String,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
211 #[specta(optional)]
212 pub target: Option<String>,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
214 #[specta(optional)]
215 pub actor: Option<Actor>,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
217 #[specta(optional)]
218 pub idempotency_key: Option<IdempotencyKey>,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 #[specta(optional)]
221 pub outcome: Option<Outcome>,
222 pub created_at: Timestamp,
223 pub updated_at: Timestamp,
224}
225
226#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
229#[serde(deny_unknown_fields)]
230pub struct Gate {
231 pub id: GateId,
232 pub version: u32,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
234 #[specta(optional)]
235 pub attempt_id: Option<AttemptId>,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
237 #[specta(optional)]
238 pub phase_ref: Option<String>,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
240 #[specta(optional)]
241 pub kind: Option<String>,
242 pub verdict: GateVerdict,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
244 #[specta(optional)]
245 pub evidence_ref: Option<String>,
246 pub created_at: Timestamp,
247 pub updated_at: Timestamp,
248}
249
250#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
252#[serde(deny_unknown_fields)]
253pub struct AuthorityGrant {
254 pub id: AuthorityGrantId,
255 pub grantee: Actor,
256 pub action_class: String,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
259 #[specta(optional)]
260 pub scope: Option<String>,
261 pub granted_at: Timestamp,
262 #[serde(default, skip_serializing_if = "Option::is_none")]
263 #[specta(optional)]
264 pub expires_at: Option<Timestamp>,
265 #[serde(default, skip_serializing_if = "Option::is_none")]
268 #[specta(optional)]
269 pub revoked_at: Option<Timestamp>,
270 #[serde(default, skip_serializing_if = "Option::is_none")]
271 #[specta(optional)]
272 pub revoke_reason: Option<String>,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
274 #[specta(optional)]
275 pub receipt_id: Option<ReceiptId>,
276}
277
278#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
284#[serde(deny_unknown_fields)]
285pub struct Receipt {
286 pub id: ReceiptId,
287 pub actor: Actor,
288 pub action: String,
290 pub subject_type: String,
291 pub subject_id: String,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
293 #[specta(optional)]
294 pub from: Option<String>,
295 #[serde(default, skip_serializing_if = "Option::is_none")]
296 #[specta(optional)]
297 pub to: Option<String>,
298 #[serde(default, skip_serializing_if = "Option::is_none")]
299 #[specta(optional)]
300 pub observed_basis: Option<String>,
301 pub ts: Timestamp,
302}
303
304#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
307#[serde(deny_unknown_fields)]
308pub struct Evidence {
309 pub id: EvidenceId,
310 pub kind: String,
311 pub r#ref: String,
312 #[serde(default, skip_serializing_if = "Option::is_none")]
313 #[specta(optional)]
314 pub digest: Option<String>,
315 #[serde(default, skip_serializing_if = "Option::is_none")]
316 #[specta(optional)]
317 pub byte_size: Option<ByteCount>,
318 pub created_at: Timestamp,
319}
320
321#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, specta::Type)]
336#[serde(deny_unknown_fields)]
337pub struct IngestedRecord {
338 pub id: IngestedRecordId,
339 pub kind: IngestionKind,
340 #[specta(type = crate::envelope::JsonValue)]
341 pub payload: serde_json::Value,
342 #[serde(default, skip_serializing_if = "Option::is_none")]
343 #[specta(optional)]
344 pub payload_ref: Option<PayloadRef>,
345 #[serde(default, skip_serializing_if = "Option::is_none")]
346 #[specta(optional)]
347 pub ingested_by: Option<Actor>,
348 pub event_seq: Seq,
351 pub ingested_at: Timestamp,
352}
353
354#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
356#[serde(deny_unknown_fields)]
357pub struct AttentionItem {
358 pub id: AttentionItemId,
359 pub kind: String,
360 pub summary: String,
361 #[serde(default, skip_serializing_if = "Option::is_none")]
362 #[specta(optional)]
363 pub subject_ref: Option<String>,
364 #[serde(default, skip_serializing_if = "Option::is_none")]
365 #[specta(optional)]
366 pub raised_by: Option<Actor>,
367 pub raised_at: Timestamp,
368 #[serde(default, skip_serializing_if = "Option::is_none")]
372 #[specta(optional)]
373 pub resolved_at: Option<Timestamp>,
374 #[serde(default, skip_serializing_if = "Option::is_none")]
375 #[specta(optional)]
376 pub resolution: Option<String>,
377}
378
379#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
381#[serde(deny_unknown_fields)]
382pub struct Worktree {
383 pub id: WorktreeId,
384 pub repo: String,
385 pub path: String,
386 pub branch: String,
387 #[serde(default, skip_serializing_if = "Option::is_none")]
388 #[specta(optional)]
389 pub base_sha: Option<String>,
390 #[serde(default, skip_serializing_if = "Option::is_none")]
391 #[specta(optional)]
392 pub lease_id: Option<LeaseId>,
393 pub dirty: bool,
394 pub unpushed: bool,
395 #[serde(default, skip_serializing_if = "Option::is_none")]
398 #[specta(optional)]
399 pub released_at: Option<Timestamp>,
400 #[serde(default, skip_serializing_if = "Option::is_none")]
401 #[specta(optional)]
402 pub disposition: Option<String>,
403 pub created_at: Timestamp,
404}
405
406#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
410#[serde(deny_unknown_fields)]
411pub struct Lease {
412 pub id: LeaseId,
413 pub version: u32,
414 pub state: LeaseState,
415 pub mode: LeaseMode,
416 #[serde(default, skip_serializing_if = "Option::is_none")]
417 #[specta(optional)]
418 pub holder: Option<String>,
419 #[serde(default, skip_serializing_if = "Option::is_none")]
420 #[specta(optional)]
421 pub scope: Option<String>,
422 #[serde(default, skip_serializing_if = "Option::is_none")]
423 #[specta(optional)]
424 pub repo: Option<String>,
425 #[serde(default, skip_serializing_if = "Option::is_none")]
426 #[specta(optional)]
427 pub path: Option<String>,
428 #[serde(default, skip_serializing_if = "Option::is_none")]
429 #[specta(optional)]
430 pub branch: Option<String>,
431 #[serde(default, skip_serializing_if = "Option::is_none")]
432 #[specta(optional)]
433 pub base_sha: Option<String>,
434 #[serde(default, skip_serializing_if = "Option::is_none")]
435 #[specta(optional)]
436 pub fence_token: Option<FenceToken>,
437 #[serde(default, skip_serializing_if = "Option::is_none")]
438 #[specta(optional)]
439 pub heartbeat_at: Option<Timestamp>,
440 #[serde(default, skip_serializing_if = "Option::is_none")]
441 #[specta(optional)]
442 pub expires_at: Option<Timestamp>,
443 pub dirty: bool,
444 pub unpushed: bool,
445 #[serde(default, skip_serializing_if = "Option::is_none")]
446 #[specta(optional)]
447 pub disposition: Option<String>,
448 pub created_at: Timestamp,
449 pub updated_at: Timestamp,
450}
451
452#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
455#[serde(deny_unknown_fields)]
456pub struct DispatchNode {
457 pub id: DispatchNodeId,
458 pub version: u32,
459 #[serde(default, skip_serializing_if = "Option::is_none")]
460 #[specta(optional)]
461 pub parent_id: Option<DispatchNodeId>,
462 #[serde(default, skip_serializing_if = "Option::is_none")]
463 #[specta(optional)]
464 pub attempt_id: Option<AttemptId>,
465 pub kind: String,
466 pub state: String,
470 #[serde(default, skip_serializing_if = "Option::is_none")]
471 #[specta(optional)]
472 pub label: Option<String>,
473 pub created_at: Timestamp,
474 pub updated_at: Timestamp,
475}
476
477pub const DISPATCH_NODE_INITIAL_STATE: &str = "registered";
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483 use crate::fsm::StateMachine;
484
485 #[test]
486 fn command_outcome_rides_the_terminal_only() {
487 let cmd = Command {
490 id: CommandId::new("cmd-1"),
491 version: 4,
492 state: CommandState::VerificationComplete,
493 kind: "stop_attempt".into(),
494 target: None,
495 actor: None,
496 idempotency_key: None,
497 outcome: Some(Outcome::Clean),
498 created_at: Timestamp::new("2026-07-27T00:00:00Z"),
499 updated_at: Timestamp::new("2026-07-27T00:00:05Z"),
500 };
501 assert!(cmd.state.is_terminal());
502 let json = serde_json::to_string(&cmd).expect("serialize");
503 assert!(json.contains("\"outcome\":\"clean\""));
504 assert!(json.contains("\"state\":\"verification_complete\""));
505 }
506
507 #[test]
508 fn message_delivery_refs_is_a_channel_map() {
509 let mut refs = BTreeMap::new();
510 refs.insert("chat".to_string(), "msg-ref-1".to_string());
511 let msg = Message {
512 id: MessageId::new("m-1"),
513 version: 1,
514 state: MessageState::Accepted,
515 idempotency_key: IdempotencyKey::new("k-1"),
516 correlation_id: None,
517 reply_to: None,
518 sender: None,
519 recipient: None,
520 channel: None,
521 kind: None,
522 payload: None,
523 deadline: None,
524 delivery_attempts: 0,
525 dead_letter_reason: None,
526 delivery_refs: Some(refs),
527 created_at: Timestamp::new("2026-07-27T00:00:00Z"),
528 updated_at: Timestamp::new("2026-07-27T00:00:00Z"),
529 };
530 let json = serde_json::to_string(&msg).expect("serialize");
531 assert!(json.contains("\"delivery_refs\":{\"chat\":\"msg-ref-1\"}"));
532 let back: Message = serde_json::from_str(&json).expect("deserialize");
533 assert_eq!(back, msg);
534 }
535}