Skip to main content

gwk_domain/
entity.rs

1//! Entity snapshots — the vertical-slice record shapes.
2//!
3//! These are the wire/storage projections of each aggregate at a version, not
4//! the event stream itself. Conventions (see `docs/contract/NAMING.md`):
5//! snake_case fields, absent optionals omitted, refs opaque strings, open
6//! classification fields are bounded strings (never closed enums), and every
7//! CAS-transitioned entity carries `version`.
8
9use 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/// Tracker-visible work item.
24#[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    /// Opaque external-tracker reference (vendor-neutral).
46    #[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/// Resource budget for one attempt. All fields optional: an absent field means
54/// "no cap on this axis", never zero.
55#[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/// One engine execution of a task, with its full semantic route snapshot.
73#[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    /// The model tier lane (routing), distinct from `engine` (the runtime).
88    #[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/// One provider-level session under an attempt (an attempt may resume across
135/// several engine sessions).
136#[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/// Governed inter-party message. `delivery_refs` maps a delivery channel name
152/// to an opaque per-channel reference (replaces any single-vendor ref column).
153#[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/// A stop/kill command. `outcome` is present iff `state` is
196/// `verification_complete` — written in the same transaction as that terminal
197/// transition (the invariant `gwk-cert` checks and the DDL CHECKs).
198#[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    /// OPEN bounded string (e.g. `stop_attempt`).
205    pub kind: String,
206    // The PRIMARY target of this command: the command event payload carries a
207    // `targets` array (a stop may name several attempts); this projection keeps
208    // the single primary. Plain `//`, not a `///` doc: a doc attribute here
209    // would flow into the generated bindings.ts golden.
210    #[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/// A verify/review/security/eval/cert checkpoint. `kind` is OPEN; the verdict
227/// set is CLOSED.
228#[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/// A standing authorization: what `grantee` may do without paging the operator.
251#[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    /// OPEN action class this grant covers (e.g. `deploy`, `auto_answer`).
257    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    // Set together when the grant is withdrawn. Absent means live: a grant with
266    // no revocation stamp is one that still matches, subject only to expiry.
267    #[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/// An attestation that an actor performed an action — the audit row every
279/// auto-answer, state flip, and side effect leaves behind. The
280/// liveness-producer flip rule's receipt uses `subject_type = "attempt"`,
281/// `from`/`to` as the flip edge, and `observed_basis` naming the liveness
282/// evidence.
283#[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    /// OPEN action attested (e.g. `state_flip`, `auto_answer`, `deploy`).
289    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/// A pointer to one piece of evidence. `kind` is an OPEN bounded string
305/// (e.g. `transcript`, `diff`, `log`); there is no format column.
306#[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/// One record that entered the log through ingestion rather than through an
322/// aggregate's lifecycle.
323///
324/// Immutable like [`Evidence`] — no `version`, no `state`, written once. Its
325/// `kind` is the CLOSED [`IngestionKind`] set, unlike the open classification
326/// strings elsewhere in this contract: the absence of an import path is
327/// load-bearing here, and an open string would be that door.
328///
329/// `payload` and `payload_ref` are not exclusive. The inline half is bounded
330/// (the append path enforces
331/// [`INLINE_PAYLOAD_MAX_BYTES`](crate::envelope::INLINE_PAYLOAD_MAX_BYTES)) and
332/// carries either the whole content or a descriptor of the blob beside it —
333/// a graph snapshot's node and edge counts are worth having in the projection
334/// without fetching ninety megabytes to learn them.
335#[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    /// Where this record's event sits in the log — the pointer back to the one
349    /// thing that can prove the row, since ingestion writes no transition.
350    pub event_seq: Seq,
351    pub ingested_at: Timestamp,
352}
353
354/// Something the operator should look at. `kind` is OPEN.
355#[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    // Set together when someone closes the item. While `resolved_at` is absent
369    // the item is what the (kind, subject_ref) dedup counts, so it is also the
370    // field that decides whether the same problem raises a second time.
371    #[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/// An isolated working copy a writer attempt runs in.
380#[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    // Set together when the tree is handed back. Absent means still held: a
396    // worktree with no release stamp is one somebody may still be working in.
397    #[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/// An advisory lease over a scope (worktree, file set, singleton role).
407/// `fence_token` increases on every re-grant of the same scope; storage rejects
408/// writes presenting a stale token.
409#[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/// One node in the dispatch tree (who spawned whom). `kind` is OPEN
453/// (e.g. `orchestrator`, `subagent`, `engine`).
454#[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    // OPEN bounded lifecycle label, NOT an FSM state: the spawn tree has no
467    // seeded edge set, so storage guards the version CAS and nothing else.
468    // Plain `//` — a doc attribute here would flow into the bindings.ts golden.
469    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
477/// The state a dispatch node is born in — the spawn tree's only fixed label.
478pub 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        // The invariant is checked by gwk-cert + the DDL; here we pin the shape:
488        // outcome is representable exactly as an optional column beside state.
489        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}