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}