Skip to main content

meerkat_workgraph/
types.rs

1use std::collections::BTreeSet;
2use std::fmt;
3use std::str::FromStr;
4
5use chrono::{DateTime, Utc};
6use meerkat_core::SessionId;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use uuid::Uuid;
10
11use crate::WorkGraphError;
12pub use crate::machines::work_attention_lifecycle::WorkAttentionLifecycleMachineState as WorkAttentionMachineState;
13use crate::machines::workgraph_lifecycle as wg_dsl;
14pub use crate::machines::workgraph_lifecycle::WorkGraphLifecycleMachineState as WorkGraphMachineState;
15
16#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
17#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
18#[serde(transparent)]
19pub struct WorkItemId(String);
20
21impl WorkItemId {
22    pub fn new(value: impl Into<String>) -> Result<Self, WorkGraphError> {
23        validate_token("work item id", value.into()).map(Self)
24    }
25
26    pub fn generated() -> Self {
27        Self(format!("work_{}", Uuid::now_v7()))
28    }
29
30    pub fn as_str(&self) -> &str {
31        &self.0
32    }
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
36#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
37#[serde(transparent)]
38pub struct WorkAttentionBindingId(String);
39
40impl WorkAttentionBindingId {
41    pub fn new(value: impl Into<String>) -> Result<Self, WorkGraphError> {
42        validate_token("work attention binding id", value.into()).map(Self)
43    }
44
45    pub fn generated() -> Self {
46        Self(format!("attention_{}", Uuid::now_v7()))
47    }
48
49    pub fn as_str(&self) -> &str {
50        &self.0
51    }
52}
53
54impl fmt::Display for WorkAttentionBindingId {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        f.write_str(self.as_str())
57    }
58}
59
60impl FromStr for WorkAttentionBindingId {
61    type Err = WorkGraphError;
62
63    fn from_str(value: &str) -> Result<Self, Self::Err> {
64        Self::new(value)
65    }
66}
67
68impl fmt::Display for WorkItemId {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        f.write_str(self.as_str())
71    }
72}
73
74impl FromStr for WorkItemId {
75    type Err = WorkGraphError;
76
77    fn from_str(value: &str) -> Result<Self, Self::Err> {
78        Self::new(value)
79    }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
83#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
84#[serde(transparent)]
85pub struct WorkNamespace(String);
86
87impl WorkNamespace {
88    pub fn new(value: impl Into<String>) -> Result<Self, WorkGraphError> {
89        validate_token("work namespace", value.into()).map(Self)
90    }
91
92    pub fn default_namespace() -> Self {
93        Self("default".to_string())
94    }
95
96    pub fn as_str(&self) -> &str {
97        &self.0
98    }
99}
100
101impl Default for WorkNamespace {
102    fn default() -> Self {
103        Self::default_namespace()
104    }
105}
106
107impl fmt::Display for WorkNamespace {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        f.write_str(self.as_str())
110    }
111}
112
113impl FromStr for WorkNamespace {
114    type Err = WorkGraphError;
115
116    fn from_str(value: &str) -> Result<Self, Self::Err> {
117        Self::new(value)
118    }
119}
120
121fn validate_token(name: &str, value: String) -> Result<String, WorkGraphError> {
122    let trimmed = value.trim();
123    if trimmed.is_empty() {
124        return Err(WorkGraphError::InvalidInput(format!(
125            "{name} must not be empty"
126        )));
127    }
128    if trimmed.chars().any(char::is_control) {
129        return Err(WorkGraphError::InvalidInput(format!(
130            "{name} must not contain control characters"
131        )));
132    }
133    Ok(trimmed.to_string())
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
137#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
138#[serde(rename_all = "snake_case")]
139pub enum WorkStatus {
140    #[default]
141    Open,
142    InProgress,
143    Blocked,
144    Completed,
145    Cancelled,
146    Failed,
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
150#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
151#[serde(rename_all = "snake_case")]
152pub enum WorkPriority {
153    Low,
154    #[default]
155    Medium,
156    High,
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
160#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
161#[serde(rename_all = "snake_case")]
162pub enum WorkEdgeKind {
163    Blocks,
164    Parent,
165    Related,
166    Supersedes,
167    DerivedFrom,
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
171#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
172#[serde(rename_all = "snake_case")]
173pub enum WorkOwnerKind {
174    Principal,
175    Agent,
176    Session,
177    Mob,
178    Label,
179}
180
181impl WorkOwnerKind {
182    pub fn as_str(self) -> &'static str {
183        match self {
184            Self::Principal => "principal",
185            Self::Agent => "agent",
186            Self::Session => "session",
187            Self::Mob => "mob",
188            Self::Label => "label",
189        }
190    }
191}
192
193#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
194#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
195pub struct WorkOwnerKey {
196    pub kind: WorkOwnerKind,
197    pub id: String,
198}
199
200impl WorkOwnerKey {
201    pub fn new(kind: WorkOwnerKind, id: impl Into<String>) -> Result<Self, WorkGraphError> {
202        Ok(Self {
203            kind,
204            id: validate_token("work owner id", id.into())?,
205        })
206    }
207
208    pub fn principal(id: impl Into<String>) -> Result<Self, WorkGraphError> {
209        Self::new(WorkOwnerKind::Principal, id)
210    }
211
212    pub fn agent(id: impl Into<String>) -> Result<Self, WorkGraphError> {
213        Self::new(WorkOwnerKind::Agent, id)
214    }
215
216    pub fn session(id: impl Into<String>) -> Result<Self, WorkGraphError> {
217        Self::new(WorkOwnerKind::Session, id)
218    }
219
220    pub fn mob(id: impl Into<String>) -> Result<Self, WorkGraphError> {
221        Self::new(WorkOwnerKind::Mob, id)
222    }
223
224    pub fn label(id: impl Into<String>) -> Result<Self, WorkGraphError> {
225        Self::new(WorkOwnerKind::Label, id)
226    }
227
228    pub fn canonical(&self) -> String {
229        format!("{}:{}", self.kind.as_str(), self.id)
230    }
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
235pub struct WorkOwner {
236    pub key: WorkOwnerKey,
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub display_name: Option<String>,
239}
240
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
242#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
243#[serde(tag = "kind", rename_all = "snake_case")]
244pub enum WorkCompletionPolicy {
245    #[default]
246    SelfAttest,
247    HostConfirmed,
248    PrincipalConfirmed,
249    Supervisor {
250        owner_key: WorkOwnerKey,
251    },
252    ReviewerQuorum {
253        threshold: u16,
254    },
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
258#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
259#[serde(tag = "kind", rename_all = "snake_case")]
260pub enum PublicGoalCompletionPolicy {
261    #[default]
262    SelfAttest,
263}
264
265impl From<PublicGoalCompletionPolicy> for WorkCompletionPolicy {
266    fn from(policy: PublicGoalCompletionPolicy) -> Self {
267        match policy {
268            PublicGoalCompletionPolicy::SelfAttest => Self::SelfAttest,
269        }
270    }
271}
272
273impl WorkCompletionPolicy {
274    pub fn requires_trusted_principal(&self) -> bool {
275        matches!(
276            self,
277            Self::PrincipalConfirmed | Self::Supervisor { .. } | Self::ReviewerQuorum { .. }
278        )
279    }
280
281    pub(crate) fn to_machine(&self) -> wg_dsl::WorkCompletionPolicy {
282        match self {
283            Self::SelfAttest => wg_dsl::WorkCompletionPolicy::SelfAttest,
284            Self::HostConfirmed => wg_dsl::WorkCompletionPolicy::HostConfirmed,
285            Self::PrincipalConfirmed => wg_dsl::WorkCompletionPolicy::PrincipalConfirmed,
286            Self::Supervisor { .. } => wg_dsl::WorkCompletionPolicy::Supervisor,
287            Self::ReviewerQuorum { .. } => wg_dsl::WorkCompletionPolicy::ReviewerQuorum,
288        }
289    }
290
291    pub(crate) fn supervisor_owner_key(&self) -> Option<wg_dsl::WorkOwnerKey> {
292        match self {
293            Self::Supervisor { owner_key } => Some(work_owner_key_to_machine(owner_key)),
294            _ => None,
295        }
296    }
297
298    pub(crate) fn reviewer_quorum_threshold(&self) -> Option<u64> {
299        match self {
300            Self::ReviewerQuorum { threshold } => Some(u64::from(*threshold)),
301            _ => None,
302        }
303    }
304
305    pub(crate) fn from_machine(
306        policy: wg_dsl::WorkCompletionPolicy,
307        supervisor_owner_key: Option<wg_dsl::WorkOwnerKey>,
308        reviewer_quorum_threshold: Option<u64>,
309    ) -> Self {
310        match policy {
311            wg_dsl::WorkCompletionPolicy::SelfAttest => Self::SelfAttest,
312            wg_dsl::WorkCompletionPolicy::HostConfirmed => Self::HostConfirmed,
313            wg_dsl::WorkCompletionPolicy::PrincipalConfirmed => Self::PrincipalConfirmed,
314            wg_dsl::WorkCompletionPolicy::Supervisor => Self::Supervisor {
315                owner_key: supervisor_owner_key
316                    .map(work_owner_key_from_machine)
317                    .unwrap_or_else(|| WorkOwnerKey {
318                        kind: WorkOwnerKind::Principal,
319                        id: "supervisor".to_string(),
320                    }),
321            },
322            wg_dsl::WorkCompletionPolicy::ReviewerQuorum => Self::ReviewerQuorum {
323                threshold: reviewer_quorum_threshold
324                    .and_then(|threshold| u16::try_from(threshold).ok())
325                    .unwrap_or(1),
326            },
327        }
328    }
329}
330
331impl WorkOwner {
332    pub fn new(key: WorkOwnerKey) -> Self {
333        Self {
334            key,
335            display_name: None,
336        }
337    }
338}
339
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
341#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
342pub struct WorkClaim {
343    pub owner: WorkOwner,
344    pub claimed_at: DateTime<Utc>,
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub lease_expires_at: Option<DateTime<Utc>>,
347}
348
349#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
350#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
351pub struct ExternalWorkRef {
352    pub kind: String,
353    pub id: String,
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub url: Option<String>,
356}
357
358/// Typed classification of confirmation evidence.
359///
360/// This is the canonical signal the `WorkGraphLifecycleMachine` consumes to
361/// decide completion-policy satisfaction. The producer
362/// (`confirmation_evidence_for_policy`) sets this field; the raw
363/// [`WorkEvidenceRef::kind`] string remains only as opaque provenance/display
364/// and is never re-read to classify evidence for the satisfaction decision.
365#[derive(
366    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default,
367)]
368#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
369#[serde(rename_all = "snake_case")]
370pub enum WorkEvidenceKind {
371    /// Generic / self-attested evidence that does not satisfy any
372    /// confirmation policy on its own.
373    #[default]
374    SelfAttest,
375    HostConfirmation,
376    PrincipalConfirmation,
377    SupervisorConfirmation,
378    ReviewerConfirmation,
379}
380
381impl WorkEvidenceKind {
382    pub(crate) fn to_machine(self) -> wg_dsl::WorkEvidenceKind {
383        match self {
384            Self::SelfAttest => wg_dsl::WorkEvidenceKind::SelfAttest,
385            Self::HostConfirmation => wg_dsl::WorkEvidenceKind::HostConfirmation,
386            Self::PrincipalConfirmation => wg_dsl::WorkEvidenceKind::PrincipalConfirmation,
387            Self::SupervisorConfirmation => wg_dsl::WorkEvidenceKind::SupervisorConfirmation,
388            Self::ReviewerConfirmation => wg_dsl::WorkEvidenceKind::ReviewerConfirmation,
389        }
390    }
391
392    /// Parse a reserved confirmation classification out of the opaque
393    /// provenance/display [`WorkEvidenceRef::kind`] string at the ingress
394    /// boundary. The recognized reserved literals map 1:1 onto a confirmation
395    /// variant; the generic `"self_attest"` literal and every other string
396    /// (including the empty string) carry no reserved confirmation and yield
397    /// `None`. This is the single place the opaque string is classified — every
398    /// downstream decision reads the typed classification, not the string.
399    pub(crate) fn from_kind_str(kind: &str) -> Option<Self> {
400        match kind {
401            "host_confirmation" => Some(Self::HostConfirmation),
402            "principal_confirmation" => Some(Self::PrincipalConfirmation),
403            "supervisor_confirmation" => Some(Self::SupervisorConfirmation),
404            "reviewer_confirmation" => Some(Self::ReviewerConfirmation),
405            _ => None,
406        }
407    }
408
409    /// Whether this classification is a reserved confirmation that may only be
410    /// stamped by the trusted goal-confirm producer. Generic
411    /// [`WorkEvidenceKind::SelfAttest`] evidence is never reserved.
412    pub(crate) fn is_reserved_confirmation(self) -> bool {
413        !matches!(self, Self::SelfAttest)
414    }
415
416    /// Project the typed classification into the machine-owned confirmation
417    /// observation the `WorkGraphLifecycleMachine` consumes. Generic
418    /// self-attested evidence projects to the `Other` observation; the
419    /// empty-display case is handled separately by the caller.
420    pub(crate) fn to_confirmation_observation(self) -> wg_dsl::WorkConfirmationEvidenceObservation {
421        match self {
422            Self::SelfAttest => wg_dsl::WorkConfirmationEvidenceObservation::Other,
423            Self::HostConfirmation => wg_dsl::WorkConfirmationEvidenceObservation::HostConfirmation,
424            Self::PrincipalConfirmation => {
425                wg_dsl::WorkConfirmationEvidenceObservation::PrincipalConfirmation
426            }
427            Self::SupervisorConfirmation => {
428                wg_dsl::WorkConfirmationEvidenceObservation::SupervisorConfirmation
429            }
430            Self::ReviewerConfirmation => {
431                wg_dsl::WorkConfirmationEvidenceObservation::ReviewerConfirmation
432            }
433        }
434    }
435}
436
437#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
438#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
439pub struct WorkEvidenceRef {
440    /// Opaque provenance/display label for the evidence. It is parsed into the
441    /// typed confirmation classification at the ingress boundary only (see
442    /// [`WorkEvidenceRef::confirmation_classification`]); no completion-policy
443    /// satisfaction decision re-reads this string. The typed
444    /// [`WorkEvidenceRef::confirmation_kind`] is the authoritative carrier.
445    pub kind: String,
446    pub id: String,
447    #[serde(default, skip_serializing_if = "Option::is_none")]
448    pub label: Option<String>,
449    #[serde(default, skip_serializing_if = "Option::is_none")]
450    pub summary: Option<String>,
451    /// Typed confirmation classification set by the trusted producer. Drives the
452    /// machine-owned completion-policy satisfaction decision. Generic evidence
453    /// leaves this unset (treated as `SelfAttest`).
454    #[serde(default, skip_serializing_if = "Option::is_none")]
455    pub confirmation_kind: Option<WorkEvidenceKind>,
456    /// Typed identity of the confirming owner for supervisor/reviewer
457    /// confirmations. Set by the trusted producer; the machine records distinct
458    /// owners per confirmation kind.
459    #[serde(default, skip_serializing_if = "Option::is_none")]
460    pub confirming_owner_key: Option<WorkOwnerKey>,
461}
462
463impl WorkEvidenceRef {
464    /// The effective typed confirmation classification carried by this evidence,
465    /// considering BOTH carriers: the typed [`WorkEvidenceRef::confirmation_kind`]
466    /// field (authoritative when set) and the reserved confirmation literals that
467    /// may be encoded only in the opaque [`WorkEvidenceRef::kind`] string at
468    /// ingress. Returns `None` for generic self-attested evidence.
469    ///
470    /// This is the single typed read every confirmation decision uses, so a
471    /// reserved classification surfaces regardless of which carrier the caller
472    /// supplied — closing the gap where the machine honored a forged
473    /// `confirmation_kind` while the guards inspected only the `kind` string.
474    pub(crate) fn confirmation_classification(&self) -> Option<WorkEvidenceKind> {
475        self.confirmation_kind
476            .filter(|kind| kind.is_reserved_confirmation())
477            .or_else(|| WorkEvidenceKind::from_kind_str(&self.kind))
478    }
479}
480
481#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
482#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
483pub struct WorkItemRef {
484    pub realm_id: String,
485    pub namespace: WorkNamespace,
486    pub item_id: WorkItemId,
487}
488
489#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
490#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
491#[serde(tag = "kind", rename_all = "snake_case")]
492pub enum WorkAttentionTarget {
493    Session { session_id: SessionId },
494    LoweredOwner { owner_key: WorkOwnerKey },
495}
496
497impl WorkAttentionTarget {
498    pub fn owner_key(&self) -> Result<WorkOwnerKey, WorkGraphError> {
499        match self {
500            Self::Session { session_id } => WorkOwnerKey::session(session_id.to_string()),
501            Self::LoweredOwner { owner_key } => Ok(owner_key.clone()),
502        }
503    }
504}
505
506#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
507#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
508#[serde(tag = "kind", rename_all = "snake_case")]
509pub enum GoalAttentionTarget {
510    Session { session_id: SessionId },
511}
512
513impl GoalAttentionTarget {
514    pub fn to_attention_target(&self) -> WorkAttentionTarget {
515        match self {
516            Self::Session { session_id } => WorkAttentionTarget::Session {
517                session_id: session_id.clone(),
518            },
519        }
520    }
521}
522
523#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
524#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
525#[serde(rename_all = "snake_case")]
526pub enum WorkAttentionMode {
527    #[default]
528    Pursue,
529    Coordinate,
530    Review,
531    Falsify,
532    Judge,
533    Observe,
534}
535
536#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
537#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
538#[serde(tag = "state", rename_all = "snake_case")]
539pub enum WorkAttentionStatus {
540    #[default]
541    Active,
542    Paused {
543        #[serde(default, skip_serializing_if = "Option::is_none")]
544        until: Option<DateTime<Utc>>,
545    },
546    Superseded,
547    Stopped,
548}
549
550#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
551#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
552#[serde(rename_all = "snake_case")]
553pub enum AttentionDelegatedAuthority {
554    #[default]
555    AddEvidence,
556    CloseOwnReviewItem,
557    RequestClosure,
558    CloseIfPolicyAllows,
559}
560
561#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
562#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
563pub struct AttentionProjectionPolicy {
564    #[serde(default = "default_projection_max_text_chars")]
565    pub max_text_chars: u32,
566    #[serde(default = "default_include_parent_context")]
567    pub include_parent_context: bool,
568}
569
570fn default_include_parent_context() -> bool {
571    true
572}
573
574impl Default for AttentionProjectionPolicy {
575    fn default() -> Self {
576        Self {
577            max_text_chars: default_projection_max_text_chars(),
578            include_parent_context: true,
579        }
580    }
581}
582
583fn default_projection_max_text_chars() -> u32 {
584    4096
585}
586
587#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
588#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
589pub struct WorkAttentionBinding {
590    pub binding_id: WorkAttentionBindingId,
591    pub work_ref: WorkItemRef,
592    pub target: WorkAttentionTarget,
593    pub mode: WorkAttentionMode,
594    pub status: WorkAttentionStatus,
595    #[serde(default = "default_work_attention_machine_state")]
596    #[cfg_attr(feature = "schema", schemars(with = "WorkAttentionMachineStateSchema"))]
597    pub machine_state: WorkAttentionMachineState,
598    pub delegated_authority: AttentionDelegatedAuthority,
599    #[serde(default)]
600    pub projection_policy: AttentionProjectionPolicy,
601    pub created_at: DateTime<Utc>,
602    pub updated_at: DateTime<Utc>,
603}
604
605#[cfg(feature = "schema")]
606#[derive(schemars::JsonSchema)]
607#[allow(dead_code)]
608struct WorkAttentionMachineStateSchema {
609    lifecycle_phase: String,
610    revision: u64,
611    paused_until_utc_ms: Option<u64>,
612    superseded_by_binding_key: Option<String>,
613    terminal_at_utc_ms: Option<u64>,
614}
615
616fn default_work_attention_machine_state() -> WorkAttentionMachineState {
617    WorkAttentionMachineState::default()
618}
619
620#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
621pub struct WorkItem {
622    pub id: WorkItemId,
623    pub realm_id: String,
624    pub namespace: WorkNamespace,
625    pub title: String,
626    #[serde(default, skip_serializing_if = "Option::is_none")]
627    pub description: Option<String>,
628    pub status: WorkStatus,
629    #[serde(default)]
630    pub completion_policy: WorkCompletionPolicy,
631    pub priority: WorkPriority,
632    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
633    pub labels: BTreeSet<String>,
634    #[serde(default, skip_serializing_if = "Option::is_none")]
635    pub owner: Option<WorkOwner>,
636    #[serde(default, skip_serializing_if = "Option::is_none")]
637    pub claim: Option<WorkClaim>,
638    pub machine_state: WorkGraphMachineState,
639    pub revision: u64,
640    #[serde(default, skip_serializing_if = "Option::is_none")]
641    pub due_at: Option<DateTime<Utc>>,
642    #[serde(default, skip_serializing_if = "Option::is_none")]
643    pub not_before: Option<DateTime<Utc>>,
644    #[serde(default, skip_serializing_if = "Option::is_none")]
645    pub snoozed_until: Option<DateTime<Utc>>,
646    pub created_at: DateTime<Utc>,
647    pub updated_at: DateTime<Utc>,
648    #[serde(default, skip_serializing_if = "Option::is_none")]
649    pub terminal_at: Option<DateTime<Utc>>,
650    #[serde(default, skip_serializing_if = "Vec::is_empty")]
651    pub external_refs: Vec<ExternalWorkRef>,
652    #[serde(default, skip_serializing_if = "Vec::is_empty")]
653    pub evidence_refs: Vec<WorkEvidenceRef>,
654}
655
656#[derive(Deserialize)]
657struct WorkItemWire {
658    id: WorkItemId,
659    realm_id: String,
660    namespace: WorkNamespace,
661    title: String,
662    #[serde(default)]
663    description: Option<String>,
664    status: WorkStatus,
665    #[serde(default)]
666    completion_policy: WorkCompletionPolicy,
667    priority: WorkPriority,
668    #[serde(default)]
669    labels: BTreeSet<String>,
670    #[serde(default)]
671    owner: Option<WorkOwner>,
672    #[serde(default)]
673    claim: Option<WorkClaim>,
674    #[serde(default)]
675    machine_state: Option<WorkGraphMachineState>,
676    revision: u64,
677    #[serde(default)]
678    due_at: Option<DateTime<Utc>>,
679    #[serde(default)]
680    not_before: Option<DateTime<Utc>>,
681    #[serde(default)]
682    snoozed_until: Option<DateTime<Utc>>,
683    created_at: DateTime<Utc>,
684    updated_at: DateTime<Utc>,
685    #[serde(default)]
686    terminal_at: Option<DateTime<Utc>>,
687    #[serde(default)]
688    external_refs: Vec<ExternalWorkRef>,
689    #[serde(default)]
690    evidence_refs: Vec<WorkEvidenceRef>,
691}
692
693impl<'de> Deserialize<'de> for WorkItem {
694    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
695    where
696        D: serde::Deserializer<'de>,
697    {
698        let mut wire = WorkItemWire::deserialize(deserializer)?;
699        let machine_state = wire.machine_state.take().ok_or_else(|| {
700            serde::de::Error::custom(
701                "WorkItem is missing `machine_state`: lifecycle/revision authority is machine-owned \
702                 and cannot be reconstructed from projected fields",
703            )
704        })?;
705        Ok(Self {
706            id: wire.id,
707            realm_id: wire.realm_id,
708            namespace: wire.namespace,
709            title: wire.title,
710            description: wire.description,
711            status: wire.status,
712            completion_policy: wire.completion_policy,
713            priority: wire.priority,
714            labels: wire.labels,
715            owner: wire.owner,
716            claim: wire.claim,
717            machine_state,
718            revision: wire.revision,
719            due_at: wire.due_at,
720            not_before: wire.not_before,
721            snoozed_until: wire.snoozed_until,
722            created_at: wire.created_at,
723            updated_at: wire.updated_at,
724            terminal_at: wire.terminal_at,
725            external_refs: wire.external_refs,
726            evidence_refs: wire.evidence_refs,
727        })
728    }
729}
730
731#[cfg(feature = "schema")]
732impl schemars::JsonSchema for WorkItem {
733    // NOTE (K21): this manual schema inlines the composite field shapes
734    // (`owner`, `claim`, `completion_policy`, `external_refs`,
735    // `evidence_refs`). The SDK generator's inline-object promotion pass
736    // (tools/sdk-codegen/generate.py) dedupes them by structural content
737    // against the derived sibling schemas (`WorkOwnerKey`,
738    // `WorkCompletionPolicy`, `WorkEvidenceRef`); keep these inline copies
739    // structurally identical to the derived shapes or the generator will
740    // mint `WorkItem*`-named twins (visible in the regen diff and the
741    // promotion report).
742    fn schema_name() -> std::borrow::Cow<'static, str> {
743        "WorkItem".into()
744    }
745
746    fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
747        schemars::json_schema!({
748            "type": "object",
749            "required": [
750                "id",
751                "realm_id",
752                "namespace",
753                "title",
754                "status",
755                "completion_policy",
756                "priority",
757                "machine_state",
758                "revision",
759                "created_at",
760                "updated_at"
761            ],
762            "properties": {
763                "id": { "type": "string" },
764                "realm_id": { "type": "string" },
765                "namespace": { "type": "string" },
766                "title": { "type": "string" },
767                "description": { "type": ["string", "null"] },
768                "status": {
769                    "type": "string",
770                    "enum": ["open", "in_progress", "blocked", "completed", "cancelled", "failed"]
771                },
772                "completion_policy": {
773                    "oneOf": [
774                        {
775                            "type": "object",
776                            "required": ["kind"],
777                            "properties": { "kind": { "type": "string", "const": "self_attest" } }
778                        },
779                        {
780                            "type": "object",
781                            "required": ["kind"],
782                            "properties": { "kind": { "type": "string", "const": "host_confirmed" } }
783                        },
784                        {
785                            "type": "object",
786                            "required": ["kind"],
787                            "properties": { "kind": { "type": "string", "const": "principal_confirmed" } }
788                        },
789                        {
790                            "type": "object",
791                            "required": ["kind", "owner_key"],
792                            "properties": {
793                                "kind": { "type": "string", "const": "supervisor" },
794                                "owner_key": {
795                                    "type": "object",
796                                    "required": ["kind", "id"],
797                                    "properties": {
798                                        "kind": {
799                                            "type": "string",
800                                            "enum": ["principal", "agent", "session", "mob", "label"]
801                                        },
802                                        "id": { "type": "string" }
803                                    }
804                                }
805                            }
806                        },
807                        {
808                            "type": "object",
809                            "required": ["kind", "threshold"],
810                            "properties": {
811                                "kind": { "type": "string", "const": "reviewer_quorum" },
812                                "threshold": { "type": "integer", "format": "uint16", "minimum": 0, "maximum": 65535 }
813                            }
814                        }
815                    ]
816                },
817                "priority": {
818                    "type": "string",
819                    "enum": ["low", "medium", "high"]
820                },
821                "labels": {
822                    "type": "array",
823                    "uniqueItems": true,
824                    "items": { "type": "string" }
825                },
826                "owner": {
827                    "anyOf": [
828                        {
829                            "type": "object",
830                            "required": ["key"],
831                            "properties": {
832                                "key": {
833                                    "type": "object",
834                                    "required": ["kind", "id"],
835                                    "properties": {
836                                        "kind": {
837                                            "type": "string",
838                                            "enum": ["principal", "agent", "session", "mob", "label"]
839                                        },
840                                        "id": { "type": "string" }
841                                    }
842                                },
843                                "display_name": { "type": ["string", "null"] }
844                            }
845                        },
846                        { "type": "null" }
847                    ]
848                },
849                "claim": {
850                    "anyOf": [
851                        {
852                            "type": "object",
853                            "required": ["owner", "claimed_at"],
854                            "properties": {
855                                "owner": {
856                                    "type": "object",
857                                    "required": ["key"],
858                                    "properties": {
859                                        "key": {
860                                            "type": "object",
861                                            "required": ["kind", "id"],
862                                            "properties": {
863                                                "kind": {
864                                                    "type": "string",
865                                                    "enum": ["principal", "agent", "session", "mob", "label"]
866                                                },
867                                                "id": { "type": "string" }
868                                            }
869                                        },
870                                        "display_name": { "type": ["string", "null"] }
871                                    }
872                                },
873                                "claimed_at": { "type": "string", "format": "date-time" },
874                                "lease_expires_at": { "type": ["string", "null"], "format": "date-time" }
875                            }
876                        },
877                        { "type": "null" }
878                    ]
879                },
880                "machine_state": {
881                    "type": "object",
882                    "description": "Catalog-generated WorkGraphLifecycleMachine state projection."
883                },
884                "revision": { "type": "integer", "format": "uint64", "minimum": 0 },
885                "due_at": { "type": ["string", "null"], "format": "date-time" },
886                "not_before": { "type": ["string", "null"], "format": "date-time" },
887                "snoozed_until": { "type": ["string", "null"], "format": "date-time" },
888                "created_at": { "type": "string", "format": "date-time" },
889                "updated_at": { "type": "string", "format": "date-time" },
890                "terminal_at": { "type": ["string", "null"], "format": "date-time" },
891                "external_refs": {
892                    "type": "array",
893                    "items": {
894                        "type": "object",
895                        "required": ["kind", "id"],
896                        "properties": {
897                            "kind": { "type": "string" },
898                            "id": { "type": "string" },
899                            "url": { "type": ["string", "null"] }
900                        }
901                    }
902                },
903                "evidence_refs": {
904                    "type": "array",
905                    "items": {
906                        "type": "object",
907                        "required": ["kind", "id"],
908                        "properties": {
909                            "kind": { "type": "string" },
910                            "id": { "type": "string" },
911                            "label": { "type": ["string", "null"] },
912                            "summary": { "type": ["string", "null"] },
913                            "confirmation_kind": {
914                                "anyOf": [
915                                    {
916                                        "oneOf": [
917                                            {
918                                                "type": "string",
919                                                "enum": [
920                                                    "host_confirmation",
921                                                    "principal_confirmation",
922                                                    "supervisor_confirmation",
923                                                    "reviewer_confirmation"
924                                                ]
925                                            },
926                                            { "type": "string", "const": "self_attest" }
927                                        ]
928                                    },
929                                    { "type": "null" }
930                                ]
931                            },
932                            "confirming_owner_key": {
933                                "anyOf": [
934                                    {
935                                        "type": "object",
936                                        "required": ["kind", "id"],
937                                        "properties": {
938                                            "kind": {
939                                                "type": "string",
940                                                "enum": ["principal", "agent", "session", "mob", "label"]
941                                            },
942                                            "id": { "type": "string" }
943                                        }
944                                    },
945                                    { "type": "null" }
946                                ]
947                            }
948                        }
949                    }
950                }
951            }
952        })
953    }
954}
955
956pub(crate) fn work_lifecycle_state_from_status(status: WorkStatus) -> wg_dsl::WorkLifecycleState {
957    match status {
958        WorkStatus::Open => wg_dsl::WorkLifecycleState::Open,
959        WorkStatus::InProgress => wg_dsl::WorkLifecycleState::InProgress,
960        WorkStatus::Blocked => wg_dsl::WorkLifecycleState::Blocked,
961        WorkStatus::Completed => wg_dsl::WorkLifecycleState::Completed,
962        WorkStatus::Cancelled => wg_dsl::WorkLifecycleState::Cancelled,
963        WorkStatus::Failed => wg_dsl::WorkLifecycleState::Failed,
964    }
965}
966
967pub(crate) fn work_owner_kind_to_machine(kind: WorkOwnerKind) -> wg_dsl::WorkOwnerKind {
968    match kind {
969        WorkOwnerKind::Principal => wg_dsl::WorkOwnerKind::Principal,
970        WorkOwnerKind::Agent => wg_dsl::WorkOwnerKind::Agent,
971        WorkOwnerKind::Session => wg_dsl::WorkOwnerKind::Session,
972        WorkOwnerKind::Mob => wg_dsl::WorkOwnerKind::Mob,
973        WorkOwnerKind::Label => wg_dsl::WorkOwnerKind::Label,
974    }
975}
976
977pub(crate) fn work_owner_key_to_machine(owner: &WorkOwnerKey) -> wg_dsl::WorkOwnerKey {
978    wg_dsl::WorkOwnerKey {
979        kind: work_owner_kind_to_machine(owner.kind),
980        id: owner.id.clone(),
981    }
982}
983
984fn work_owner_key_from_machine(owner: wg_dsl::WorkOwnerKey) -> WorkOwnerKey {
985    let kind = match owner.kind {
986        wg_dsl::WorkOwnerKind::Principal => WorkOwnerKind::Principal,
987        wg_dsl::WorkOwnerKind::Agent => WorkOwnerKind::Agent,
988        wg_dsl::WorkOwnerKind::Session => WorkOwnerKind::Session,
989        wg_dsl::WorkOwnerKind::Mob => WorkOwnerKind::Mob,
990        wg_dsl::WorkOwnerKind::Label => WorkOwnerKind::Label,
991    };
992    WorkOwnerKey { kind, id: owner.id }
993}
994
995#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
996#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
997pub struct WorkEdge {
998    pub realm_id: String,
999    pub namespace: WorkNamespace,
1000    pub kind: WorkEdgeKind,
1001    pub from_id: WorkItemId,
1002    pub to_id: WorkItemId,
1003    pub created_at: DateTime<Utc>,
1004}
1005
1006#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1007#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1008#[serde(rename_all = "snake_case")]
1009pub enum WorkGraphEventKind {
1010    Created,
1011    Updated,
1012    Claimed,
1013    Released,
1014    Blocked,
1015    Closed,
1016    Linked,
1017    EvidenceAdded,
1018    AttentionCreated,
1019    AttentionUpdated,
1020}
1021
1022#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1023#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1024pub struct WorkGraphEvent {
1025    #[serde(default, skip_serializing_if = "Option::is_none")]
1026    pub seq: Option<i64>,
1027    pub realm_id: String,
1028    pub namespace: WorkNamespace,
1029    #[serde(default, skip_serializing_if = "Option::is_none")]
1030    pub item_id: Option<WorkItemId>,
1031    pub kind: WorkGraphEventKind,
1032    pub at: DateTime<Utc>,
1033    #[serde(default, skip_serializing_if = "Value::is_null")]
1034    pub payload: Value,
1035}
1036
1037impl WorkGraphEvent {
1038    pub fn item(
1039        realm_id: String,
1040        namespace: WorkNamespace,
1041        item_id: WorkItemId,
1042        kind: WorkGraphEventKind,
1043        at: DateTime<Utc>,
1044        payload: Value,
1045    ) -> Self {
1046        Self {
1047            seq: None,
1048            realm_id,
1049            namespace,
1050            item_id: Some(item_id),
1051            kind,
1052            at,
1053            payload,
1054        }
1055    }
1056
1057    pub fn graph(
1058        realm_id: String,
1059        namespace: WorkNamespace,
1060        kind: WorkGraphEventKind,
1061        at: DateTime<Utc>,
1062        payload: Value,
1063    ) -> Self {
1064        Self {
1065            seq: None,
1066            realm_id,
1067            namespace,
1068            item_id: None,
1069            kind,
1070            at,
1071            payload,
1072        }
1073    }
1074}
1075
1076#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1077#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1078pub struct CreateWorkItemRequest {
1079    #[serde(default, skip_serializing_if = "Option::is_none")]
1080    pub realm_id: Option<String>,
1081    #[serde(default, skip_serializing_if = "Option::is_none")]
1082    pub namespace: Option<WorkNamespace>,
1083    pub title: String,
1084    #[serde(default, skip_serializing_if = "Option::is_none")]
1085    pub description: Option<String>,
1086    #[serde(default)]
1087    pub priority: WorkPriority,
1088    #[serde(default)]
1089    pub completion_policy: WorkCompletionPolicy,
1090    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
1091    pub labels: BTreeSet<String>,
1092    #[serde(default, skip_serializing_if = "Option::is_none")]
1093    pub due_at: Option<DateTime<Utc>>,
1094    #[serde(default, skip_serializing_if = "Option::is_none")]
1095    pub not_before: Option<DateTime<Utc>>,
1096    #[serde(default, skip_serializing_if = "Option::is_none")]
1097    pub snoozed_until: Option<DateTime<Utc>>,
1098    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1099    pub external_refs: Vec<ExternalWorkRef>,
1100    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1101    pub evidence_refs: Vec<WorkEvidenceRef>,
1102    #[serde(default, skip_serializing_if = "Option::is_none")]
1103    pub status: Option<WorkStatus>,
1104}
1105
1106#[derive(Debug, Clone, Serialize, Deserialize)]
1107#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1108pub struct UpdateWorkItemRequest {
1109    pub id: WorkItemId,
1110    #[serde(default, skip_serializing_if = "Option::is_none")]
1111    pub realm_id: Option<String>,
1112    #[serde(default, skip_serializing_if = "Option::is_none")]
1113    pub namespace: Option<WorkNamespace>,
1114    pub expected_revision: u64,
1115    #[serde(default, skip_serializing_if = "Option::is_none")]
1116    pub title: Option<String>,
1117    #[serde(default, skip_serializing_if = "Option::is_none")]
1118    pub description: Option<String>,
1119    #[serde(default, skip_serializing_if = "Option::is_none")]
1120    pub priority: Option<WorkPriority>,
1121    #[serde(default, skip_serializing_if = "Option::is_none")]
1122    pub completion_policy: Option<WorkCompletionPolicy>,
1123    #[serde(default, skip_serializing_if = "Option::is_none")]
1124    pub labels: Option<BTreeSet<String>>,
1125    #[serde(default, skip_serializing_if = "Option::is_none")]
1126    pub due_at: Option<DateTime<Utc>>,
1127    #[serde(default, skip_serializing_if = "Option::is_none")]
1128    pub not_before: Option<DateTime<Utc>>,
1129    #[serde(default, skip_serializing_if = "Option::is_none")]
1130    pub snoozed_until: Option<DateTime<Utc>>,
1131    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1132    pub external_refs: Vec<ExternalWorkRef>,
1133}
1134
1135#[derive(Debug, Clone, Serialize, Deserialize)]
1136#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1137pub struct ClaimWorkItemRequest {
1138    pub id: WorkItemId,
1139    #[serde(default, skip_serializing_if = "Option::is_none")]
1140    pub realm_id: Option<String>,
1141    #[serde(default, skip_serializing_if = "Option::is_none")]
1142    pub namespace: Option<WorkNamespace>,
1143    pub expected_revision: u64,
1144    pub owner: WorkOwner,
1145    #[serde(default, skip_serializing_if = "Option::is_none")]
1146    pub lease_seconds: Option<u64>,
1147    #[serde(default, skip_serializing_if = "Option::is_none")]
1148    pub lease_expires_at: Option<DateTime<Utc>>,
1149}
1150
1151#[derive(Debug, Clone, Serialize, Deserialize)]
1152#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1153pub struct ReleaseWorkItemRequest {
1154    pub id: WorkItemId,
1155    #[serde(default, skip_serializing_if = "Option::is_none")]
1156    pub realm_id: Option<String>,
1157    #[serde(default, skip_serializing_if = "Option::is_none")]
1158    pub namespace: Option<WorkNamespace>,
1159    pub expected_revision: u64,
1160}
1161
1162#[derive(Debug, Clone, Serialize, Deserialize)]
1163#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1164pub struct CloseWorkItemRequest {
1165    pub id: WorkItemId,
1166    #[serde(default, skip_serializing_if = "Option::is_none")]
1167    pub realm_id: Option<String>,
1168    #[serde(default, skip_serializing_if = "Option::is_none")]
1169    pub namespace: Option<WorkNamespace>,
1170    pub expected_revision: u64,
1171    #[serde(default = "default_terminal_status")]
1172    pub status: WorkStatus,
1173}
1174
1175fn default_terminal_status() -> WorkStatus {
1176    WorkStatus::Completed
1177}
1178
1179#[derive(Debug, Clone, Serialize, Deserialize)]
1180#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1181pub struct LinkWorkItemsRequest {
1182    #[serde(default, skip_serializing_if = "Option::is_none")]
1183    pub realm_id: Option<String>,
1184    #[serde(default, skip_serializing_if = "Option::is_none")]
1185    pub namespace: Option<WorkNamespace>,
1186    pub kind: WorkEdgeKind,
1187    pub from_id: WorkItemId,
1188    pub to_id: WorkItemId,
1189}
1190
1191#[derive(Debug, Clone, Serialize, Deserialize)]
1192#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1193pub struct AddEvidenceRequest {
1194    pub id: WorkItemId,
1195    #[serde(default, skip_serializing_if = "Option::is_none")]
1196    pub realm_id: Option<String>,
1197    #[serde(default, skip_serializing_if = "Option::is_none")]
1198    pub namespace: Option<WorkNamespace>,
1199    pub expected_revision: u64,
1200    pub evidence: WorkEvidenceRef,
1201}
1202
1203#[derive(Debug, Clone, Serialize, Deserialize)]
1204#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1205pub struct GoalCreateRequest {
1206    #[serde(default, skip_serializing_if = "Option::is_none")]
1207    pub realm_id: Option<String>,
1208    #[serde(default, skip_serializing_if = "Option::is_none")]
1209    pub namespace: Option<WorkNamespace>,
1210    pub title: String,
1211    #[serde(default, skip_serializing_if = "Option::is_none")]
1212    pub description: Option<String>,
1213    pub target: GoalAttentionTarget,
1214    #[serde(default)]
1215    pub mode: WorkAttentionMode,
1216    #[serde(default)]
1217    pub completion_policy: WorkCompletionPolicy,
1218    #[serde(default)]
1219    pub delegated_authority: AttentionDelegatedAuthority,
1220    #[serde(default)]
1221    pub projection_policy: AttentionProjectionPolicy,
1222}
1223
1224#[derive(Debug, Clone, Serialize, Deserialize)]
1225#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1226pub struct PublicGoalCreateRequest {
1227    #[serde(default, skip_serializing_if = "Option::is_none")]
1228    pub realm_id: Option<String>,
1229    #[serde(default, skip_serializing_if = "Option::is_none")]
1230    pub namespace: Option<WorkNamespace>,
1231    pub title: String,
1232    #[serde(default, skip_serializing_if = "Option::is_none")]
1233    pub description: Option<String>,
1234    pub target: GoalAttentionTarget,
1235    #[serde(default)]
1236    pub mode: WorkAttentionMode,
1237    #[serde(default)]
1238    pub completion_policy: PublicGoalCompletionPolicy,
1239    #[serde(default)]
1240    pub delegated_authority: AttentionDelegatedAuthority,
1241    #[serde(default)]
1242    pub projection_policy: AttentionProjectionPolicy,
1243}
1244
1245impl From<PublicGoalCreateRequest> for GoalCreateRequest {
1246    fn from(request: PublicGoalCreateRequest) -> Self {
1247        Self {
1248            realm_id: request.realm_id,
1249            namespace: request.namespace,
1250            title: request.title,
1251            description: request.description,
1252            target: request.target,
1253            mode: request.mode,
1254            completion_policy: request.completion_policy.into(),
1255            delegated_authority: request.delegated_authority,
1256            projection_policy: request.projection_policy,
1257        }
1258    }
1259}
1260
1261#[derive(Debug, Clone, Serialize, Deserialize)]
1262#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1263pub struct GoalCreateResult {
1264    pub item: WorkItem,
1265    pub attention: WorkAttentionBinding,
1266}
1267
1268#[derive(Debug, Clone, Serialize, Deserialize)]
1269#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1270pub struct GoalStatusRequest {
1271    pub binding_id: WorkAttentionBindingId,
1272    #[serde(default, skip_serializing_if = "Option::is_none")]
1273    pub realm_id: Option<String>,
1274    #[serde(default, skip_serializing_if = "Option::is_none")]
1275    pub namespace: Option<WorkNamespace>,
1276}
1277
1278#[derive(Debug, Clone, Serialize, Deserialize)]
1279#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1280pub struct GoalStatusResult {
1281    pub item: WorkItem,
1282    pub attention: WorkAttentionBinding,
1283}
1284
1285#[derive(Debug, Clone, Serialize, Deserialize)]
1286#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1287pub struct GoalConfirmRequest {
1288    pub binding_id: WorkAttentionBindingId,
1289    #[serde(default, skip_serializing_if = "Option::is_none")]
1290    pub realm_id: Option<String>,
1291    #[serde(default, skip_serializing_if = "Option::is_none")]
1292    pub namespace: Option<WorkNamespace>,
1293    pub expected_revision: u64,
1294    pub evidence: WorkEvidenceRef,
1295    #[serde(skip)]
1296    #[cfg_attr(feature = "schema", schemars(skip))]
1297    pub principal: Option<WorkOwnerKey>,
1298    #[serde(skip)]
1299    #[cfg_attr(feature = "schema", schemars(skip))]
1300    pub trusted_principal: Option<WorkOwnerKey>,
1301}
1302
1303impl GoalConfirmRequest {
1304    /// Promote an already-authenticated host principal into the service authority field.
1305    pub fn with_trusted_principal(mut self, principal: Option<WorkOwnerKey>) -> Self {
1306        if self.trusted_principal.is_none() {
1307            self.trusted_principal = principal;
1308        }
1309        self
1310    }
1311}
1312
1313#[derive(Debug, Clone, Serialize, Deserialize)]
1314#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1315pub struct GoalConfirmResult {
1316    pub item: WorkItem,
1317    pub attention: WorkAttentionBinding,
1318}
1319
1320#[derive(Debug, Clone, Serialize, Deserialize)]
1321#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1322pub struct GoalRequestCloseRequest {
1323    pub binding_id: WorkAttentionBindingId,
1324    #[serde(default, skip_serializing_if = "Option::is_none")]
1325    pub realm_id: Option<String>,
1326    #[serde(default, skip_serializing_if = "Option::is_none")]
1327    pub namespace: Option<WorkNamespace>,
1328    pub expected_revision: u64,
1329    #[serde(default)]
1330    pub status: GoalTerminalStatus,
1331}
1332
1333#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1334#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1335#[serde(rename_all = "snake_case")]
1336pub enum GoalTerminalStatus {
1337    #[default]
1338    Completed,
1339    Cancelled,
1340    Failed,
1341}
1342
1343impl From<GoalTerminalStatus> for WorkStatus {
1344    fn from(status: GoalTerminalStatus) -> Self {
1345        match status {
1346            GoalTerminalStatus::Completed => Self::Completed,
1347            GoalTerminalStatus::Cancelled => Self::Cancelled,
1348            GoalTerminalStatus::Failed => Self::Failed,
1349        }
1350    }
1351}
1352
1353#[derive(Debug, Clone, Serialize, Deserialize)]
1354#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1355pub struct PublicGoalRequestCloseRequest {
1356    pub binding_id: WorkAttentionBindingId,
1357    #[serde(default, skip_serializing_if = "Option::is_none")]
1358    pub realm_id: Option<String>,
1359    #[serde(default, skip_serializing_if = "Option::is_none")]
1360    pub namespace: Option<WorkNamespace>,
1361    pub expected_revision: u64,
1362    #[serde(default)]
1363    pub status: GoalTerminalStatus,
1364}
1365
1366impl From<PublicGoalRequestCloseRequest> for GoalRequestCloseRequest {
1367    fn from(request: PublicGoalRequestCloseRequest) -> Self {
1368        Self {
1369            binding_id: request.binding_id,
1370            realm_id: request.realm_id,
1371            namespace: request.namespace,
1372            expected_revision: request.expected_revision,
1373            status: request.status,
1374        }
1375    }
1376}
1377
1378#[derive(Debug, Clone, Serialize, Deserialize)]
1379#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1380pub struct GoalRequestCloseResult {
1381    pub item: WorkItem,
1382    pub attention: WorkAttentionBinding,
1383}
1384
1385#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1386#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1387pub struct AttentionListRequest {
1388    #[serde(default, skip_serializing_if = "Option::is_none")]
1389    pub realm_id: Option<String>,
1390    #[serde(default, skip_serializing_if = "Option::is_none")]
1391    pub namespace: Option<WorkNamespace>,
1392    #[serde(default, skip_serializing_if = "Option::is_none")]
1393    pub target: Option<WorkAttentionTarget>,
1394    #[serde(default, skip_serializing_if = "Option::is_none")]
1395    pub status: Option<WorkAttentionStatus>,
1396}
1397
1398#[derive(Debug, Clone, Serialize, Deserialize)]
1399#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1400pub struct AttentionListResult {
1401    pub attention: Vec<WorkAttentionBinding>,
1402}
1403
1404#[derive(Debug, Clone, Serialize, Deserialize)]
1405#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1406pub struct AttentionBindingRequest {
1407    pub binding_id: WorkAttentionBindingId,
1408    #[serde(default, skip_serializing_if = "Option::is_none")]
1409    pub realm_id: Option<String>,
1410    #[serde(default, skip_serializing_if = "Option::is_none")]
1411    pub namespace: Option<WorkNamespace>,
1412}
1413
1414#[derive(Debug, Clone, Serialize, Deserialize)]
1415#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1416pub struct AttentionPauseRequest {
1417    pub binding_id: WorkAttentionBindingId,
1418    #[serde(default, skip_serializing_if = "Option::is_none")]
1419    pub realm_id: Option<String>,
1420    #[serde(default, skip_serializing_if = "Option::is_none")]
1421    pub namespace: Option<WorkNamespace>,
1422    pub expected_revision: u64,
1423    #[serde(default, skip_serializing_if = "Option::is_none")]
1424    pub until: Option<DateTime<Utc>>,
1425}
1426
1427#[derive(Debug, Clone, Serialize, Deserialize)]
1428#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1429pub struct AttentionResumeRequest {
1430    pub binding_id: WorkAttentionBindingId,
1431    #[serde(default, skip_serializing_if = "Option::is_none")]
1432    pub realm_id: Option<String>,
1433    #[serde(default, skip_serializing_if = "Option::is_none")]
1434    pub namespace: Option<WorkNamespace>,
1435    pub expected_revision: u64,
1436}
1437
1438#[derive(Debug, Clone, Serialize, Deserialize)]
1439#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1440pub struct AttentionReassignRequest {
1441    pub binding_id: WorkAttentionBindingId,
1442    #[serde(default, skip_serializing_if = "Option::is_none")]
1443    pub realm_id: Option<String>,
1444    #[serde(default, skip_serializing_if = "Option::is_none")]
1445    pub namespace: Option<WorkNamespace>,
1446    pub target: GoalAttentionTarget,
1447}
1448
1449#[derive(Debug, Clone, Serialize, Deserialize)]
1450#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1451pub struct AttentionBindingResult {
1452    pub attention: WorkAttentionBinding,
1453}
1454
1455#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1456#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1457#[serde(rename_all = "snake_case")]
1458pub enum AttentionContinueOutcome {
1459    Accepted,
1460    Deduplicated,
1461    Rejected,
1462}
1463
1464#[derive(Debug, Clone, Serialize, Deserialize)]
1465#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1466pub struct AttentionContinueResult {
1467    pub outcome: AttentionContinueOutcome,
1468    #[serde(default, skip_serializing_if = "Option::is_none")]
1469    pub input_id: Option<String>,
1470    #[serde(default, skip_serializing_if = "Option::is_none")]
1471    pub existing_id: Option<String>,
1472    #[serde(default, skip_serializing_if = "Option::is_none")]
1473    pub reason: Option<String>,
1474}
1475
1476#[derive(Debug, Clone, Serialize, Deserialize)]
1477#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1478pub struct AttentionProjectionRequest {
1479    pub binding_id: WorkAttentionBindingId,
1480    #[serde(default, skip_serializing_if = "Option::is_none")]
1481    pub realm_id: Option<String>,
1482    #[serde(default, skip_serializing_if = "Option::is_none")]
1483    pub namespace: Option<WorkNamespace>,
1484}
1485
1486#[derive(Debug, Clone, Serialize, Deserialize)]
1487#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1488pub struct AttentionProjectionResult {
1489    pub projection: AttentionContextProjection,
1490}
1491
1492#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1493#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1494pub struct AttentionContextProjection {
1495    pub binding_id: WorkAttentionBindingId,
1496    pub work_ref: WorkItemRef,
1497    pub mode: WorkAttentionMode,
1498    pub binding_revision: u64,
1499    pub item_revision: u64,
1500    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1501    pub parent_refs: Vec<WorkItemRef>,
1502    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1503    pub parent_context: Vec<AttentionProjectionParentContext>,
1504    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1505    pub evidence_refs: Vec<WorkEvidenceRef>,
1506    pub authority: ProjectedAttentionAuthority,
1507    pub text: AttentionProjectionText,
1508}
1509
1510#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1511#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1512pub struct AttentionProjectionParentContext {
1513    pub work_ref: WorkItemRef,
1514    pub status: WorkStatus,
1515    pub revision: u64,
1516}
1517
1518#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1519#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1520pub struct ProjectedAttentionAuthority {
1521    pub can_get: bool,
1522    pub can_add_evidence: bool,
1523    pub can_release: bool,
1524    pub can_update: bool,
1525    pub can_block: bool,
1526    pub can_create: bool,
1527    pub can_link: bool,
1528    pub can_link_parent: bool,
1529    pub can_link_related: bool,
1530    pub can_link_derived_from: bool,
1531    #[serde(default)]
1532    pub can_close_own_review_item: bool,
1533    pub can_close_if_policy_allows: bool,
1534}
1535
1536#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1537#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1538pub struct AttentionProjectionText {
1539    pub title: String,
1540    pub rendered: String,
1541    pub truncated: bool,
1542}
1543
1544#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1545#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1546pub struct WorkItemFilter {
1547    #[serde(default, skip_serializing_if = "Option::is_none")]
1548    pub realm_id: Option<String>,
1549    #[serde(default, skip_serializing_if = "Option::is_none")]
1550    pub namespace: Option<WorkNamespace>,
1551    #[serde(default)]
1552    pub all_namespaces: bool,
1553    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1554    pub statuses: Vec<WorkStatus>,
1555    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1556    pub labels: Vec<String>,
1557    #[serde(default)]
1558    pub include_terminal: bool,
1559    #[serde(default, skip_serializing_if = "Option::is_none")]
1560    pub limit: Option<usize>,
1561}
1562
1563#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1564#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1565pub struct ReadyWorkFilter {
1566    #[serde(default, skip_serializing_if = "Option::is_none")]
1567    pub realm_id: Option<String>,
1568    #[serde(default, skip_serializing_if = "Option::is_none")]
1569    pub namespace: Option<WorkNamespace>,
1570    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1571    pub labels: Vec<String>,
1572    #[serde(default, skip_serializing_if = "Option::is_none")]
1573    pub limit: Option<usize>,
1574}
1575
1576#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1577#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1578pub struct WorkGraphSnapshotFilter {
1579    #[serde(default, skip_serializing_if = "Option::is_none")]
1580    pub realm_id: Option<String>,
1581    #[serde(default, skip_serializing_if = "Option::is_none")]
1582    pub namespace: Option<WorkNamespace>,
1583    #[serde(default)]
1584    pub all_namespaces: bool,
1585    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1586    pub statuses: Vec<WorkStatus>,
1587    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1588    pub labels: Vec<String>,
1589    #[serde(default)]
1590    pub include_terminal: bool,
1591    #[serde(default, skip_serializing_if = "Option::is_none")]
1592    pub limit: Option<usize>,
1593}
1594
1595/// Parameters identifying a single WorkGraph item by id within an optional
1596/// realm/namespace scope (`workgraph/get`).
1597#[derive(Debug, Clone, Serialize, Deserialize)]
1598#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1599pub struct WorkGraphIdParams {
1600    pub id: WorkItemId,
1601    #[serde(default, skip_serializing_if = "Option::is_none")]
1602    pub realm_id: Option<String>,
1603    #[serde(default, skip_serializing_if = "Option::is_none")]
1604    pub namespace: Option<WorkNamespace>,
1605}
1606
1607#[derive(Debug, Clone, Serialize, Deserialize)]
1608#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1609pub struct WorkGraphSnapshot {
1610    pub realm_id: String,
1611    #[serde(default, skip_serializing_if = "Option::is_none")]
1612    pub namespace: Option<WorkNamespace>,
1613    pub all_namespaces: bool,
1614    pub captured_at: DateTime<Utc>,
1615    #[serde(default, skip_serializing_if = "Option::is_none")]
1616    pub event_high_water_mark: Option<i64>,
1617    pub items: Vec<WorkItem>,
1618    pub edges: Vec<WorkEdge>,
1619    #[serde(default)]
1620    pub attention: Vec<WorkAttentionBinding>,
1621    pub ready_item_ids: Vec<WorkItemId>,
1622}
1623
1624#[derive(Debug, Clone, Serialize, Deserialize)]
1625#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1626pub struct WorkGraphItemsResponse {
1627    pub items: Vec<WorkItem>,
1628}
1629
1630#[derive(Debug, Clone, Serialize, Deserialize)]
1631#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1632pub struct WorkGraphEventsResponse {
1633    pub events: Vec<WorkGraphEvent>,
1634}
1635
1636#[cfg(test)]
1637#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1638mod tests {
1639    use super::*;
1640    use crate::machine::WorkGraphMachine;
1641
1642    fn machine_item() -> WorkItem {
1643        WorkGraphMachine::create_item(
1644            CreateWorkItemRequest {
1645                title: "deserialize-authority".to_string(),
1646                ..Default::default()
1647            },
1648            "realm".to_string(),
1649            WorkNamespace::default(),
1650            Utc::now(),
1651        )
1652        .expect("machine create_item")
1653        .0
1654    }
1655
1656    #[test]
1657    fn work_item_round_trip_preserves_machine_state() {
1658        let item = machine_item();
1659        let json = serde_json::to_string(&item).expect("serialize work item");
1660        let decoded: WorkItem = serde_json::from_str(&json).expect("deserialize work item");
1661        assert_eq!(
1662            decoded, item,
1663            "round-trip must preserve the whole work item"
1664        );
1665        assert_eq!(
1666            decoded.machine_state, item.machine_state,
1667            "round-trip must preserve machine-owned lifecycle authority verbatim"
1668        );
1669    }
1670
1671    #[test]
1672    fn work_item_without_machine_state_fails_closed() {
1673        let item = machine_item();
1674        let mut value = serde_json::to_value(&item).expect("serialize work item to value");
1675        value
1676            .as_object_mut()
1677            .expect("work item json object")
1678            .remove("machine_state");
1679
1680        let result: Result<WorkItem, _> = serde_json::from_value(value);
1681        let err = result.expect_err(
1682            "deserializing a WorkItem without machine_state must fail closed, \
1683             never fabricate lifecycle/revision authority from projected fields",
1684        );
1685        assert!(
1686            err.to_string().contains("machine_state"),
1687            "fail-closed error must cite the missing machine_state authority, got: {err}"
1688        );
1689    }
1690}