Skip to main content

meerkat_workgraph/
machine.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use chrono::{DateTime, Duration, Utc};
4use serde_json::json;
5
6use crate::WorkGraphError;
7use crate::machines::{work_attention_lifecycle as attention_dsl, workgraph_lifecycle as wg_dsl};
8use crate::types::{
9    AddEvidenceRequest, AttentionDelegatedAuthority, ClaimWorkItemRequest, CloseWorkItemRequest,
10    CreateWorkItemRequest, PolicyEscalateRequest, ProjectedAttentionAuthority,
11    ReleaseWorkItemRequest, UpdateWorkItemRequest, WorkAttentionBinding, WorkAttentionMode,
12    WorkAttentionStatus, WorkClaim, WorkCompletionPolicy, WorkEdge, WorkEdgeKind, WorkGraphEvent,
13    WorkGraphEventKind, WorkGraphMachineState, WorkItem, WorkItemId, WorkNamespace, WorkStatus,
14};
15
16/// Machine-owned public error classification surfaced to REST/RPC callers.
17///
18/// Re-exported from the canonical `WorkGraphLifecycleMachine` DSL: the machine
19/// is the sole authority for the variant->class POLICY (see
20/// `WorkGraphMachine::public_error_class`). Surfaces mirror the emitted class.
21pub use wg_dsl::WorkGraphPublicErrorClass;
22
23/// Machine-owned public-confirmation admission verdict surfaced to the
24/// public-confirm surface.
25///
26/// Re-exported from the canonical `WorkGraphLifecycleMachine` DSL: the machine
27/// is the sole authority for the trust-scoped eligibility (see
28/// `WorkGraphMachine::classify_public_confirmation_admission`). The surface
29/// mirrors the emitted verdict.
30pub use wg_dsl::WorkPublicConfirmationAdmissionKind;
31
32/// Machine-owned admission verdict for a requested mutation of a work item's
33/// `completion_policy`.
34///
35/// Re-exported from the canonical `WorkGraphLifecycleMachine` DSL: the machine is
36/// the sole authority for the invariant "a work item's completion policy is
37/// fixed at creation, monotonically tightenable, and never update-writable" (see
38/// `WorkGraphMachine::classify_completion_policy_mutation_admission`). The shell
39/// mirrors the emitted verdict.
40pub use wg_dsl::WorkCompletionPolicyMutationAdmissionKind;
41pub use wg_dsl::WorkPolicyEscalationAdmissionKind;
42
43#[derive(Debug, Default, Clone, Copy)]
44pub struct WorkAttentionMachine;
45
46impl WorkAttentionMachine {
47    pub fn pause(
48        mut binding: WorkAttentionBinding,
49        expected_revision: u64,
50        until: Option<DateTime<Utc>>,
51        now: DateTime<Utc>,
52    ) -> Result<WorkAttentionBinding, WorkGraphError> {
53        let input = attention_dsl::WorkAttentionLifecycleInput::Pause {
54            expected_revision,
55            until_utc_ms: until.map(datetime_to_millis),
56        };
57        binding.machine_state = apply_attention_dsl(&binding, input, Some(expected_revision))?;
58        sync_attention_from_machine_state(&mut binding);
59        binding.updated_at = now;
60        Ok(binding)
61    }
62
63    pub fn resume(
64        mut binding: WorkAttentionBinding,
65        expected_revision: u64,
66        now: DateTime<Utc>,
67    ) -> Result<WorkAttentionBinding, WorkGraphError> {
68        let input = attention_dsl::WorkAttentionLifecycleInput::Resume { expected_revision };
69        binding.machine_state = apply_attention_dsl(&binding, input, Some(expected_revision))?;
70        sync_attention_from_machine_state(&mut binding);
71        binding.updated_at = now;
72        Ok(binding)
73    }
74
75    pub fn stop(
76        mut binding: WorkAttentionBinding,
77        expected_revision: u64,
78        now: DateTime<Utc>,
79    ) -> Result<WorkAttentionBinding, WorkGraphError> {
80        let input = attention_dsl::WorkAttentionLifecycleInput::Stop {
81            expected_revision,
82            at_utc_ms: datetime_to_millis(now),
83        };
84        binding.machine_state = apply_attention_dsl(&binding, input, Some(expected_revision))?;
85        sync_attention_from_machine_state(&mut binding);
86        binding.updated_at = now;
87        Ok(binding)
88    }
89
90    pub fn supersede(
91        mut binding: WorkAttentionBinding,
92        expected_revision: u64,
93        superseded_by_binding_id: &crate::types::WorkAttentionBindingId,
94        now: DateTime<Utc>,
95    ) -> Result<WorkAttentionBinding, WorkGraphError> {
96        let input = attention_dsl::WorkAttentionLifecycleInput::Supersede {
97            expected_revision,
98            superseded_by_binding_key: attention_dsl::WorkAttentionBindingKey(
99                superseded_by_binding_id.as_str().to_string(),
100            ),
101            at_utc_ms: datetime_to_millis(now),
102        };
103        binding.machine_state = apply_attention_dsl(&binding, input, Some(expected_revision))?;
104        sync_attention_from_machine_state(&mut binding);
105        binding.updated_at = now;
106        Ok(binding)
107    }
108
109    /// Resolve attention-projection eligibility for the binding at `now`.
110    ///
111    /// The shell extracts only the raw wall-clock `now` (a pure observation) and
112    /// drives the canonical `WorkAttentionLifecycleMachine`'s
113    /// `ClassifyAttentionEligibility` input over the recovered binding state. The
114    /// machine owns the eligibility POLICY — including the Paused deadline-elapsed
115    /// rule (`paused_until <= now`) — and emits the verdict; this function only
116    /// mirrors the emitted `AttentionEligibilityClassified.eligible`. It fails
117    /// closed (returns `Err`) if the machine refuses to classify or emits no
118    /// verdict; it decides nothing.
119    pub fn classify_eligibility_at(
120        binding: &WorkAttentionBinding,
121        now: DateTime<Utc>,
122    ) -> Result<bool, WorkGraphError> {
123        let mut dsl_auth =
124            attention_dsl::WorkAttentionLifecycleMachineAuthority::recover_from_state(
125                binding.machine_state.clone(),
126            )
127            .map_err(|error| {
128                WorkGraphError::InvalidTransition(format!(
129                    "attention binding {} refused eligibility recovery: {error:?}",
130                    binding.binding_id
131                ))
132            })?;
133        let transition = attention_dsl::WorkAttentionLifecycleMachineMutator::apply(
134            &mut dsl_auth,
135            attention_dsl::WorkAttentionLifecycleInput::ClassifyAttentionEligibility {
136                now_utc_ms: datetime_to_millis(now),
137            },
138        )
139        .map_err(|error| {
140            WorkGraphError::InvalidTransition(format!(
141                "attention binding {} refused eligibility classification: {error:?}",
142                binding.binding_id
143            ))
144        })?;
145
146        let mut classified = None;
147        for effect in transition.effects() {
148            if let attention_dsl::WorkAttentionLifecycleEffect::AttentionEligibilityClassified {
149                eligible,
150            } = effect
151                && classified.replace(*eligible).is_some()
152            {
153                return Err(WorkGraphError::Store(format!(
154                    "attention binding {} emitted multiple eligibility verdicts",
155                    binding.binding_id
156                )));
157            }
158        }
159
160        classified.ok_or_else(|| {
161            WorkGraphError::Store(format!(
162                "attention binding {} emitted no eligibility verdict",
163                binding.binding_id
164            ))
165        })
166    }
167
168    /// Resolve the projected attention authority for the binding.
169    ///
170    /// The shell extracts only the raw binding facts (`mode`,
171    /// `delegated_authority`) and drives the canonical
172    /// `WorkAttentionLifecycleMachine`'s `ClassifyAttentionAuthority` input over
173    /// the recovered binding state. The machine owns the COMPLETE per-stance
174    /// tool-admission POLICY (which stances may read, add evidence, release,
175    /// update, block, create, link, close their own review item, or
176    /// close-if-policy-allows) and emits the capability verdict; this function
177    /// mirrors the emitted `AttentionAuthorityClassified` capability bits. Fails
178    /// closed.
179    pub fn classify_authority(
180        binding: &WorkAttentionBinding,
181    ) -> Result<ProjectedAttentionAuthority, WorkGraphError> {
182        let mode = attention_mode_to_dsl(binding.mode);
183        let delegated_authority = attention_delegated_authority_to_dsl(binding.delegated_authority);
184        let mut dsl_auth =
185            attention_dsl::WorkAttentionLifecycleMachineAuthority::recover_from_state(
186                binding.machine_state.clone(),
187            )
188            .map_err(|error| {
189                WorkGraphError::InvalidTransition(format!(
190                    "attention binding {} refused authority recovery: {error:?}",
191                    binding.binding_id
192                ))
193            })?;
194        let transition = attention_dsl::WorkAttentionLifecycleMachineMutator::apply(
195            &mut dsl_auth,
196            attention_dsl::WorkAttentionLifecycleInput::ClassifyAttentionAuthority {
197                mode,
198                delegated_authority,
199            },
200        )
201        .map_err(|error| {
202            WorkGraphError::InvalidTransition(format!(
203                "attention binding {} refused authority classification: {error:?}",
204                binding.binding_id
205            ))
206        })?;
207
208        let mut classified = None;
209        for effect in transition.effects() {
210            if let attention_dsl::WorkAttentionLifecycleEffect::AttentionAuthorityClassified {
211                can_get,
212                can_add_evidence,
213                can_release,
214                can_update,
215                can_block,
216                can_create,
217                can_link,
218                can_link_parent,
219                can_link_related,
220                can_link_derived_from,
221                can_close_own_review_item,
222                can_close_if_policy_allows,
223            } = effect
224            {
225                let verdict = ProjectedAttentionAuthority {
226                    can_get: *can_get,
227                    can_add_evidence: *can_add_evidence,
228                    can_release: *can_release,
229                    can_update: *can_update,
230                    can_block: *can_block,
231                    can_create: *can_create,
232                    can_link: *can_link,
233                    can_link_parent: *can_link_parent,
234                    can_link_related: *can_link_related,
235                    can_link_derived_from: *can_link_derived_from,
236                    can_close_own_review_item: *can_close_own_review_item,
237                    can_close_if_policy_allows: *can_close_if_policy_allows,
238                };
239                if classified.replace(verdict).is_some() {
240                    return Err(WorkGraphError::Store(format!(
241                        "attention binding {} emitted multiple authority verdicts",
242                        binding.binding_id
243                    )));
244                }
245            }
246        }
247
248        classified.ok_or_else(|| {
249            WorkGraphError::Store(format!(
250                "attention binding {} emitted no authority verdict",
251                binding.binding_id
252            ))
253        })
254    }
255}
256
257fn attention_mode_to_dsl(mode: WorkAttentionMode) -> attention_dsl::WorkAttentionMode {
258    match mode {
259        WorkAttentionMode::Pursue => attention_dsl::WorkAttentionMode::Pursue,
260        WorkAttentionMode::Coordinate => attention_dsl::WorkAttentionMode::Coordinate,
261        WorkAttentionMode::Review => attention_dsl::WorkAttentionMode::Review,
262        WorkAttentionMode::Falsify => attention_dsl::WorkAttentionMode::Falsify,
263        WorkAttentionMode::Judge => attention_dsl::WorkAttentionMode::Judge,
264        WorkAttentionMode::Observe => attention_dsl::WorkAttentionMode::Observe,
265    }
266}
267
268fn attention_delegated_authority_to_dsl(
269    authority: AttentionDelegatedAuthority,
270) -> attention_dsl::AttentionDelegatedAuthority {
271    match authority {
272        AttentionDelegatedAuthority::AddEvidence => {
273            attention_dsl::AttentionDelegatedAuthority::AddEvidence
274        }
275        AttentionDelegatedAuthority::CloseOwnReviewItem => {
276            attention_dsl::AttentionDelegatedAuthority::CloseOwnReviewItem
277        }
278        AttentionDelegatedAuthority::RequestClosure => {
279            attention_dsl::AttentionDelegatedAuthority::RequestClosure
280        }
281        AttentionDelegatedAuthority::CloseIfPolicyAllows => {
282            attention_dsl::AttentionDelegatedAuthority::CloseIfPolicyAllows
283        }
284    }
285}
286
287#[derive(Debug, Default, Clone, Copy)]
288pub struct WorkGraphMachine;
289
290impl WorkGraphMachine {
291    /// Mechanically assert that a `WorkItem`'s projected lifecycle fields agree
292    /// with its machine-owned `machine_state` authority.
293    ///
294    /// This is a *pure structural check*: it borrows `item` immutably and can
295    /// only return `Ok(())` (every projection matches the machine state) or an
296    /// `Err` rejecting the drift. It never synthesizes, repairs, or derives any
297    /// lifecycle/revision field — the canonical truth lives in
298    /// `WorkGraphMachineState`, and this guard merely rejects projections that
299    /// disagree with it.
300    pub fn validate_item_projection(item: &WorkItem) -> Result<(), WorkGraphError> {
301        validate_item_machine_projection(item)
302    }
303
304    /// Resolve the public error class for a `WorkGraphError`.
305    ///
306    /// The shell performs only a pure typed extraction of the error variant
307    /// into a `WorkGraphErrorKind` discriminant (one kind per variant, no
308    /// grouping). The variant->class POLICY is owned by the canonical
309    /// `WorkGraphLifecycleMachine`: this drives the machine's
310    /// `ClassifyWorkGraphPublicError` input and mirrors the emitted
311    /// `WorkGraphPublicErrorClassified` effect. The shell decides nothing.
312    pub fn public_error_class(
313        error: &WorkGraphError,
314    ) -> Result<WorkGraphPublicErrorClass, WorkGraphError> {
315        let kind = work_graph_error_kind(error);
316        let mut dsl_auth = wg_dsl::WorkGraphLifecycleMachineAuthority::new();
317        let transition = wg_dsl::WorkGraphLifecycleMachineMutator::apply(
318            &mut dsl_auth,
319            wg_dsl::WorkGraphLifecycleInput::ClassifyWorkGraphPublicError { kind },
320        )
321        .map_err(|transition_error| {
322            WorkGraphError::Store(format!(
323                "generated WorkGraph public error classification refused kind {kind:?}: {transition_error:?}"
324            ))
325        })?;
326
327        let mut classified = None;
328        for effect in transition.effects() {
329            if let wg_dsl::WorkGraphLifecycleEffect::WorkGraphPublicErrorClassified {
330                kind: emitted_kind,
331                public_class,
332            } = effect
333            {
334                if *emitted_kind != kind {
335                    return Err(WorkGraphError::Store(format!(
336                        "generated WorkGraph public error classification emitted kind {emitted_kind:?} while classifying {kind:?}"
337                    )));
338                }
339                if classified.replace(*public_class).is_some() {
340                    return Err(WorkGraphError::Store(format!(
341                        "generated WorkGraph public error classification emitted multiple classes for kind {kind:?}"
342                    )));
343                }
344            }
345        }
346
347        classified.ok_or_else(|| {
348            WorkGraphError::Store(format!(
349                "generated WorkGraph public error classification did not emit a class for kind {kind:?}"
350            ))
351        })
352    }
353
354    /// Resolve whether an untrusted PUBLIC caller may confirm an item with the
355    /// given machine-owned completion policy.
356    ///
357    /// The trust-scoped eligibility "only a self-attested completion policy may
358    /// be confirmed by a public caller; every other policy requires trusted
359    /// in-process host authority" is owned by the canonical
360    /// `WorkGraphLifecycleMachine`, not the public-confirm surface. The shell
361    /// performs only a pure typed extraction of the machine-owned
362    /// `completion_policy` into the DSL observation, drives the machine's
363    /// `ClassifyPublicConfirmationAdmission` input, and mirrors the emitted
364    /// `PublicConfirmationAdmissionClassified` verdict. The shell decides
365    /// nothing and fails closed if the machine refuses or emits no verdict.
366    pub fn classify_public_confirmation_admission(
367        completion_policy: &crate::types::WorkCompletionPolicy,
368    ) -> Result<wg_dsl::WorkPublicConfirmationAdmissionKind, WorkGraphError> {
369        let policy = completion_policy.to_machine();
370        let mut dsl_auth = wg_dsl::WorkGraphLifecycleMachineAuthority::new();
371        let transition = wg_dsl::WorkGraphLifecycleMachineMutator::apply(
372            &mut dsl_auth,
373            wg_dsl::WorkGraphLifecycleInput::ClassifyPublicConfirmationAdmission {
374                completion_policy: policy,
375            },
376        )
377        .map_err(|error| {
378            WorkGraphError::InvalidInput(format!(
379                "WorkGraphLifecycle refused public-confirmation admission for {policy:?}: {error:?}"
380            ))
381        })?;
382
383        let mut admission = None;
384        for effect in transition.effects() {
385            if let wg_dsl::WorkGraphLifecycleEffect::PublicConfirmationAdmissionClassified {
386                admission: emitted,
387            } = effect
388                && admission.replace(*emitted).is_some()
389            {
390                return Err(WorkGraphError::Store(format!(
391                    "WorkGraphLifecycle public-confirmation admission emitted multiple verdicts for {policy:?}"
392                )));
393            }
394        }
395
396        admission.ok_or_else(|| {
397            WorkGraphError::Store(format!(
398                "WorkGraphLifecycle public-confirmation admission emitted no verdict for {policy:?}"
399            ))
400        })
401    }
402
403    /// Resolve whether a requested completion policy is admissible at CREATE for
404    /// a non-goal work item.
405    ///
406    /// The creation policy "non-goal work items must use the self-attest
407    /// completion policy" is owned by the canonical `WorkGraphLifecycleMachine`,
408    /// not the create shell. The shell performs only a pure typed extraction of
409    /// the requested completion policy into the DSL observation, drives the
410    /// machine's `ClassifyCreateCompletionPolicyAdmission` input over a fresh
411    /// authority, and mirrors the emitted
412    /// `CreateCompletionPolicyAdmissionClassified` verdict. The shell decides
413    /// nothing and fails closed if the machine refuses or emits no verdict.
414    pub fn classify_create_completion_policy_admission(
415        completion_policy: &crate::types::WorkCompletionPolicy,
416    ) -> Result<wg_dsl::WorkCreateCompletionPolicyAdmissionKind, WorkGraphError> {
417        let policy = completion_policy.to_machine();
418        let mut dsl_auth = wg_dsl::WorkGraphLifecycleMachineAuthority::new();
419        let transition = wg_dsl::WorkGraphLifecycleMachineMutator::apply(
420            &mut dsl_auth,
421            wg_dsl::WorkGraphLifecycleInput::ClassifyCreateCompletionPolicyAdmission {
422                completion_policy: policy,
423            },
424        )
425        .map_err(|error| {
426            WorkGraphError::InvalidInput(format!(
427                "WorkGraphLifecycle refused create completion-policy admission for {policy:?}: {error:?}"
428            ))
429        })?;
430
431        let mut admission = None;
432        for effect in transition.effects() {
433            if let wg_dsl::WorkGraphLifecycleEffect::CreateCompletionPolicyAdmissionClassified {
434                admission: emitted,
435            } = effect
436                && admission.replace(*emitted).is_some()
437            {
438                return Err(WorkGraphError::Store(format!(
439                    "WorkGraphLifecycle create completion-policy admission emitted multiple verdicts for {policy:?}"
440                )));
441            }
442        }
443
444        admission.ok_or_else(|| {
445            WorkGraphError::Store(format!(
446                "WorkGraphLifecycle create completion-policy admission emitted no verdict for {policy:?}"
447            ))
448        })
449    }
450
451    /// Resolve whether a requested completion-policy mutation is admissible.
452    ///
453    /// The invariant "a work item's completion policy is fixed at creation,
454    /// monotonically tightenable, and never update-writable" is owned by the
455    /// canonical `WorkGraphLifecycleMachine`, not the shell. The shell performs only a pure
456    /// typed extraction of the requested completion policy into the DSL
457    /// observation (variant plus supervisor owner key plus reviewer quorum
458    /// threshold), drives the machine's `ClassifyCompletionPolicyMutationAdmission`
459    /// input over the item's recovered machine state, and mirrors the emitted
460    /// `CompletionPolicyMutationAdmissionClassified` verdict. The machine compares
461    /// the requested policy — in full — against its own machine-owned completion
462    /// policy; this function decides nothing and fails closed if the machine
463    /// refuses or emits no verdict.
464    pub fn classify_completion_policy_mutation_admission(
465        item: &WorkItem,
466        requested: &crate::types::WorkCompletionPolicy,
467    ) -> Result<wg_dsl::WorkCompletionPolicyMutationAdmissionKind, WorkGraphError> {
468        validate_item_machine_projection(item)?;
469        let mut dsl_auth = wg_dsl::WorkGraphLifecycleMachineAuthority::recover_from_state(
470            item.machine_state.clone(),
471        )
472        .map_err(|error| WorkGraphError::InvalidTransition(format!("{error:?}")))?;
473        let transition = wg_dsl::WorkGraphLifecycleMachineMutator::apply(
474            &mut dsl_auth,
475            wg_dsl::WorkGraphLifecycleInput::ClassifyCompletionPolicyMutationAdmission {
476                requested_completion_policy: requested.to_machine(),
477                requested_completion_supervisor_owner_key: requested.supervisor_owner_key(),
478                requested_completion_reviewer_quorum_threshold: requested
479                    .reviewer_quorum_threshold(),
480            },
481        )
482        .map_err(|error| {
483            WorkGraphError::InvalidTransition(format!(
484                "work item {} refused completion-policy mutation classification: {error:?}",
485                item.id
486            ))
487        })?;
488
489        let mut admission = None;
490        for effect in transition.effects() {
491            if let wg_dsl::WorkGraphLifecycleEffect::CompletionPolicyMutationAdmissionClassified {
492                admission: emitted,
493            } = effect
494                && admission.replace(*emitted).is_some()
495            {
496                return Err(WorkGraphError::Store(format!(
497                    "work item {} emitted multiple completion-policy mutation verdicts",
498                    item.id
499                )));
500            }
501        }
502
503        admission.ok_or_else(|| {
504            WorkGraphError::Store(format!(
505                "work item {} emitted no completion-policy mutation verdict",
506                item.id
507            ))
508        })
509    }
510
511    /// Resolve whether a trusted-path goal confirmation is admissible for a work
512    /// item's machine-owned completion policy.
513    ///
514    /// The eligibility "is this confirming principal + supplied evidence kind
515    /// admissible for this completion policy" is owned by the canonical
516    /// `WorkGraphLifecycleMachine`, not the goal-confirm shell. The shell
517    /// performs only pure typed extraction of the observations (the machine-owned
518    /// completion policy + its supervisor owner key, the requested confirming
519    /// principal owner key + kind, and the typed evidence-kind observation
520    /// projected from the evidence's typed confirmation classification), drives
521    /// the machine's
522    /// `ClassifyConfirmationAdmission` input, and mirrors the emitted
523    /// `ConfirmationAdmissionClassified` verdict. This function decides nothing
524    /// and fails closed if the machine refuses or emits no verdict.
525    pub fn classify_confirmation_admission(
526        completion_policy: &crate::types::WorkCompletionPolicy,
527        requested_principal: Option<&crate::types::WorkOwnerKey>,
528        supplied_evidence_kind: wg_dsl::WorkConfirmationEvidenceObservation,
529    ) -> Result<wg_dsl::WorkConfirmationAdmissionKind, WorkGraphError> {
530        let policy = completion_policy.to_machine();
531        let requested_principal_owner_key =
532            requested_principal.map(crate::types::work_owner_key_to_machine);
533        let requested_principal_kind = requested_principal
534            .map(|principal| crate::types::work_owner_kind_to_machine(principal.kind));
535        let mut dsl_auth = wg_dsl::WorkGraphLifecycleMachineAuthority::new();
536        let transition = wg_dsl::WorkGraphLifecycleMachineMutator::apply(
537            &mut dsl_auth,
538            wg_dsl::WorkGraphLifecycleInput::ClassifyConfirmationAdmission {
539                completion_policy: policy,
540                completion_supervisor_owner_key: completion_policy.supervisor_owner_key(),
541                requested_principal_owner_key,
542                requested_principal_kind,
543                supplied_evidence_kind,
544            },
545        )
546        .map_err(|error| {
547            WorkGraphError::Store(format!(
548                "WorkGraphLifecycle refused confirmation admission for {policy:?}: {error:?}"
549            ))
550        })?;
551
552        let mut admission = None;
553        for effect in transition.effects() {
554            if let wg_dsl::WorkGraphLifecycleEffect::ConfirmationAdmissionClassified {
555                admission: emitted,
556            } = effect
557                && admission.replace(*emitted).is_some()
558            {
559                return Err(WorkGraphError::Store(format!(
560                    "WorkGraphLifecycle confirmation admission emitted multiple verdicts for {policy:?}"
561                )));
562            }
563        }
564
565        admission.ok_or_else(|| {
566            WorkGraphError::Store(format!(
567                "WorkGraphLifecycle confirmation admission emitted no verdict for {policy:?}"
568            ))
569        })
570    }
571
572    /// Resolve whether a work item is terminal.
573    ///
574    /// The shell extracts no fact: it drives the canonical
575    /// `WorkGraphLifecycleMachine`'s `ClassifyTerminality` input over the item's
576    /// recovered machine state. The machine owns the lifecycle_phase and the
577    /// terminality verdict (which phases are terminal); this function mirrors the
578    /// emitted `WorkItemTerminalityClassified.terminal`, failing closed if the
579    /// machine refuses or emits no verdict. It decides nothing.
580    pub fn classify_terminality(item: &WorkItem) -> Result<bool, WorkGraphError> {
581        validate_item_machine_projection(item)?;
582        let mut dsl_auth = wg_dsl::WorkGraphLifecycleMachineAuthority::recover_from_state(
583            item.machine_state.clone(),
584        )
585        .map_err(|error| WorkGraphError::InvalidTransition(format!("{error:?}")))?;
586        let transition = wg_dsl::WorkGraphLifecycleMachineMutator::apply(
587            &mut dsl_auth,
588            wg_dsl::WorkGraphLifecycleInput::ClassifyTerminality {},
589        )
590        .map_err(|error| {
591            WorkGraphError::InvalidTransition(format!(
592                "work item {} refused terminality classification: {error:?}",
593                item.id
594            ))
595        })?;
596
597        let mut classified = None;
598        for effect in transition.effects() {
599            if let wg_dsl::WorkGraphLifecycleEffect::WorkItemTerminalityClassified { terminal } =
600                effect
601                && classified.replace(*terminal).is_some()
602            {
603                return Err(WorkGraphError::Store(format!(
604                    "work item {} emitted multiple terminality verdicts",
605                    item.id
606                )));
607            }
608        }
609
610        classified.ok_or_else(|| {
611            WorkGraphError::Store(format!(
612                "work item {} emitted no terminality verdict",
613                item.id
614            ))
615        })
616    }
617
618    /// Resolve whether a single blocking edge is satisfied.
619    ///
620    /// The shell extracts only the raw blocker lifecycle phase (a pure
621    /// observation projected from the blocker's own machine state) and whether
622    /// the blocker was resolvable at all, then drives the canonical
623    /// `WorkGraphLifecycleMachine`'s `ClassifyBlockerSatisfied` input over the
624    /// gated item's recovered state. The machine owns the satisfaction POLICY (a
625    /// blocking edge is satisfied iff its blocker reached terminal SUCCESS,
626    /// `Completed`) and emits the verdict; this function mirrors it. The caller
627    /// mechanically fans-in (counts) the unsatisfied edges. Fails closed.
628    pub fn classify_blocker_satisfied(
629        gated_item: &WorkItem,
630        blocker: Option<&WorkItem>,
631    ) -> Result<bool, WorkGraphError> {
632        validate_item_machine_projection(gated_item)?;
633        let blocker_lifecycle_phase = match blocker {
634            Some(blocker) => blocker.machine_state.lifecycle_phase,
635            None => wg_dsl::WorkLifecycleState::Absent,
636        };
637        let mut dsl_auth = wg_dsl::WorkGraphLifecycleMachineAuthority::recover_from_state(
638            gated_item.machine_state.clone(),
639        )
640        .map_err(|error| WorkGraphError::InvalidTransition(format!("{error:?}")))?;
641        let transition = wg_dsl::WorkGraphLifecycleMachineMutator::apply(
642            &mut dsl_auth,
643            wg_dsl::WorkGraphLifecycleInput::ClassifyBlockerSatisfied {
644                blocker_present: blocker.is_some(),
645                blocker_lifecycle_phase,
646            },
647        )
648        .map_err(|error| {
649            WorkGraphError::InvalidTransition(format!(
650                "work item {} refused blocker satisfaction classification: {error:?}",
651                gated_item.id
652            ))
653        })?;
654
655        let mut classified = None;
656        for effect in transition.effects() {
657            if let wg_dsl::WorkGraphLifecycleEffect::BlockerSatisfactionClassified { satisfied } =
658                effect
659                && classified.replace(*satisfied).is_some()
660            {
661                return Err(WorkGraphError::Store(format!(
662                    "work item {} emitted multiple blocker satisfaction verdicts",
663                    gated_item.id
664                )));
665            }
666        }
667
668        classified.ok_or_else(|| {
669            WorkGraphError::Store(format!(
670                "work item {} emitted no blocker satisfaction verdict",
671                gated_item.id
672            ))
673        })
674    }
675
676    pub fn create_item(
677        request: CreateWorkItemRequest,
678        realm_id: String,
679        namespace: WorkNamespace,
680        now: DateTime<Utc>,
681    ) -> Result<(WorkItem, WorkGraphEvent), WorkGraphError> {
682        let title = validate_title(request.title)?;
683        let status = request.status.unwrap_or_default();
684        // The creation policy "a new work item may only start open or blocked"
685        // is owned by WorkGraphLifecycleMachine, not this shell. We extract the
686        // requested status as a pure typed observation, drive the machine's
687        // admission classifier, and mirror the verdict: AdmittedOpen ->
688        // CreateOpen, AdmittedBlocked -> CreateBlocked, Denied -> the same
689        // InvalidTransition rejection. Fails closed.
690        let input = match classify_create_status_admission(status)? {
691            wg_dsl::WorkCreateStatusAdmissionKind::AdmittedOpen => {
692                wg_dsl::WorkGraphLifecycleInput::CreateOpen {
693                    due_at_utc_ms: request.due_at.map(datetime_to_millis),
694                    not_before_utc_ms: request.not_before.map(datetime_to_millis),
695                    snoozed_until_utc_ms: request.snoozed_until.map(datetime_to_millis),
696                    completion_policy: request.completion_policy.clone().to_machine(),
697                    completion_supervisor_owner_key: request
698                        .completion_policy
699                        .supervisor_owner_key(),
700                    completion_reviewer_quorum_threshold: request
701                        .completion_policy
702                        .reviewer_quorum_threshold(),
703                    unresolved_blocker_count: 0,
704                }
705            }
706            wg_dsl::WorkCreateStatusAdmissionKind::AdmittedBlocked => {
707                wg_dsl::WorkGraphLifecycleInput::CreateBlocked {
708                    due_at_utc_ms: request.due_at.map(datetime_to_millis),
709                    not_before_utc_ms: request.not_before.map(datetime_to_millis),
710                    snoozed_until_utc_ms: request.snoozed_until.map(datetime_to_millis),
711                    completion_policy: request.completion_policy.clone().to_machine(),
712                    completion_supervisor_owner_key: request
713                        .completion_policy
714                        .supervisor_owner_key(),
715                    completion_reviewer_quorum_threshold: request
716                        .completion_policy
717                        .reviewer_quorum_threshold(),
718                    unresolved_blocker_count: 0,
719                }
720            }
721            wg_dsl::WorkCreateStatusAdmissionKind::Denied => {
722                return Err(WorkGraphError::InvalidTransition(
723                    "new work items may only start open or blocked".to_string(),
724                ));
725            }
726        };
727        let dsl_state = apply_new_item_dsl(input)?;
728        let mut item = WorkItem {
729            id: WorkItemId::generated(),
730            realm_id,
731            namespace,
732            title,
733            description: request.description,
734            status: work_status_from_dsl(dsl_state.lifecycle_phase)?,
735            completion_policy: request.completion_policy,
736            priority: request.priority,
737            labels: normalize_labels(request.labels)?,
738            owner: None,
739            claim: None,
740            machine_state: dsl_state.clone(),
741            revision: dsl_state.revision,
742            due_at: dsl_state.due_at_utc_ms.and_then(millis_to_datetime),
743            not_before: dsl_state.not_before_utc_ms.and_then(millis_to_datetime),
744            snoozed_until: dsl_state.snoozed_until_utc_ms.and_then(millis_to_datetime),
745            created_at: now,
746            updated_at: now,
747            terminal_at: dsl_state.terminal_at_utc_ms.and_then(millis_to_datetime),
748            external_refs: request.external_refs,
749            evidence_refs: request.evidence_refs,
750        };
751        sync_item_from_machine_state(&mut item)?;
752        let event = item_event(&item, WorkGraphEventKind::Created, now)?;
753        Ok((item, event))
754    }
755
756    pub fn update_item(
757        mut item: WorkItem,
758        request: UpdateWorkItemRequest,
759        now: DateTime<Utc>,
760    ) -> Result<(WorkItem, WorkGraphEvent), WorkGraphError> {
761        let due_at = request.due_at.or(item.due_at);
762        let not_before = request.not_before.or(item.not_before);
763        let snoozed_until = request.snoozed_until.or(item.snoozed_until);
764        let completion_policy = request
765            .completion_policy
766            .unwrap_or_else(|| item.completion_policy.clone());
767        let dsl_state = apply_item_dsl(
768            &item,
769            item.machine_state.unresolved_blocker_count,
770            wg_dsl::WorkGraphLifecycleInput::Update {
771                expected_revision: request.expected_revision,
772                due_at_utc_ms: due_at.map(datetime_to_millis),
773                not_before_utc_ms: not_before.map(datetime_to_millis),
774                snoozed_until_utc_ms: snoozed_until.map(datetime_to_millis),
775                completion_policy: completion_policy.clone().to_machine(),
776                completion_supervisor_owner_key: completion_policy.supervisor_owner_key(),
777                completion_reviewer_quorum_threshold: completion_policy.reviewer_quorum_threshold(),
778                unresolved_blocker_count: item.machine_state.unresolved_blocker_count,
779            },
780            Some(request.expected_revision),
781        )?;
782
783        if let Some(title) = request.title {
784            item.title = validate_title(title)?;
785        }
786        if let Some(description) = request.description {
787            item.description = Some(description);
788        }
789        if let Some(priority) = request.priority {
790            item.priority = priority;
791        }
792        if let Some(labels) = request.labels {
793            item.labels = normalize_labels(labels)?;
794        }
795        item.machine_state = dsl_state;
796        sync_item_from_machine_state(&mut item)?;
797        if !request.external_refs.is_empty() {
798            item.external_refs = request.external_refs;
799        }
800        item.updated_at = now;
801        let event = item_event(&item, WorkGraphEventKind::Updated, now)?;
802        Ok((item, event))
803    }
804
805    pub fn escalate_policy(
806        mut item: WorkItem,
807        request: PolicyEscalateRequest,
808        now: DateTime<Utc>,
809    ) -> Result<(WorkItem, WorkGraphEvent), WorkGraphError> {
810        validate_item_machine_projection(&item)?;
811        let mut state = item.machine_state.clone();
812        state.unresolved_blocker_count = item.machine_state.unresolved_blocker_count;
813        let mut dsl_auth = wg_dsl::WorkGraphLifecycleMachineAuthority::recover_from_state(state)
814            .map_err(|error| WorkGraphError::InvalidTransition(format!("{error:?}")))?;
815        let transition = wg_dsl::WorkGraphLifecycleMachineMutator::apply(
816            &mut dsl_auth,
817            wg_dsl::WorkGraphLifecycleInput::PolicyEscalate {
818                expected_revision: request.expected_revision,
819                requested_completion_policy: request.completion_policy.to_machine(),
820                requested_completion_supervisor_owner_key: request
821                    .completion_policy
822                    .supervisor_owner_key(),
823                requested_completion_reviewer_quorum_threshold: request
824                    .completion_policy
825                    .reviewer_quorum_threshold(),
826            },
827        )
828        .map_err(|error| {
829            if item.revision != request.expected_revision {
830                return WorkGraphError::StaleRevision {
831                    id: item.id.clone(),
832                    expected: request.expected_revision,
833                    actual: item.revision,
834                };
835            }
836            WorkGraphError::InvalidTransition(format!("{error:?}"))
837        })?;
838
839        let mut admission = None;
840        for effect in transition.effects() {
841            if let wg_dsl::WorkGraphLifecycleEffect::PolicyEscalationAdmissionClassified {
842                admission: emitted,
843            } = effect
844                && admission.replace(*emitted).is_some()
845            {
846                return Err(WorkGraphError::Store(format!(
847                    "work item {} emitted multiple policy escalation verdicts",
848                    item.id
849                )));
850            }
851        }
852        match admission.ok_or_else(|| {
853            WorkGraphError::Store(format!(
854                "work item {} emitted no policy escalation verdict",
855                item.id
856            ))
857        })? {
858            WorkPolicyEscalationAdmissionKind::Admitted => {}
859            WorkPolicyEscalationAdmissionKind::Denied => {
860                return Err(WorkGraphError::InvalidInput(format!(
861                    "completion policy for work item {} can only be monotonically tightened",
862                    item.id
863                )));
864            }
865        }
866
867        item.machine_state = dsl_auth.state().clone();
868        sync_item_from_machine_state(&mut item)?;
869        item.updated_at = now;
870        let event = item_event(&item, WorkGraphEventKind::Updated, now)?;
871        Ok((item, event))
872    }
873
874    pub fn claim_item(
875        item: WorkItem,
876        request: ClaimWorkItemRequest,
877        now: DateTime<Utc>,
878    ) -> Result<(WorkItem, WorkGraphEvent), WorkGraphError> {
879        Self::claim_ready_item(item, request, now)
880    }
881
882    pub fn claim_ready_item(
883        item: WorkItem,
884        request: ClaimWorkItemRequest,
885        now: DateTime<Utc>,
886    ) -> Result<(WorkItem, WorkGraphEvent), WorkGraphError> {
887        Self::claim_item_with_unresolved_blockers(
888            item.clone(),
889            item.machine_state.unresolved_blocker_count,
890            request,
891            now,
892        )
893    }
894
895    pub fn refresh_eligibility(
896        mut item: WorkItem,
897        unresolved_blocker_count: u64,
898        now: DateTime<Utc>,
899    ) -> Result<Option<(WorkItem, WorkGraphEvent)>, WorkGraphError> {
900        if item.machine_state.unresolved_blocker_count == unresolved_blocker_count {
901            return Ok(None);
902        }
903        let dsl_state = apply_item_dsl(
904            &item,
905            unresolved_blocker_count,
906            wg_dsl::WorkGraphLifecycleInput::RefreshEligibility {
907                unresolved_blocker_count,
908            },
909            None,
910        )?;
911        item.machine_state = dsl_state;
912        sync_item_from_machine_state(&mut item)?;
913        item.updated_at = now;
914        let event = item_event(&item, WorkGraphEventKind::Updated, now)?;
915        Ok(Some((item, event)))
916    }
917
918    pub(crate) fn claim_item_with_unresolved_blockers(
919        mut item: WorkItem,
920        unresolved_blocker_count: u64,
921        request: ClaimWorkItemRequest,
922        now: DateTime<Utc>,
923    ) -> Result<(WorkItem, WorkGraphEvent), WorkGraphError> {
924        let lease_expires_at = request.lease_expires_at.or_else(|| {
925            request
926                .lease_seconds
927                .map(|seconds| now + seconds_to_duration(seconds))
928        });
929        let owner_key = work_owner_key(&request.owner)?;
930        let dsl_state = apply_item_dsl(
931            &item,
932            unresolved_blocker_count,
933            wg_dsl::WorkGraphLifecycleInput::Claim {
934                expected_revision: request.expected_revision,
935                owner_key,
936                now_utc_ms: datetime_to_millis(now),
937                lease_expires_at_utc_ms: lease_expires_at.map(datetime_to_millis),
938            },
939            Some(request.expected_revision),
940        )?;
941        item.owner = Some(request.owner.clone());
942        item.claim = Some(WorkClaim {
943            owner: request.owner,
944            claimed_at: now,
945            lease_expires_at,
946        });
947        item.machine_state = dsl_state;
948        sync_item_from_machine_state(&mut item)?;
949        item.updated_at = now;
950        let event = item_event(&item, WorkGraphEventKind::Claimed, now)?;
951        Ok((item, event))
952    }
953
954    pub fn release_item(
955        mut item: WorkItem,
956        request: ReleaseWorkItemRequest,
957        now: DateTime<Utc>,
958    ) -> Result<(WorkItem, WorkGraphEvent), WorkGraphError> {
959        let dsl_state = apply_item_dsl(
960            &item,
961            item.machine_state.unresolved_blocker_count,
962            wg_dsl::WorkGraphLifecycleInput::Release {
963                expected_revision: request.expected_revision,
964            },
965            Some(request.expected_revision),
966        )?;
967        item.claim = None;
968        item.owner = None;
969        item.machine_state = dsl_state;
970        sync_item_from_machine_state(&mut item)?;
971        item.updated_at = now;
972        let event = item_event(&item, WorkGraphEventKind::Released, now)?;
973        Ok((item, event))
974    }
975
976    pub fn block_item(
977        mut item: WorkItem,
978        expected_revision: u64,
979        now: DateTime<Utc>,
980    ) -> Result<(WorkItem, WorkGraphEvent), WorkGraphError> {
981        let dsl_state = apply_item_dsl(
982            &item,
983            item.machine_state.unresolved_blocker_count,
984            wg_dsl::WorkGraphLifecycleInput::Block { expected_revision },
985            Some(expected_revision),
986        )?;
987        item.claim = None;
988        item.owner = None;
989        item.machine_state = dsl_state;
990        sync_item_from_machine_state(&mut item)?;
991        item.updated_at = now;
992        let event = item_event(&item, WorkGraphEventKind::Blocked, now)?;
993        Ok((item, event))
994    }
995
996    pub fn close_item(
997        mut item: WorkItem,
998        request: CloseWorkItemRequest,
999        now: DateTime<Utc>,
1000    ) -> Result<(WorkItem, WorkGraphEvent), WorkGraphError> {
1001        // The lifecycle-class fact "close requires a terminal status" is owned
1002        // by WorkGraphLifecycleMachine, not this shell. We extract the requested
1003        // target status as a pure typed observation, drive the machine's
1004        // admission classifier, and mirror the verdict: AdmittedCompleted ->
1005        // CloseCompleted, AdmittedCancelled -> CloseCancelled, AdmittedFailed ->
1006        // CloseFailed, DeniedNonTerminal -> the exact same InvalidTransition
1007        // rejection. Fails closed.
1008        let dsl_input = match classify_close_status_admission(request.status)? {
1009            wg_dsl::WorkCloseStatusAdmissionKind::AdmittedCompleted => {
1010                wg_dsl::WorkGraphLifecycleInput::CloseCompleted {
1011                    expected_revision: request.expected_revision,
1012                    at_utc_ms: datetime_to_millis(now),
1013                }
1014            }
1015            wg_dsl::WorkCloseStatusAdmissionKind::AdmittedCancelled => {
1016                wg_dsl::WorkGraphLifecycleInput::CloseCancelled {
1017                    expected_revision: request.expected_revision,
1018                    at_utc_ms: datetime_to_millis(now),
1019                }
1020            }
1021            wg_dsl::WorkCloseStatusAdmissionKind::AdmittedFailed => {
1022                wg_dsl::WorkGraphLifecycleInput::CloseFailed {
1023                    expected_revision: request.expected_revision,
1024                    at_utc_ms: datetime_to_millis(now),
1025                }
1026            }
1027            wg_dsl::WorkCloseStatusAdmissionKind::DeniedNonTerminal => {
1028                return Err(WorkGraphError::InvalidTransition(
1029                    "close requires a terminal status".to_string(),
1030                ));
1031            }
1032        };
1033        let dsl_state = apply_item_dsl(
1034            &item,
1035            item.machine_state.unresolved_blocker_count,
1036            dsl_input,
1037            Some(request.expected_revision),
1038        )?;
1039        item.claim = None;
1040        item.owner = None;
1041        item.machine_state = dsl_state;
1042        sync_item_from_machine_state(&mut item)?;
1043        item.updated_at = now;
1044        let event = item_event(&item, WorkGraphEventKind::Closed, now)?;
1045        Ok((item, event))
1046    }
1047
1048    pub fn add_evidence(
1049        mut item: WorkItem,
1050        request: AddEvidenceRequest,
1051        now: DateTime<Utc>,
1052    ) -> Result<(WorkItem, WorkGraphEvent), WorkGraphError> {
1053        let evidence_kind = request
1054            .evidence
1055            .confirmation_kind
1056            .unwrap_or(crate::types::WorkEvidenceKind::SelfAttest)
1057            .to_machine();
1058        let confirming_owner_key = request
1059            .evidence
1060            .confirming_owner_key
1061            .as_ref()
1062            .map(crate::types::work_owner_key_to_machine);
1063        let dsl_state = apply_item_dsl(
1064            &item,
1065            item.machine_state.unresolved_blocker_count,
1066            wg_dsl::WorkGraphLifecycleInput::AddEvidence {
1067                expected_revision: request.expected_revision,
1068                evidence_kind,
1069                confirming_owner_key,
1070            },
1071            Some(request.expected_revision),
1072        )?;
1073        item.evidence_refs.push(request.evidence);
1074        item.machine_state = dsl_state;
1075        sync_item_from_machine_state(&mut item)?;
1076        item.updated_at = now;
1077        let event = item_event(&item, WorkGraphEventKind::EvidenceAdded, now)?;
1078        Ok((item, event))
1079    }
1080
1081    /// Classify whether a work item is ready to claim at `now`.
1082    ///
1083    /// The shell extracts only the raw wall-clock `now` (a pure observation) and
1084    /// drives the canonical `WorkGraphLifecycleMachine`'s `ClassifyReadiness`
1085    /// input over the item's recovered machine state. The machine owns the
1086    /// readiness POLICY — reproducing exactly the `Claim` transition guards
1087    /// (`ClaimOpen` / `ClaimExpiredInProgress`) — and emits the verdict; this
1088    /// function mirrors the emitted `WorkItemReadinessClassified.ready`, failing
1089    /// closed if the machine refuses or emits no verdict. It decides nothing.
1090    pub fn classify_readiness(item: &WorkItem, now: DateTime<Utc>) -> Result<bool, WorkGraphError> {
1091        validate_item_machine_projection(item)?;
1092        let mut dsl_auth = wg_dsl::WorkGraphLifecycleMachineAuthority::recover_from_state(
1093            item.machine_state.clone(),
1094        )
1095        .map_err(|error| WorkGraphError::InvalidTransition(format!("{error:?}")))?;
1096        let transition = wg_dsl::WorkGraphLifecycleMachineMutator::apply(
1097            &mut dsl_auth,
1098            wg_dsl::WorkGraphLifecycleInput::ClassifyReadiness {
1099                now_utc_ms: datetime_to_millis(now),
1100            },
1101        )
1102        .map_err(|error| {
1103            WorkGraphError::InvalidTransition(format!(
1104                "work item {} refused readiness classification: {error:?}",
1105                item.id
1106            ))
1107        })?;
1108
1109        let mut classified = None;
1110        for effect in transition.effects() {
1111            if let wg_dsl::WorkGraphLifecycleEffect::WorkItemReadinessClassified { ready } = effect
1112                && classified.replace(*ready).is_some()
1113            {
1114                return Err(WorkGraphError::Store(format!(
1115                    "work item {} emitted multiple readiness verdicts",
1116                    item.id
1117                )));
1118            }
1119        }
1120
1121        classified.ok_or_else(|| {
1122            WorkGraphError::Store(format!(
1123                "work item {} emitted no readiness verdict",
1124                item.id
1125            ))
1126        })
1127    }
1128
1129    /// Mirror the machine-owned readiness verdict as a `bool`, failing closed.
1130    ///
1131    /// The readiness verdict belongs to the canonical
1132    /// `WorkGraphLifecycleMachine` (see
1133    /// [`WorkGraphMachine::classify_readiness`]). This filter-facing projection
1134    /// mirrors the machine bool; if the machine refuses to classify, it fails
1135    /// closed by treating the item as NOT ready so an unclassifiable item is
1136    /// never offered as claimable work.
1137    pub fn is_ready(item: &WorkItem, now: DateTime<Utc>) -> bool {
1138        Self::classify_readiness(item, now).unwrap_or(false)
1139    }
1140
1141    pub fn ready_items(items: Vec<WorkItem>, now: DateTime<Utc>) -> Vec<WorkItem> {
1142        items
1143            .into_iter()
1144            .filter(|item| Self::is_ready(item, now))
1145            .collect()
1146    }
1147
1148    pub fn validate_link(
1149        edge: &WorkEdge,
1150        existing_items: &[WorkItem],
1151        existing_edges: &[WorkEdge],
1152    ) -> Result<(), WorkGraphError> {
1153        let topology_state = topology_state(existing_items, existing_edges);
1154        apply_link_validation_dsl(
1155            topology_state,
1156            wg_dsl::WorkGraphLifecycleInput::ValidateLink {
1157                kind: dsl_edge_kind(edge.kind),
1158                from_item_key: work_item_key(&edge.from_id),
1159                to_item_key: work_item_key(&edge.to_id),
1160                edge_key: work_edge_key(edge.kind, &edge.from_id, &edge.to_id),
1161                reverse_path_key: dependency_path_key(edge.kind, &edge.to_id, &edge.from_id),
1162            },
1163        )?;
1164        Ok(())
1165    }
1166}
1167
1168/// Pure typed extraction of a `WorkGraphError` into the machine's typed
1169/// error-kind discriminant. This is a 1:1 variant->kind map with NO grouping;
1170/// the many-to-one variant->public-class POLICY lives in the canonical
1171/// `WorkGraphLifecycleMachine`, not here.
1172fn work_graph_error_kind(error: &WorkGraphError) -> wg_dsl::WorkGraphErrorKind {
1173    match error {
1174        WorkGraphError::NotFound { .. } => wg_dsl::WorkGraphErrorKind::NotFound,
1175        WorkGraphError::AttentionNotFound { .. } => wg_dsl::WorkGraphErrorKind::AttentionNotFound,
1176        WorkGraphError::StaleRevision { .. } => wg_dsl::WorkGraphErrorKind::StaleRevision,
1177        WorkGraphError::Conflict(_) => wg_dsl::WorkGraphErrorKind::Conflict,
1178        WorkGraphError::InvalidTransition(_) => wg_dsl::WorkGraphErrorKind::InvalidTransition,
1179        WorkGraphError::InvalidInput(_) => wg_dsl::WorkGraphErrorKind::InvalidInput,
1180        WorkGraphError::InvalidTimestampMillis { .. } => {
1181            wg_dsl::WorkGraphErrorKind::InvalidTimestampMillis
1182        }
1183        WorkGraphError::Store(_) => wg_dsl::WorkGraphErrorKind::Store,
1184        WorkGraphError::UnsupportedBackend(_) => wg_dsl::WorkGraphErrorKind::UnsupportedBackend,
1185    }
1186}
1187
1188pub(crate) fn completion_policy_name(policy: &WorkCompletionPolicy) -> &'static str {
1189    match policy {
1190        WorkCompletionPolicy::SelfAttest => "self_attest",
1191        WorkCompletionPolicy::HostConfirmed => "host_confirmed",
1192        WorkCompletionPolicy::PrincipalConfirmed => "principal_confirmed",
1193        WorkCompletionPolicy::Supervisor { .. } => "supervisor",
1194        WorkCompletionPolicy::ReviewerQuorum { .. } => "reviewer_quorum",
1195    }
1196}
1197
1198fn validate_title(title: String) -> Result<String, WorkGraphError> {
1199    let title = title.trim();
1200    if title.is_empty() {
1201        return Err(WorkGraphError::InvalidInput(
1202            "work item title must not be empty".to_string(),
1203        ));
1204    }
1205    Ok(title.to_string())
1206}
1207
1208fn normalize_labels(labels: BTreeSet<String>) -> Result<BTreeSet<String>, WorkGraphError> {
1209    let mut normalized = BTreeSet::new();
1210    for label in labels {
1211        let label = label.trim();
1212        if label.is_empty() {
1213            return Err(WorkGraphError::InvalidInput(
1214                "work item labels must not be empty".to_string(),
1215            ));
1216        }
1217        normalized.insert(label.to_string());
1218    }
1219    Ok(normalized)
1220}
1221
1222fn apply_new_item_dsl(
1223    input: wg_dsl::WorkGraphLifecycleInput,
1224) -> Result<wg_dsl::WorkGraphLifecycleMachineState, WorkGraphError> {
1225    let mut dsl_auth = wg_dsl::WorkGraphLifecycleMachineAuthority::new();
1226    wg_dsl::WorkGraphLifecycleMachineMutator::apply(&mut dsl_auth, input)
1227        .map_err(|error| WorkGraphError::InvalidTransition(format!("{error:?}")))?;
1228    Ok(dsl_auth.state().clone())
1229}
1230
1231/// Resolve whether a requested INITIAL work status is an admissible creation
1232/// state.
1233///
1234/// The shell performs only a pure typed extraction of the requested
1235/// `WorkStatus` into the machine's `WorkLifecycleState` observation. The
1236/// creation POLICY "a new work item may only start open or blocked" is owned by
1237/// the canonical `WorkGraphLifecycleMachine`: this drives its
1238/// `ClassifyCreateStatusAdmission` input over a fresh authority and mirrors the
1239/// emitted `CreateStatusAdmissionClassified` verdict. The shell decides nothing
1240/// and fails closed if the machine refuses or emits no verdict.
1241fn classify_create_status_admission(
1242    status: WorkStatus,
1243) -> Result<wg_dsl::WorkCreateStatusAdmissionKind, WorkGraphError> {
1244    let requested_status = crate::types::work_lifecycle_state_from_status(status);
1245    let mut dsl_auth = wg_dsl::WorkGraphLifecycleMachineAuthority::new();
1246    let transition = wg_dsl::WorkGraphLifecycleMachineMutator::apply(
1247        &mut dsl_auth,
1248        wg_dsl::WorkGraphLifecycleInput::ClassifyCreateStatusAdmission { requested_status },
1249    )
1250    .map_err(|error| {
1251        WorkGraphError::InvalidTransition(format!(
1252            "WorkGraphLifecycle refused create-status admission for {requested_status:?}: {error:?}"
1253        ))
1254    })?;
1255
1256    let mut admission = None;
1257    for effect in transition.effects() {
1258        if let wg_dsl::WorkGraphLifecycleEffect::CreateStatusAdmissionClassified {
1259            admission: emitted,
1260        } = effect
1261            && admission.replace(*emitted).is_some()
1262        {
1263            return Err(WorkGraphError::Store(format!(
1264                "WorkGraphLifecycle create-status admission emitted multiple verdicts for {requested_status:?}"
1265            )));
1266        }
1267    }
1268
1269    admission.ok_or_else(|| {
1270        WorkGraphError::Store(format!(
1271            "WorkGraphLifecycle create-status admission emitted no verdict for {requested_status:?}"
1272        ))
1273    })
1274}
1275
1276/// Resolve whether a requested target lifecycle status is an admissible CLOSE
1277/// target.
1278///
1279/// The shell performs only a pure typed extraction of the requested
1280/// `WorkStatus` into the machine's `WorkLifecycleState` observation. The
1281/// lifecycle-class fact "close requires a terminal status" is owned by the
1282/// canonical `WorkGraphLifecycleMachine`: this drives its
1283/// `ClassifyCloseStatusAdmission` input over a fresh authority and mirrors the
1284/// emitted `CloseStatusAdmissionClassified` verdict. The shell decides nothing
1285/// and fails closed if the machine refuses or emits no verdict.
1286fn classify_close_status_admission(
1287    status: WorkStatus,
1288) -> Result<wg_dsl::WorkCloseStatusAdmissionKind, WorkGraphError> {
1289    let requested_status = crate::types::work_lifecycle_state_from_status(status);
1290    let mut dsl_auth = wg_dsl::WorkGraphLifecycleMachineAuthority::new();
1291    let transition = wg_dsl::WorkGraphLifecycleMachineMutator::apply(
1292        &mut dsl_auth,
1293        wg_dsl::WorkGraphLifecycleInput::ClassifyCloseStatusAdmission { requested_status },
1294    )
1295    .map_err(|error| {
1296        WorkGraphError::InvalidTransition(format!(
1297            "WorkGraphLifecycle refused close-status admission for {requested_status:?}: {error:?}"
1298        ))
1299    })?;
1300
1301    let mut admission = None;
1302    for effect in transition.effects() {
1303        if let wg_dsl::WorkGraphLifecycleEffect::CloseStatusAdmissionClassified {
1304            admission: emitted,
1305        } = effect
1306            && admission.replace(*emitted).is_some()
1307        {
1308            return Err(WorkGraphError::Store(format!(
1309                "WorkGraphLifecycle close-status admission emitted multiple verdicts for {requested_status:?}"
1310            )));
1311        }
1312    }
1313
1314    admission.ok_or_else(|| {
1315        WorkGraphError::Store(format!(
1316            "WorkGraphLifecycle close-status admission emitted no verdict for {requested_status:?}"
1317        ))
1318    })
1319}
1320
1321fn apply_link_validation_dsl(
1322    state: WorkGraphMachineState,
1323    input: wg_dsl::WorkGraphLifecycleInput,
1324) -> Result<(), WorkGraphError> {
1325    let mut dsl_auth = wg_dsl::WorkGraphLifecycleMachineAuthority::recover_from_state(state)
1326        .map_err(|error| WorkGraphError::InvalidTransition(format!("{error:?}")))?;
1327    wg_dsl::WorkGraphLifecycleMachineMutator::apply(&mut dsl_auth, input)
1328        .map_err(|error| WorkGraphError::InvalidTransition(format!("{error:?}")))?;
1329    Ok(())
1330}
1331
1332fn apply_attention_dsl(
1333    binding: &WorkAttentionBinding,
1334    input: attention_dsl::WorkAttentionLifecycleInput,
1335    expected_revision: Option<u64>,
1336) -> Result<attention_dsl::WorkAttentionLifecycleMachineState, WorkGraphError> {
1337    let mut dsl_auth = attention_dsl::WorkAttentionLifecycleMachineAuthority::recover_from_state(
1338        binding.machine_state.clone(),
1339    )
1340    .map_err(|error| {
1341        WorkGraphError::InvalidTransition(format!(
1342            "attention binding {} refused recovery: {error:?}",
1343            binding.binding_id
1344        ))
1345    })?;
1346    attention_dsl::WorkAttentionLifecycleMachineMutator::apply(&mut dsl_auth, input).map_err(
1347        |error| {
1348            if let Some(expected) = expected_revision
1349                && binding.machine_state.revision != expected
1350            {
1351                return WorkGraphError::StaleRevision {
1352                    id: binding.work_ref.item_id.clone(),
1353                    expected,
1354                    actual: binding.machine_state.revision,
1355                };
1356            }
1357            WorkGraphError::InvalidTransition(format!(
1358                "attention binding {} refused transition: {error:?}",
1359                binding.binding_id
1360            ))
1361        },
1362    )?;
1363    Ok(dsl_auth.state().clone())
1364}
1365
1366fn apply_item_dsl(
1367    item: &WorkItem,
1368    unresolved_blocker_count: u64,
1369    input: wg_dsl::WorkGraphLifecycleInput,
1370    expected_revision: Option<u64>,
1371) -> Result<WorkGraphMachineState, WorkGraphError> {
1372    validate_item_machine_projection(item)?;
1373    let mut state = item.machine_state.clone();
1374    state.unresolved_blocker_count = unresolved_blocker_count;
1375    let mut dsl_auth = wg_dsl::WorkGraphLifecycleMachineAuthority::recover_from_state(state)
1376        .map_err(|error| WorkGraphError::InvalidTransition(format!("{error:?}")))?;
1377    wg_dsl::WorkGraphLifecycleMachineMutator::apply(&mut dsl_auth, input).map_err(|error| {
1378        if let Some(expected) = expected_revision
1379            && item.revision != expected
1380        {
1381            return WorkGraphError::StaleRevision {
1382                id: item.id.clone(),
1383                expected,
1384                actual: item.revision,
1385            };
1386        }
1387        WorkGraphError::InvalidTransition(format!("{error:?}"))
1388    })?;
1389    Ok(dsl_auth.state().clone())
1390}
1391
1392fn sync_attention_from_machine_state(binding: &mut WorkAttentionBinding) {
1393    binding.status = match binding.machine_state.lifecycle_phase {
1394        attention_dsl::WorkAttentionLifecycleState::Active => WorkAttentionStatus::Active,
1395        attention_dsl::WorkAttentionLifecycleState::Paused => WorkAttentionStatus::Paused {
1396            until: binding
1397                .machine_state
1398                .paused_until_utc_ms
1399                .and_then(millis_to_datetime),
1400        },
1401        attention_dsl::WorkAttentionLifecycleState::Superseded => WorkAttentionStatus::Superseded,
1402        attention_dsl::WorkAttentionLifecycleState::Stopped => WorkAttentionStatus::Stopped,
1403    };
1404}
1405
1406fn work_status_from_dsl(status: wg_dsl::WorkLifecycleState) -> Result<WorkStatus, WorkGraphError> {
1407    match status {
1408        wg_dsl::WorkLifecycleState::Open => Ok(WorkStatus::Open),
1409        wg_dsl::WorkLifecycleState::InProgress => Ok(WorkStatus::InProgress),
1410        wg_dsl::WorkLifecycleState::Blocked => Ok(WorkStatus::Blocked),
1411        wg_dsl::WorkLifecycleState::Completed => Ok(WorkStatus::Completed),
1412        wg_dsl::WorkLifecycleState::Cancelled => Ok(WorkStatus::Cancelled),
1413        wg_dsl::WorkLifecycleState::Failed => Ok(WorkStatus::Failed),
1414        wg_dsl::WorkLifecycleState::Absent => Err(WorkGraphError::InvalidTransition(
1415            "work item lifecycle state is absent".to_string(),
1416        )),
1417    }
1418}
1419
1420fn sync_item_from_machine_state(item: &mut WorkItem) -> Result<(), WorkGraphError> {
1421    item.status = work_status_from_dsl(item.machine_state.lifecycle_phase)?;
1422    item.revision = item.machine_state.revision;
1423    item.due_at = item
1424        .machine_state
1425        .due_at_utc_ms
1426        .and_then(millis_to_datetime);
1427    item.not_before = item
1428        .machine_state
1429        .not_before_utc_ms
1430        .and_then(millis_to_datetime);
1431    item.snoozed_until = item
1432        .machine_state
1433        .snoozed_until_utc_ms
1434        .and_then(millis_to_datetime);
1435    item.completion_policy = crate::types::WorkCompletionPolicy::from_machine(
1436        item.machine_state.completion_policy,
1437        item.machine_state.completion_supervisor_owner_key.clone(),
1438        item.machine_state.completion_reviewer_quorum_threshold,
1439    );
1440    item.terminal_at = item
1441        .machine_state
1442        .terminal_at_utc_ms
1443        .and_then(millis_to_datetime);
1444    Ok(())
1445}
1446
1447fn validate_item_machine_projection(item: &WorkItem) -> Result<(), WorkGraphError> {
1448    let status = work_status_from_dsl(item.machine_state.lifecycle_phase)?;
1449    if item.status != status {
1450        return Err(WorkGraphError::Store(format!(
1451            "work item {} status projection {:?} does not match machine state {:?}",
1452            item.id, item.status, status
1453        )));
1454    }
1455    if item.revision != item.machine_state.revision {
1456        return Err(WorkGraphError::Store(format!(
1457            "work item {} revision projection {} does not match machine state {}",
1458            item.id, item.revision, item.machine_state.revision
1459        )));
1460    }
1461    if item.due_at.map(datetime_to_millis) != item.machine_state.due_at_utc_ms {
1462        return Err(WorkGraphError::Store(format!(
1463            "work item {} due_at projection does not match machine state",
1464            item.id
1465        )));
1466    }
1467    if item.not_before.map(datetime_to_millis) != item.machine_state.not_before_utc_ms {
1468        return Err(WorkGraphError::Store(format!(
1469            "work item {} not_before projection does not match machine state",
1470            item.id
1471        )));
1472    }
1473    if item.snoozed_until.map(datetime_to_millis) != item.machine_state.snoozed_until_utc_ms {
1474        return Err(WorkGraphError::Store(format!(
1475            "work item {} snoozed_until projection does not match machine state",
1476            item.id
1477        )));
1478    }
1479    if item.completion_policy
1480        != crate::types::WorkCompletionPolicy::from_machine(
1481            item.machine_state.completion_policy,
1482            item.machine_state.completion_supervisor_owner_key.clone(),
1483            item.machine_state.completion_reviewer_quorum_threshold,
1484        )
1485    {
1486        return Err(WorkGraphError::Store(format!(
1487            "work item {} completion_policy projection does not match machine state",
1488            item.id
1489        )));
1490    }
1491    if item.terminal_at.map(datetime_to_millis) != item.machine_state.terminal_at_utc_ms {
1492        return Err(WorkGraphError::Store(format!(
1493            "work item {} terminal_at projection does not match machine state",
1494            item.id
1495        )));
1496    }
1497    if let Some(claim) = &item.claim {
1498        let claim_owner_key = work_owner_key(&claim.owner)?;
1499        if item.machine_state.claim_owner_key.as_ref() != Some(&claim_owner_key) {
1500            return Err(WorkGraphError::Store(format!(
1501                "work item {} claim owner projection does not match machine state",
1502                item.id
1503            )));
1504        }
1505        if item.machine_state.claimed_at_utc_ms != Some(datetime_to_millis(claim.claimed_at)) {
1506            return Err(WorkGraphError::Store(format!(
1507                "work item {} claim time projection does not match machine state",
1508                item.id
1509            )));
1510        }
1511        if item.machine_state.lease_expires_at_utc_ms
1512            != claim.lease_expires_at.map(datetime_to_millis)
1513        {
1514            return Err(WorkGraphError::Store(format!(
1515                "work item {} claim lease projection does not match machine state",
1516                item.id
1517            )));
1518        }
1519    } else if item.machine_state.claim_owner_key.is_some()
1520        || item.machine_state.claimed_at_utc_ms.is_some()
1521        || item.machine_state.lease_expires_at_utc_ms.is_some()
1522    {
1523        return Err(WorkGraphError::Store(format!(
1524            "work item {} machine state has a claim without a claim projection",
1525            item.id
1526        )));
1527    }
1528    Ok(())
1529}
1530
1531fn work_owner_key(owner: &crate::types::WorkOwner) -> Result<wg_dsl::WorkOwnerKey, WorkGraphError> {
1532    let kind = match owner.key.kind {
1533        crate::types::WorkOwnerKind::Principal => wg_dsl::WorkOwnerKind::Principal,
1534        crate::types::WorkOwnerKind::Agent => wg_dsl::WorkOwnerKind::Agent,
1535        crate::types::WorkOwnerKind::Session => wg_dsl::WorkOwnerKind::Session,
1536        crate::types::WorkOwnerKind::Mob => wg_dsl::WorkOwnerKind::Mob,
1537        crate::types::WorkOwnerKind::Label => wg_dsl::WorkOwnerKind::Label,
1538    };
1539    Ok(wg_dsl::WorkOwnerKey {
1540        kind,
1541        id: owner.key.id.clone(),
1542    })
1543}
1544
1545fn topology_state(
1546    existing_items: &[WorkItem],
1547    existing_edges: &[WorkEdge],
1548) -> WorkGraphMachineState {
1549    WorkGraphMachineState {
1550        topology_item_keys: existing_items
1551            .iter()
1552            .map(|item| work_item_key(&item.id))
1553            .collect(),
1554        topology_edge_keys: existing_edges
1555            .iter()
1556            .map(|edge| work_edge_key(edge.kind, &edge.from_id, &edge.to_id))
1557            .collect(),
1558        blocks_reachability: dependency_reachability(existing_edges, WorkEdgeKind::Blocks),
1559        parent_reachability: dependency_reachability(existing_edges, WorkEdgeKind::Parent),
1560        ..Default::default()
1561    }
1562}
1563
1564fn dependency_reachability(
1565    edges: &[WorkEdge],
1566    kind: WorkEdgeKind,
1567) -> BTreeSet<wg_dsl::WorkDependencyPathKey> {
1568    let mut adjacency = BTreeMap::<WorkItemId, BTreeSet<WorkItemId>>::new();
1569    for edge in edges.iter().filter(|edge| edge.kind == kind) {
1570        adjacency
1571            .entry(edge.from_id.clone())
1572            .or_default()
1573            .insert(edge.to_id.clone());
1574    }
1575
1576    let mut reachability = BTreeSet::new();
1577    for start in adjacency.keys() {
1578        let mut stack = adjacency
1579            .get(start)
1580            .into_iter()
1581            .flat_map(|targets| targets.iter().cloned())
1582            .collect::<Vec<_>>();
1583        let mut seen = BTreeSet::new();
1584        while let Some(current) = stack.pop() {
1585            if !seen.insert(current.clone()) {
1586                continue;
1587            }
1588            reachability.insert(dependency_path_key(kind, start, &current));
1589            if let Some(targets) = adjacency.get(&current) {
1590                stack.extend(targets.iter().cloned());
1591            }
1592        }
1593    }
1594    reachability
1595}
1596
1597fn work_item_key(id: &WorkItemId) -> wg_dsl::WorkItemKey {
1598    wg_dsl::WorkItemKey(id.as_str().to_string())
1599}
1600
1601fn work_edge_key(
1602    kind: WorkEdgeKind,
1603    from_id: &WorkItemId,
1604    to_id: &WorkItemId,
1605) -> wg_dsl::WorkEdgeKey {
1606    wg_dsl::WorkEdgeKey(format!(
1607        "{}:{}:{}",
1608        edge_kind_key(kind),
1609        from_id.as_str(),
1610        to_id.as_str()
1611    ))
1612}
1613
1614fn dependency_path_key(
1615    kind: WorkEdgeKind,
1616    from_id: &WorkItemId,
1617    to_id: &WorkItemId,
1618) -> wg_dsl::WorkDependencyPathKey {
1619    wg_dsl::WorkDependencyPathKey(format!(
1620        "{}:{}:{}",
1621        edge_kind_key(kind),
1622        from_id.as_str(),
1623        to_id.as_str()
1624    ))
1625}
1626
1627fn dsl_edge_kind(kind: WorkEdgeKind) -> wg_dsl::WorkEdgeKind {
1628    match kind {
1629        WorkEdgeKind::Blocks => wg_dsl::WorkEdgeKind::Blocks,
1630        WorkEdgeKind::Parent => wg_dsl::WorkEdgeKind::Parent,
1631        WorkEdgeKind::Related => wg_dsl::WorkEdgeKind::Related,
1632        WorkEdgeKind::Supersedes => wg_dsl::WorkEdgeKind::Supersedes,
1633        WorkEdgeKind::DerivedFrom => wg_dsl::WorkEdgeKind::DerivedFrom,
1634    }
1635}
1636
1637fn edge_kind_key(kind: WorkEdgeKind) -> &'static str {
1638    match kind {
1639        WorkEdgeKind::Blocks => "blocks",
1640        WorkEdgeKind::Parent => "parent",
1641        WorkEdgeKind::Related => "related",
1642        WorkEdgeKind::Supersedes => "supersedes",
1643        WorkEdgeKind::DerivedFrom => "derived_from",
1644    }
1645}
1646
1647fn datetime_to_millis(dt: DateTime<Utc>) -> u64 {
1648    u64::try_from(dt.timestamp_millis()).unwrap_or(0)
1649}
1650
1651fn millis_to_datetime(ms: u64) -> Option<DateTime<Utc>> {
1652    DateTime::from_timestamp_millis(i64::try_from(ms).ok()?)
1653}
1654
1655fn item_event(
1656    item: &WorkItem,
1657    kind: WorkGraphEventKind,
1658    at: DateTime<Utc>,
1659) -> Result<WorkGraphEvent, WorkGraphError> {
1660    Ok(WorkGraphEvent::item(
1661        item.realm_id.clone(),
1662        item.namespace.clone(),
1663        item.id.clone(),
1664        kind,
1665        at,
1666        json!({ "item": item }),
1667    ))
1668}
1669
1670fn seconds_to_duration(seconds: u64) -> Duration {
1671    let seconds = i64::try_from(seconds).unwrap_or(i64::MAX);
1672    Duration::seconds(seconds)
1673}
1674
1675#[cfg(test)]
1676#[allow(
1677    clippy::expect_used,
1678    clippy::unwrap_used,
1679    clippy::panic,
1680    clippy::redundant_clone
1681)]
1682mod tests {
1683    use super::*;
1684    use crate::types::{
1685        AddEvidenceRequest, ClaimWorkItemRequest, CloseWorkItemRequest, UpdateWorkItemRequest,
1686        WorkEvidenceKind, WorkEvidenceRef, WorkOwner, WorkOwnerKey,
1687    };
1688
1689    fn create(title: &str, now: DateTime<Utc>) -> WorkItem {
1690        create_with_policy(title, WorkCompletionPolicy::SelfAttest, now)
1691    }
1692
1693    fn create_with_policy(
1694        title: &str,
1695        completion_policy: WorkCompletionPolicy,
1696        now: DateTime<Utc>,
1697    ) -> WorkItem {
1698        WorkGraphMachine::create_item(
1699            CreateWorkItemRequest {
1700                realm_id: None,
1701                namespace: None,
1702                title: title.to_string(),
1703                description: None,
1704                priority: Default::default(),
1705                completion_policy,
1706                labels: BTreeSet::new(),
1707                due_at: None,
1708                not_before: None,
1709                snoozed_until: None,
1710                external_refs: Vec::new(),
1711                evidence_refs: Vec::new(),
1712                status: None,
1713            },
1714            "realm".to_string(),
1715            WorkNamespace::default(),
1716            now,
1717        )
1718        .expect("create")
1719        .0
1720    }
1721
1722    fn owner(id: &str) -> WorkOwner {
1723        WorkOwner::new(WorkOwnerKey::label(id).expect("owner key"))
1724    }
1725
1726    fn create_with_status(
1727        status: Option<WorkStatus>,
1728        now: DateTime<Utc>,
1729    ) -> Result<WorkItem, WorkGraphError> {
1730        WorkGraphMachine::create_item(
1731            CreateWorkItemRequest {
1732                realm_id: None,
1733                namespace: None,
1734                title: "status-create".to_string(),
1735                description: None,
1736                priority: Default::default(),
1737                completion_policy: WorkCompletionPolicy::SelfAttest,
1738                labels: BTreeSet::new(),
1739                due_at: None,
1740                not_before: None,
1741                snoozed_until: None,
1742                external_refs: Vec::new(),
1743                evidence_refs: Vec::new(),
1744                status,
1745            },
1746            "realm".to_string(),
1747            WorkNamespace::default(),
1748            now,
1749        )
1750        .map(|(item, _)| item)
1751    }
1752
1753    #[test]
1754    fn create_status_admission_is_decided_by_machine() {
1755        use wg_dsl::WorkCreateStatusAdmissionKind as Admission;
1756        // The machine owns the "only open or blocked" creation policy.
1757        assert_eq!(
1758            classify_create_status_admission(WorkStatus::Open).expect("classify open"),
1759            Admission::AdmittedOpen
1760        );
1761        assert_eq!(
1762            classify_create_status_admission(WorkStatus::Blocked).expect("classify blocked"),
1763            Admission::AdmittedBlocked
1764        );
1765        for status in [
1766            WorkStatus::InProgress,
1767            WorkStatus::Completed,
1768            WorkStatus::Cancelled,
1769            WorkStatus::Failed,
1770        ] {
1771            assert_eq!(
1772                classify_create_status_admission(status)
1773                    .unwrap_or_else(|error| panic!("classify {status:?}: {error:?}")),
1774                Admission::Denied,
1775                "requested status {status:?} must be denied as a creation state"
1776            );
1777        }
1778    }
1779
1780    #[test]
1781    fn create_item_admits_open_and_blocked_and_rejects_the_rest() {
1782        let now = Utc::now();
1783        assert_eq!(
1784            create_with_status(Some(WorkStatus::Open), now)
1785                .expect("open admitted")
1786                .status,
1787            WorkStatus::Open
1788        );
1789        assert_eq!(
1790            create_with_status(Some(WorkStatus::Blocked), now)
1791                .expect("blocked admitted")
1792                .status,
1793            WorkStatus::Blocked
1794        );
1795        // Default (None) resolves to Open and must be admitted.
1796        assert_eq!(
1797            create_with_status(None, now)
1798                .expect("default admitted")
1799                .status,
1800            WorkStatus::Open
1801        );
1802        for status in [
1803            WorkStatus::InProgress,
1804            WorkStatus::Completed,
1805            WorkStatus::Cancelled,
1806            WorkStatus::Failed,
1807        ] {
1808            let error = create_with_status(Some(status), now)
1809                .expect_err("non-open/blocked create status must be rejected");
1810            match error {
1811                WorkGraphError::InvalidTransition(message) => assert_eq!(
1812                    message, "new work items may only start open or blocked",
1813                    "rejection message preserved for {status:?}"
1814                ),
1815                other => panic!("expected InvalidTransition for {status:?}, got {other:?}"),
1816            }
1817        }
1818    }
1819
1820    #[test]
1821    fn public_confirmation_admission_is_decided_by_machine() {
1822        use wg_dsl::WorkPublicConfirmationAdmissionKind as Admission;
1823        // The machine owns the trust-scoped eligibility: only SelfAttest is
1824        // publicly confirmable; every other policy requires trusted host.
1825        assert_eq!(
1826            WorkGraphMachine::classify_public_confirmation_admission(
1827                &WorkCompletionPolicy::SelfAttest
1828            )
1829            .expect("self-attest admission"),
1830            Admission::Admitted
1831        );
1832        let owner_key = WorkOwnerKey::label("supervisor").expect("owner key");
1833        let denied = [
1834            WorkCompletionPolicy::HostConfirmed,
1835            WorkCompletionPolicy::PrincipalConfirmed,
1836            WorkCompletionPolicy::Supervisor {
1837                owner_key: owner_key.clone(),
1838            },
1839            WorkCompletionPolicy::ReviewerQuorum { threshold: 2 },
1840        ];
1841        for policy in denied {
1842            assert_eq!(
1843                WorkGraphMachine::classify_public_confirmation_admission(&policy)
1844                    .unwrap_or_else(|error| panic!("classify {policy:?}: {error:?}")),
1845                Admission::DeniedRequiresTrustedHost,
1846                "policy {policy:?} must require trusted host for public confirmation"
1847            );
1848        }
1849    }
1850
1851    #[test]
1852    fn create_completion_policy_admission_is_decided_by_machine() {
1853        use wg_dsl::WorkCreateCompletionPolicyAdmissionKind as Admission;
1854        // The machine owns the "non-goal work items must use self_attest"
1855        // creation policy.
1856        assert_eq!(
1857            WorkGraphMachine::classify_create_completion_policy_admission(
1858                &WorkCompletionPolicy::SelfAttest
1859            )
1860            .expect("self-attest admission"),
1861            Admission::Admitted
1862        );
1863        let owner_key = WorkOwnerKey::label("supervisor").expect("owner key");
1864        let denied = [
1865            WorkCompletionPolicy::HostConfirmed,
1866            WorkCompletionPolicy::PrincipalConfirmed,
1867            WorkCompletionPolicy::Supervisor { owner_key },
1868            WorkCompletionPolicy::ReviewerQuorum { threshold: 2 },
1869        ];
1870        for policy in denied {
1871            assert_eq!(
1872                WorkGraphMachine::classify_create_completion_policy_admission(&policy)
1873                    .unwrap_or_else(|error| panic!("classify {policy:?}: {error:?}")),
1874                Admission::DeniedNonSelfAttest,
1875                "policy {policy:?} must be denied at create for a non-goal work item"
1876            );
1877        }
1878    }
1879
1880    #[test]
1881    fn close_status_admission_is_decided_by_machine() {
1882        use wg_dsl::WorkCloseStatusAdmissionKind as Admission;
1883        // The machine owns the "close requires a terminal status" lifecycle
1884        // class fact.
1885        assert_eq!(
1886            classify_close_status_admission(WorkStatus::Completed).expect("classify completed"),
1887            Admission::AdmittedCompleted
1888        );
1889        assert_eq!(
1890            classify_close_status_admission(WorkStatus::Cancelled).expect("classify cancelled"),
1891            Admission::AdmittedCancelled
1892        );
1893        assert_eq!(
1894            classify_close_status_admission(WorkStatus::Failed).expect("classify failed"),
1895            Admission::AdmittedFailed
1896        );
1897        for status in [
1898            WorkStatus::Open,
1899            WorkStatus::InProgress,
1900            WorkStatus::Blocked,
1901        ] {
1902            assert_eq!(
1903                classify_close_status_admission(status)
1904                    .unwrap_or_else(|error| panic!("classify {status:?}: {error:?}")),
1905                Admission::DeniedNonTerminal,
1906                "requested close status {status:?} must be denied as a non-terminal target"
1907            );
1908        }
1909    }
1910
1911    #[test]
1912    fn close_item_rejects_non_terminal_status_with_preserved_message() {
1913        let now = Utc::now();
1914        for status in [
1915            WorkStatus::Open,
1916            WorkStatus::InProgress,
1917            WorkStatus::Blocked,
1918        ] {
1919            let item = create("close-target", now);
1920            let error = WorkGraphMachine::close_item(
1921                item.clone(),
1922                CloseWorkItemRequest {
1923                    id: item.id.clone(),
1924                    realm_id: None,
1925                    namespace: None,
1926                    expected_revision: item.revision,
1927                    status,
1928                },
1929                now,
1930            )
1931            .expect_err("non-terminal close status must be rejected");
1932            match error {
1933                WorkGraphError::InvalidTransition(message) => assert_eq!(
1934                    message, "close requires a terminal status",
1935                    "rejection message preserved for {status:?}"
1936                ),
1937                other => panic!("expected InvalidTransition for {status:?}, got {other:?}"),
1938            }
1939        }
1940    }
1941
1942    #[test]
1943    fn blocked_items_are_never_ready() {
1944        let now = Utc::now();
1945        let item = create("blocked", now);
1946        let (item, _) = WorkGraphMachine::block_item(item, 1, now).expect("block");
1947        assert!(WorkGraphMachine::ready_items(vec![item], now).is_empty());
1948    }
1949
1950    #[test]
1951    fn future_due_items_are_not_ready() {
1952        let now = Utc::now();
1953        let item = create("future", now);
1954        let (item, _) = WorkGraphMachine::update_item(
1955            item,
1956            UpdateWorkItemRequest {
1957                id: WorkItemId::generated(),
1958                realm_id: None,
1959                namespace: None,
1960                expected_revision: 1,
1961                title: None,
1962                description: None,
1963                priority: None,
1964                completion_policy: None,
1965                labels: None,
1966                due_at: Some(now + Duration::hours(1)),
1967                not_before: None,
1968                snoozed_until: None,
1969                external_refs: Vec::new(),
1970            },
1971            now,
1972        )
1973        .expect("update due");
1974
1975        assert!(WorkGraphMachine::ready_items(vec![item], now).is_empty());
1976    }
1977
1978    #[test]
1979    fn readiness_is_decided_by_machine_matching_claim_guards() {
1980        let now = Utc::now();
1981
1982        // A fresh open item with no blockers and no time windows is ready —
1983        // exactly what the `ClaimOpen` guard accepts.
1984        let open = create("ready-open", now);
1985        assert_eq!(open.status, WorkStatus::Open);
1986        assert!(
1987            WorkGraphMachine::classify_readiness(&open, now).expect("classify open"),
1988            "an unblocked, due-eligible open item must be machine-classified ready"
1989        );
1990        assert!(WorkGraphMachine::is_ready(&open, now));
1991
1992        // A future-due open item is not ready (mirrors `due_eligible` guard).
1993        let (future, _) = WorkGraphMachine::update_item(
1994            open,
1995            UpdateWorkItemRequest {
1996                id: WorkItemId::generated(),
1997                realm_id: None,
1998                namespace: None,
1999                expected_revision: 1,
2000                title: None,
2001                description: None,
2002                priority: None,
2003                completion_policy: None,
2004                labels: None,
2005                due_at: Some(now + Duration::hours(1)),
2006                not_before: None,
2007                snoozed_until: None,
2008                external_refs: Vec::new(),
2009            },
2010            now,
2011        )
2012        .expect("update future due");
2013        assert!(
2014            !WorkGraphMachine::classify_readiness(&future, now).expect("classify future"),
2015            "a future-due open item must be machine-classified not ready"
2016        );
2017        assert!(!WorkGraphMachine::is_ready(&future, now));
2018
2019        // A claimed (InProgress) item with a live lease is NOT ready; once the
2020        // lease expires it becomes ready (mirrors `ClaimExpiredInProgress`).
2021        let claimable = create("reclaim", now);
2022        let (claimed, _) = WorkGraphMachine::claim_item(
2023            claimable,
2024            ClaimWorkItemRequest {
2025                id: WorkItemId::generated(),
2026                realm_id: None,
2027                namespace: None,
2028                expected_revision: 1,
2029                owner: owner("worker"),
2030                lease_seconds: Some(30),
2031                lease_expires_at: None,
2032            },
2033            now,
2034        )
2035        .expect("claim");
2036        assert_eq!(claimed.status, WorkStatus::InProgress);
2037        assert!(
2038            !WorkGraphMachine::classify_readiness(&claimed, now).expect("classify live lease"),
2039            "an in-progress item with a live lease must not be machine-classified ready"
2040        );
2041        let after_lease = now + Duration::seconds(31);
2042        assert!(
2043            WorkGraphMachine::classify_readiness(&claimed, after_lease)
2044                .expect("classify expired lease"),
2045            "an in-progress item with an expired lease must be machine-classified ready"
2046        );
2047        assert!(WorkGraphMachine::is_ready(&claimed, after_lease));
2048    }
2049
2050    #[test]
2051    fn terminal_items_cannot_be_claimed() {
2052        let now = Utc::now();
2053        let item = create("done", now);
2054        let (item, _) = WorkGraphMachine::close_item(
2055            item,
2056            CloseWorkItemRequest {
2057                id: WorkItemId::generated(),
2058                realm_id: None,
2059                namespace: None,
2060                expected_revision: 1,
2061                status: WorkStatus::Completed,
2062            },
2063            now,
2064        )
2065        .expect("close");
2066        let error = WorkGraphMachine::claim_item(
2067            item,
2068            ClaimWorkItemRequest {
2069                id: WorkItemId::generated(),
2070                realm_id: None,
2071                namespace: None,
2072                expected_revision: 2,
2073                owner: owner("worker"),
2074                lease_seconds: None,
2075                lease_expires_at: None,
2076            },
2077            now,
2078        )
2079        .expect_err("terminal claim should fail");
2080        assert!(matches!(error, WorkGraphError::InvalidTransition(_)));
2081    }
2082
2083    #[test]
2084    fn completed_close_is_completion_policy_gated_by_machine() {
2085        let now = Utc::now();
2086        let item = create_with_policy(
2087            "needs host confirmation",
2088            WorkCompletionPolicy::HostConfirmed,
2089            now,
2090        );
2091
2092        // The machine's completion_policy_satisfied guard refuses the
2093        // CloseCompleted transition while no host confirmation evidence has
2094        // been recorded.
2095        let error = WorkGraphMachine::close_item(
2096            item.clone(),
2097            CloseWorkItemRequest {
2098                id: item.id.clone(),
2099                realm_id: None,
2100                namespace: None,
2101                expected_revision: 1,
2102                status: WorkStatus::Completed,
2103            },
2104            now,
2105        )
2106        .expect_err("machine must reject completed close without policy evidence");
2107        assert!(matches!(error, WorkGraphError::InvalidTransition(_)));
2108
2109        // Once typed host-confirmation evidence is recorded, the machine's
2110        // owned host_confirmation_count satisfies the policy and the close
2111        // transition is admitted.
2112        let (item, _) = WorkGraphMachine::add_evidence(
2113            item,
2114            AddEvidenceRequest {
2115                id: WorkItemId::generated(),
2116                realm_id: None,
2117                namespace: None,
2118                expected_revision: 1,
2119                evidence: WorkEvidenceRef {
2120                    kind: "host_confirmation".to_string(),
2121                    id: "acceptance".to_string(),
2122                    label: None,
2123                    summary: None,
2124                    confirmation_kind: Some(WorkEvidenceKind::HostConfirmation),
2125                    confirming_owner_key: None,
2126                    execution_binding_id: None,
2127                },
2128            },
2129            now,
2130        )
2131        .expect("record host confirmation evidence");
2132
2133        let (closed, _) = WorkGraphMachine::close_item(
2134            item.clone(),
2135            CloseWorkItemRequest {
2136                id: item.id,
2137                realm_id: None,
2138                namespace: None,
2139                expected_revision: item.revision,
2140                status: WorkStatus::Completed,
2141            },
2142            now,
2143        )
2144        .expect("machine admits close once host confirmation is satisfied");
2145        assert_eq!(closed.status, WorkStatus::Completed);
2146    }
2147
2148    #[test]
2149    fn reviewer_quorum_close_requires_distinct_reviewers() {
2150        let now = Utc::now();
2151        let item = create_with_policy(
2152            "needs two reviewers",
2153            WorkCompletionPolicy::ReviewerQuorum { threshold: 2 },
2154            now,
2155        );
2156
2157        let reviewer_evidence = |reviewer: &str| WorkEvidenceRef {
2158            kind: "reviewer_confirmation".to_string(),
2159            id: reviewer.to_string(),
2160            label: Some(reviewer.to_string()),
2161            summary: None,
2162            confirmation_kind: Some(WorkEvidenceKind::ReviewerConfirmation),
2163            confirming_owner_key: Some(
2164                WorkOwnerKey::principal(reviewer).expect("reviewer principal"),
2165            ),
2166            execution_binding_id: None,
2167        };
2168
2169        // One distinct reviewer is short of the quorum: machine refuses close.
2170        let (item, _) = WorkGraphMachine::add_evidence(
2171            item,
2172            AddEvidenceRequest {
2173                id: WorkItemId::generated(),
2174                realm_id: None,
2175                namespace: None,
2176                expected_revision: 1,
2177                evidence: reviewer_evidence("alice"),
2178            },
2179            now,
2180        )
2181        .expect("record first reviewer");
2182
2183        let error = WorkGraphMachine::close_item(
2184            item.clone(),
2185            CloseWorkItemRequest {
2186                id: item.id.clone(),
2187                realm_id: None,
2188                namespace: None,
2189                expected_revision: item.revision,
2190                status: WorkStatus::Completed,
2191            },
2192            now,
2193        )
2194        .expect_err("single reviewer must not satisfy a quorum of two");
2195        assert!(matches!(error, WorkGraphError::InvalidTransition(_)));
2196
2197        // A duplicate confirmation from the same reviewer does not advance the
2198        // distinct-reviewer count; the machine still refuses.
2199        let expected_revision = item.revision;
2200        let (item, _) = WorkGraphMachine::add_evidence(
2201            item,
2202            AddEvidenceRequest {
2203                id: WorkItemId::generated(),
2204                realm_id: None,
2205                namespace: None,
2206                expected_revision,
2207                evidence: reviewer_evidence("alice"),
2208            },
2209            now,
2210        )
2211        .expect("record duplicate reviewer");
2212
2213        let error = WorkGraphMachine::close_item(
2214            item.clone(),
2215            CloseWorkItemRequest {
2216                id: item.id.clone(),
2217                realm_id: None,
2218                namespace: None,
2219                expected_revision: item.revision,
2220                status: WorkStatus::Completed,
2221            },
2222            now,
2223        )
2224        .expect_err("duplicate reviewer must not satisfy a quorum of two");
2225        assert!(matches!(error, WorkGraphError::InvalidTransition(_)));
2226
2227        // A second distinct reviewer reaches the quorum; machine admits close.
2228        let expected_revision = item.revision;
2229        let (item, _) = WorkGraphMachine::add_evidence(
2230            item,
2231            AddEvidenceRequest {
2232                id: WorkItemId::generated(),
2233                realm_id: None,
2234                namespace: None,
2235                expected_revision,
2236                evidence: reviewer_evidence("bob"),
2237            },
2238            now,
2239        )
2240        .expect("record second reviewer");
2241
2242        let (closed, _) = WorkGraphMachine::close_item(
2243            item.clone(),
2244            CloseWorkItemRequest {
2245                id: item.id,
2246                realm_id: None,
2247                namespace: None,
2248                expected_revision: item.revision,
2249                status: WorkStatus::Completed,
2250            },
2251            now,
2252        )
2253        .expect("two distinct reviewers satisfy the quorum");
2254        assert_eq!(closed.status, WorkStatus::Completed);
2255    }
2256
2257    #[test]
2258    fn stale_revisions_fail() {
2259        let now = Utc::now();
2260        let item = create("stale", now);
2261        let error =
2262            WorkGraphMachine::block_item(item, 7, now).expect_err("stale transition should fail");
2263        assert!(matches!(error, WorkGraphError::StaleRevision { .. }));
2264    }
2265
2266    #[test]
2267    fn public_error_class_is_machine_owned() {
2268        use crate::types::{WorkAttentionBindingId, WorkNamespace};
2269
2270        let cases: &[(WorkGraphError, WorkGraphPublicErrorClass)] = &[
2271            (
2272                WorkGraphError::not_found(
2273                    "realm".to_string(),
2274                    WorkNamespace::default(),
2275                    WorkItemId::generated(),
2276                ),
2277                WorkGraphPublicErrorClass::NotFound,
2278            ),
2279            (
2280                WorkGraphError::attention_not_found(
2281                    "realm".to_string(),
2282                    WorkNamespace::default(),
2283                    WorkAttentionBindingId::generated(),
2284                ),
2285                WorkGraphPublicErrorClass::NotFound,
2286            ),
2287            (
2288                WorkGraphError::StaleRevision {
2289                    id: WorkItemId::generated(),
2290                    expected: 1,
2291                    actual: 2,
2292                },
2293                WorkGraphPublicErrorClass::Conflict,
2294            ),
2295            (
2296                WorkGraphError::Conflict("conflict".to_string()),
2297                WorkGraphPublicErrorClass::Conflict,
2298            ),
2299            (
2300                WorkGraphError::InvalidTransition("bad".to_string()),
2301                WorkGraphPublicErrorClass::InvalidTransition,
2302            ),
2303            (
2304                WorkGraphError::InvalidInput("bad".to_string()),
2305                WorkGraphPublicErrorClass::InvalidArguments,
2306            ),
2307            (
2308                WorkGraphError::InvalidTimestampMillis {
2309                    field: "due_at",
2310                    millis: -1,
2311                },
2312                WorkGraphPublicErrorClass::InvalidArguments,
2313            ),
2314            (
2315                WorkGraphError::Store("store".to_string()),
2316                WorkGraphPublicErrorClass::StoreError,
2317            ),
2318            (
2319                WorkGraphError::UnsupportedBackend("backend".to_string()),
2320                WorkGraphPublicErrorClass::CapabilityUnavailable,
2321            ),
2322        ];
2323
2324        for (error, expected) in cases {
2325            let class = WorkGraphMachine::public_error_class(error)
2326                .expect("machine must classify every WorkGraphError variant");
2327            assert_eq!(class, *expected, "unexpected public class for {error:?}");
2328        }
2329    }
2330
2331    #[test]
2332    fn only_one_active_claim_can_exist() {
2333        let now = Utc::now();
2334        let item = create("claim", now);
2335        let (claimed, _) = WorkGraphMachine::claim_item(
2336            item,
2337            ClaimWorkItemRequest {
2338                id: WorkItemId::generated(),
2339                realm_id: None,
2340                namespace: None,
2341                expected_revision: 1,
2342                owner: owner("worker"),
2343                lease_seconds: Some(60),
2344                lease_expires_at: None,
2345            },
2346            now,
2347        )
2348        .expect("claim");
2349        let error = WorkGraphMachine::claim_item(
2350            claimed,
2351            ClaimWorkItemRequest {
2352                id: WorkItemId::generated(),
2353                realm_id: None,
2354                namespace: None,
2355                expected_revision: 2,
2356                owner: owner("worker-2"),
2357                lease_seconds: Some(60),
2358                lease_expires_at: None,
2359            },
2360            now,
2361        )
2362        .expect_err("double claim should fail");
2363        assert!(matches!(error, WorkGraphError::InvalidTransition(_)));
2364    }
2365
2366    #[test]
2367    fn validate_item_projection_only_rejects_never_derives() {
2368        let now = Utc::now();
2369
2370        // A freshly machine-built item agrees with its machine_state authority.
2371        let clean = create("projection-guard", now);
2372        WorkGraphMachine::validate_item_projection(&clean)
2373            .expect("a machine-built projection must agree with its machine state");
2374
2375        // Tampering the projected status away from the machine-owned lifecycle
2376        // phase is rejected — the guard does not silently re-derive `status`
2377        // from the machine state, it fails closed on the drift.
2378        let mut status_drift = clean.clone();
2379        status_drift.status = WorkStatus::Completed;
2380        let err = WorkGraphMachine::validate_item_projection(&status_drift)
2381            .expect_err("status projection drift must be rejected, never repaired");
2382        assert!(
2383            matches!(&err, WorkGraphError::Store(message) if message.contains("status projection")),
2384            "rejection must cite the status projection drift, got: {err:?}"
2385        );
2386
2387        // Likewise for the revision projection: the machine_state.revision is
2388        // canonical, and a divergent projected revision is rejected rather than
2389        // synthesized back into agreement.
2390        let mut revision_drift = clean.clone();
2391        revision_drift.revision = revision_drift.revision.wrapping_add(1);
2392        let err = WorkGraphMachine::validate_item_projection(&revision_drift)
2393            .expect_err("revision projection drift must be rejected, never repaired");
2394        assert!(
2395            matches!(&err, WorkGraphError::Store(message) if message.contains("revision projection")),
2396            "rejection must cite the revision projection drift, got: {err:?}"
2397        );
2398    }
2399}