Skip to main content

dpm_engine/
command.rs

1use chrono::{DateTime, Utc};
2use dpm_model::{
3    ActorId, Artifact, DecisionId, DependencyId, ExternalIdentity, ExternalLinkRole,
4    ExternalReferenceId, ExternalState, Key, OperationId, ValidationError, WorkItemId, WorkKind,
5    WorkStatus,
6};
7use dpm_schedule::ScheduleError;
8use serde::{Deserialize, Serialize};
9use thiserror::Error;
10
11/// A semantic state change; adapters must use the engine to apply it.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(deny_unknown_fields)]
14pub enum Command {
15    /// Apply a reviewed plan proposal without bypassing execution history; built from a full
16    /// proposal by [`crate::plan_change`].
17    ApplyChange {
18        /// The canonical entity-level difference from the operation's base revision, in the
19        /// order [`crate::propose_change`] reports it; every `before` must match that state.
20        changes: Vec<crate::EntityChange>,
21        /// Human-readable purpose of the accepted scope change.
22        reason: String,
23    },
24    /// Undo a recorded plan change by applying its reviewed inverse, as a new operation that names
25    /// the one it reverts; the history is never rewritten.
26    RevertChange {
27        /// The recorded plan change being undone.
28        operation: OperationId,
29        /// The inverse exactly as [`crate::propose_revert`] reports it for the current plan.
30        changes: Vec<crate::EntityChange>,
31        /// Human-readable purpose of the revert.
32        reason: String,
33    },
34    /// Approve a proposed execution contract as a human or service.
35    RatifyContract {
36        /// Proposed task with a complete objective and acceptance criteria.
37        work: WorkItemId,
38    },
39    /// Return submitted work to its owner with an independent review.
40    Reject {
41        /// Submitted task to return for rework.
42        work: WorkItemId,
43        /// Non-empty explanation of unmet acceptance.
44        reason: String,
45    },
46    /// Reserve a ready task for the calling actor; a claim is not a start.
47    Claim {
48        /// Task to reserve.
49        work: WorkItemId,
50    },
51    /// Give up the caller's own unstarted claim, returning the task to Planned without an owner.
52    Release {
53        /// Claimed task owned by the caller.
54        work: WorkItemId,
55        /// Non-empty explanation, such as a mistaken claim or an executor that cannot continue.
56        reason: String,
57    },
58    /// Transfer claimed, started or blocked work to another owner, authorized by a human or
59    /// service; every recorded execution fact stays with the work.
60    Handoff {
61        /// Task changing owners.
62        work: WorkItemId,
63        /// Owner the caller observed; the command is refused if the work has changed hands.
64        from: ActorId,
65        /// New owner, who continues from the recorded state.
66        to: ActorId,
67        /// Non-empty explanation of the change of executor.
68        reason: String,
69    },
70    /// Begin executing a claimed task, recording its start event.
71    Start {
72        /// Claimed task owned by the caller.
73        work: WorkItemId,
74        /// When execution actually began, if before the commit; absent means the commit time.
75        #[serde(default, skip_serializing_if = "Option::is_none")]
76        occurred_at: Option<DateTime<Utc>>,
77    },
78    /// Suspend a task while preserving any existing owner.
79    Block {
80        /// Task to suspend.
81        work: WorkItemId,
82        /// Concrete blocker.
83        reason: String,
84    },
85    /// Resume blocked work, preserving its owner.
86    Unblock {
87        /// Task to resume.
88        work: WorkItemId,
89    },
90    /// Report task execution progress without accepting its result.
91    ReportProgress {
92        /// Owned task receiving the report.
93        work: WorkItemId,
94        /// Execution percentage from 0 through 100.
95        percent: u8,
96        /// Optional explanation or correction context.
97        note: Option<String>,
98    },
99    /// Request independent verification of owned work.
100    Submit {
101        /// Task whose result is submitted.
102        work: WorkItemId,
103        /// Optional evidence summary.
104        note: Option<String>,
105        /// When the result was actually submitted, if before the commit; absent means the
106        /// commit time.
107        #[serde(default, skip_serializing_if = "Option::is_none")]
108        occurred_at: Option<DateTime<Utc>>,
109    },
110    /// Accept a submitted result as a different actor.
111    Verify {
112        /// Submitted task to verify.
113        work: WorkItemId,
114        /// Optional verification evidence.
115        note: Option<String>,
116        /// When the result was actually verified, if before the commit; absent means the
117        /// commit time.
118        #[serde(default, skip_serializing_if = "Option::is_none")]
119        occurred_at: Option<DateTime<Utc>>,
120    },
121    /// Attach new evidence without replacing an existing artifact.
122    AttachArtifact {
123        /// Work receiving evidence.
124        work: WorkItemId,
125        /// New artifact attributed to the caller.
126        artifact: Artifact,
127    },
128    /// Stop enforcing a soft constraint as a human or service, recording who, when and why.
129    WaiveDependency {
130        /// Soft constraint to set aside.
131        dependency: DependencyId,
132        /// Non-empty justification.
133        reason: String,
134    },
135    /// Enforce a waived soft constraint again; the operation records who, when and why.
136    RestoreDependency {
137        /// Waived constraint to enforce again.
138        dependency: DependencyId,
139        /// Non-empty justification.
140        reason: String,
141    },
142    /// Record, as an independent human or service, that started work built on a rejected
143    /// predecessor attempt remains valid on the predecessor's current attempt.
144    RevalidateBasis {
145        /// Successor whose basis on the edge was invalidated.
146        work: WorkItemId,
147        /// Provisional edge carrying the invalidated basis.
148        dependency: DependencyId,
149        /// Predecessor attempt the reviewer checked; it must still be current and not rejected.
150        attempt: u32,
151        /// Non-empty justification of the review.
152        reason: String,
153    },
154    /// Resolve an open gate.
155    Decide {
156        /// Gate to resolve.
157        decision: DecisionId,
158        /// Non-empty outcome.
159        outcome: String,
160    },
161    /// Link work to an external tracker object without granting it local authority.
162    LinkExternal(ExternalLinkRequest),
163    /// Remove one work item's link to an external object; the last link removes the record.
164    UnlinkExternal {
165        /// Work whose link is removed.
166        work: WorkItemId,
167        /// Linked external reference.
168        reference: ExternalReferenceId,
169    },
170}
171
172/// Link request recording the resolved reference identifier so the operation log is explicit.
173#[derive(Debug, Clone, Serialize, Deserialize)]
174#[serde(deny_unknown_fields)]
175pub struct ExternalLinkRequest {
176    /// Work receiving the link.
177    pub work: WorkItemId,
178    /// The recorded reference for this identity, or a new identifier when none exists.
179    pub reference: ExternalReferenceId,
180    /// Canonical provider identity.
181    pub identity: ExternalIdentity,
182    /// Display label; replaces the recorded label without changing identity.
183    pub label: String,
184    /// Optional display URL; replaces the recorded URL without changing identity.
185    #[serde(default)]
186    pub url: Option<String>,
187    /// Relationship of the work to the external object.
188    pub role: ExternalLinkRole,
189    /// Optional external state reported by the caller; recorded as an observation only.
190    #[serde(default)]
191    pub observed: Option<ExternalState>,
192}
193
194/// Audit envelope for one successfully applied command.
195#[derive(Debug, Clone, Serialize, Deserialize)]
196#[serde(deny_unknown_fields)]
197pub struct Operation {
198    /// Unique identity of this operation.
199    pub id: OperationId,
200    /// Revision the caller observed before applying the command.
201    pub base_revision: u64,
202    /// Next wrapping revision committed by the command.
203    pub resulting_revision: u64,
204    /// Principal making the change.
205    pub actor: ActorId,
206    /// Caller-supplied UTC operation time.
207    pub timestamp: DateTime<Utc>,
208    /// Semantic change whose result is stored.
209    pub command: Command,
210}
211
212/// A rejected command or failed execution projection.
213#[derive(Debug, Error)]
214pub enum EngineError {
215    /// A plan proposal no longer refers to the current revision.
216    #[error("revision conflict: expected {expected}, current {actual}")]
217    RevisionConflict {
218        /// Revision the caller observed.
219        expected: u64,
220        /// Current revision.
221        actual: u64,
222    },
223    /// A recorded plan change names an entity whose state differs from the change's `before`.
224    #[error(
225        "plan change to {collection}{} was computed against a different state; diff the proposal again",
226        .id.as_ref().map_or_else(String::new, |id| format!(" {id}"))
227    )]
228    StaleChange {
229        /// Collection of the entity.
230        collection: String,
231        /// Entity identity, absent for workspace-wide values.
232        id: Option<String>,
233    },
234    /// A semantic difference could not be serialized.
235    #[error(transparent)]
236    Json(#[from] serde_json::Error),
237    /// Local principal policy refuses this action.
238    #[error("actor {actor} is not permitted to {action}")]
239    ActorNotAllowed {
240        /// Principal requesting the operation.
241        actor: ActorId,
242        /// Refused action.
243        action: &'static str,
244    },
245    /// Invalid authoritative input.
246    #[error(transparent)]
247    Validation(#[from] ValidationError),
248    /// Invalid command field or target category.
249    #[error("invalid command for {entity}: {reason}")]
250    InvalidCommand {
251        /// Identifier of the affected value.
252        entity: String,
253        /// Failed invariant.
254        reason: String,
255    },
256    /// Referenced work does not exist.
257    #[error("work item {0} does not exist")]
258    MissingWorkItem(WorkItemId),
259    /// Referenced temporal constraint does not exist.
260    #[error("dependency {0} does not exist")]
261    MissingDependency(DependencyId),
262    /// Referenced gate does not exist.
263    #[error("decision {0} does not exist")]
264    MissingDecision(DecisionId),
265    /// Execution commands target a grouping or condition rather than executable work.
266    #[error("work item {work} is a {kind:?}, not a task; only tasks accept execution commands")]
267    NotATask {
268        /// Targeted work item.
269        work: WorkItemId,
270        /// Non-executable kind of the target.
271        kind: WorkKind,
272    },
273    /// Lifecycle or relation gates do not permit this transition at the operation time.
274    #[error("work item {work} is not ready to {transition}: {}", crate::gates::summary(.unmet))]
275    NotReady {
276        /// Affected work.
277        work: WorkItemId,
278        /// Transition that was refused.
279        transition: crate::Transition,
280        /// The same structured conditions the gate report returns for this transition.
281        unmet: Vec<crate::UnmetGate>,
282    },
283    /// A supplied occurrence time lies after the time the operation commits.
284    #[error(
285        "work item {key} cannot record its {transition} at {occurred_at}: that is after the operation commits at {committed_at}"
286    )]
287    OccurrenceInFuture {
288        /// Affected work.
289        work: WorkItemId,
290        /// Human key of the affected work.
291        key: Key,
292        /// Transition whose event time was supplied.
293        transition: crate::Transition,
294        /// Supplied occurrence time.
295        occurred_at: DateTime<Utc>,
296        /// Commit time of the operation.
297        committed_at: DateTime<Utc>,
298    },
299    /// A supplied occurrence time precedes the latest time already recorded for the task.
300    #[error(
301        "work item {key} cannot record its {transition} at {occurred_at}: that is before its previous recorded event at {previous_at}"
302    )]
303    OccurrenceBeforePrevious {
304        /// Affected work.
305        work: WorkItemId,
306        /// Human key of the affected work.
307        key: Key,
308        /// Transition whose event time was supplied.
309        transition: crate::Transition,
310        /// Supplied occurrence time.
311        occurred_at: DateTime<Utc>,
312        /// Latest time already recorded in the task's execution history.
313        previous_at: DateTime<Utc>,
314    },
315    /// Progress and submission follow an explicit start; a claim only reserves work.
316    #[error("work item {0} has not started; start it before reporting progress or submitting")]
317    NotStarted(WorkItemId),
318    /// Started work has execution facts a release would orphan; only a handoff moves it.
319    #[error(
320        "work item {0} has started; started work cannot be released, only handed off by a human or service"
321    )]
322    AlreadyStarted(WorkItemId),
323    /// A handoff named an owner the work no longer has.
324    #[error("work item {work} is owned by {actual}, not {expected}; reload before handing it off")]
325    OwnerMismatch {
326        /// Affected work.
327        work: WorkItemId,
328        /// Owner named by the request.
329        expected: ActorId,
330        /// Current owner.
331        actual: ActorId,
332    },
333    /// A reported blocker prevents execution.
334    #[error("work item {work} is blocked: {reason}")]
335    Blocked {
336        /// Affected work.
337        work: WorkItemId,
338        /// Recorded blocker.
339        reason: String,
340    },
341    /// Another principal owns the task.
342    #[error("work item {work} is owned by {owner}")]
343    OwnedByAnother {
344        /// Affected work.
345        work: WorkItemId,
346        /// Principal allowed to submit the result.
347        owner: ActorId,
348    },
349    /// The lifecycle does not permit the requested transition.
350    #[error("invalid state transition for {work} from {status:?}")]
351    InvalidTransition {
352        /// Affected work.
353        work: WorkItemId,
354        /// Current lifecycle.
355        status: WorkStatus,
356    },
357    /// Verification requires a submitted result.
358    #[error("cannot verify work {0} that has not been submitted")]
359    NotSubmitted(WorkItemId),
360    /// Verification must be independent of the owner.
361    #[error("the submitting actor cannot verify its own work {0}")]
362    SelfVerification(WorkItemId),
363    /// A reviewed change would relax a gate on the applying actor's own work or on the work
364    /// waiting for its result.
365    #[error("actor {actor} owns {work} and cannot apply a change that relaxes {relaxed}")]
366    OwnGateRelaxed {
367        /// Principal applying the change.
368        actor: ActorId,
369        /// The actor's own work the constraint concerns.
370        work: Key,
371        /// The constraint the change would relax.
372        relaxed: Box<crate::RelaxedConstraint>,
373    },
374    /// A revalidation names a predecessor attempt that is not its current pending or verified one.
375    #[error(
376        "cannot revalidate {work} on dependency {dependency} against attempt #{requested}: the predecessor's current unrejected attempt is {}",
377        .current.map_or_else(|| "none".to_string(), |n| format!("#{n}"))
378    )]
379    StaleAttempt {
380        /// Successor named by the request.
381        work: WorkItemId,
382        /// Provisional edge named by the request.
383        dependency: DependencyId,
384        /// Attempt the reviewer checked.
385        requested: u32,
386        /// Predecessor's latest attempt if it is pending or verified.
387        current: Option<u32>,
388    },
389    /// A resolved gate cannot be resolved again.
390    #[error("decision {0} is not open")]
391    DecisionNotOpen(DecisionId),
392    /// Referenced external object is not recorded in this workspace.
393    #[error("external reference {0} does not exist")]
394    MissingExternalReference(ExternalReferenceId),
395    /// The work item has no link to the external reference.
396    #[error("work item {work} has no link to external reference {reference}")]
397    MissingExternalLink {
398        /// Work named by the request.
399        work: WorkItemId,
400        /// External reference named by the request.
401        reference: ExternalReferenceId,
402    },
403    /// The work item already links the external reference.
404    #[error("work item {work} already links external reference {reference}; unlink it first")]
405    DuplicateExternalLink {
406        /// Work named by the request.
407        work: WorkItemId,
408        /// External reference already linked.
409        reference: ExternalReferenceId,
410    },
411    /// Another work item already tracks the external object.
412    #[error("{identity} is already tracked by {owner}; unlink it before transferring tracking")]
413    TrackingOwned {
414        /// External identity whose tracking is owned.
415        identity: String,
416        /// Key of the work that tracks it.
417        owner: Key,
418    },
419    /// Schedule projection failed.
420    #[error(transparent)]
421    Schedule(#[from] ScheduleError),
422}