cpm_planner/plan.rs
1//! SPEC §33 PA1 — `Planner` data model.
2//!
3//! This module defines the *types* the [`Planner`](crate::ports::Planner) trait
4//! (see [`crate::ports::Planner`]) carries across the IP boundary. The trait
5//! itself lives in `ports.rs` next to the other runtime ports; everything an
6//! implementer needs to construct, mutate, or report on a plan is here.
7//!
8//! This crate ships a textbook critical-path-method implementation of the
9//! contract. The `Planner` trait is the seam: the runtime always holds an
10//! `Arc<dyn Planner>`, so operators can register whichever implementation
11//! suits their deployment.
12//!
13//! # Wire format
14//!
15//! Every data type derives `Serialize` + `Deserialize` because the MCP server
16//! layer (PA4) serialises plans, cohorts, and status snapshots into JSON-RPC
17//! responses. The unit test at the bottom of this file exercises a one-
18//! deliverable round-trip as a smoke test for the wire shape.
19//!
20//! # Locking semantics summary
21//!
22//! - A `PlanGraph` is submitted once; the implementation hashes
23//! `(graph, caller)` and returns an existing [`PlanId`] on resubmit. Calls
24//! are idempotent.
25//! - [`crate::ports::Planner::acquire_cohort`] returns a [`Cohort`]: a batch
26//! of deliverables whose prerequisites are all [`DeliverableStatus::Complete`]
27//! and whose owned-file claims do not conflict with each other or with any
28//! currently held lock (exclusive conflicts with anything; append/append
29//! may share). The batch is locked atomically (PA3 guarantees this).
30//! - [`crate::ports::Planner::mark_status`] with `Complete` or `Failed`
31//! releases the lock. A caller-id mismatch on the held lock yields
32//! [`PlannerError::LockNotHeld`].
33//! - [`crate::ports::Planner::heartbeat`] refreshes the TTL; the TTL itself is
34//! an implementation parameter (PA3 sets the open-source default at 5 min).
35//! - [`crate::ports::Planner::force_release`] is the operator escape hatch.
36//! Implementations MUST emit an audit event carrying the supplied `reason`.
37
38use std::path::{Path, PathBuf};
39use std::time::Duration;
40
41use chrono::{DateTime, Utc};
42use serde::{Deserialize, Serialize};
43use thiserror::Error;
44
45use crate::earned_value::{BaselineTaken, PlanBaseline};
46
47/// Opaque plan identifier returned by [`crate::ports::Planner::submit_plan`].
48///
49/// The string form is implementation-defined (UUID, deterministic content
50/// hash, ULID, etc.). Callers treat the value as opaque and round-trip it
51/// without inspecting the contents.
52#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
53pub struct PlanId(pub String);
54
55impl PlanId {
56 /// Borrow the underlying string for logging or hashing.
57 pub fn as_str(&self) -> &str {
58 &self.0
59 }
60}
61
62impl std::fmt::Display for PlanId {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 f.write_str(&self.0)
65 }
66}
67
68/// Opaque per-orchestrator identity. The Planner uses this to verify that
69/// the caller releasing a lock is the same caller who acquired it.
70///
71/// The string form is implementation-defined; it must be stable for the
72/// lifetime of a single orchestrator session.
73#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
74pub struct CallerId(pub String);
75
76impl CallerId {
77 pub fn as_str(&self) -> &str {
78 &self.0
79 }
80}
81
82impl std::fmt::Display for CallerId {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 f.write_str(&self.0)
85 }
86}
87
88/// Full plan submitted to [`crate::ports::Planner::submit_plan`].
89///
90/// A plan is a DAG of [`Deliverable`]s connected by the
91/// `prerequisites` field. The Planner is responsible for detecting cycles,
92/// missing prerequisite references, and duplicate ids; on any structural
93/// problem it returns [`PlannerError::InvalidGraph`] with a precise
94/// `reason`.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct PlanGraph {
97 /// Every deliverable in the plan. Order is irrelevant; the Planner
98 /// derives execution order from the `prerequisites` edges.
99 pub deliverables: Vec<Deliverable>,
100
101 /// Optional global guardrail: maximum number of deliverables that may
102 /// be dispatched in one chained sequence before the orchestrator must
103 /// pause for explicit re-prompt. `None` means no limit. Carried at the
104 /// graph level rather than per-deliverable because it reflects an
105 /// operator policy, not a property of any single task.
106 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub max_chained_dispatch: Option<u32>,
108}
109
110/// What a prerequisite edge hands over: a finished artifact or an interface.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
112#[serde(rename_all = "snake_case")]
113pub enum PrerequisiteKind {
114 Artifact,
115 Interface,
116}
117
118/// A prerequisite edge. Wire: a bare id string, or an object.
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
120#[serde(untagged, deny_unknown_fields)]
121pub enum Prerequisite {
122 Id(String),
123 Edge {
124 id: String,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 consumes: Option<String>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 kind: Option<PrerequisiteKind>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 lag_hours: Option<f32>,
131 },
132}
133
134impl Prerequisite {
135 /// Id of the deliverable this edge points at.
136 pub fn id(&self) -> &str {
137 match self {
138 Self::Id(id) | Self::Edge { id, .. } => id,
139 }
140 }
141
142 /// What the dependent consumes from the prerequisite, if stated.
143 pub fn consumes(&self) -> Option<&str> {
144 match self {
145 Self::Id(_) => None,
146 Self::Edge { consumes, .. } => consumes.as_deref(),
147 }
148 }
149
150 /// Edge kind, if stated.
151 pub fn kind(&self) -> Option<PrerequisiteKind> {
152 match self {
153 Self::Id(_) => None,
154 Self::Edge { kind, .. } => *kind,
155 }
156 }
157
158 /// Hours between the prerequisite finishing and the dependent starting;
159 /// `0.0` when absent.
160 pub fn lag_hours(&self) -> f32 {
161 match self {
162 Self::Id(_) => 0.0,
163 Self::Edge { lag_hours, .. } => lag_hours.unwrap_or(0.0),
164 }
165 }
166}
167
168impl From<&str> for Prerequisite {
169 fn from(id: &str) -> Self {
170 Self::Id(id.to_string())
171 }
172}
173
174impl From<String> for Prerequisite {
175 fn from(id: String) -> Self {
176 Self::Id(id)
177 }
178}
179
180/// How a deliverable claims a file.
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
182#[serde(rename_all = "snake_case")]
183pub enum FileMode {
184 /// Sole writer; conflicts with every other claim on the path.
185 #[default]
186 Exclusive,
187 /// Append-only; may be co-leased with other append claims.
188 Append,
189}
190
191/// One entry of `owned_files`: a bare path (exclusive) or `{path, mode}`.
192/// A bare path round-trips as a plain string.
193#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
194#[serde(untagged, from = "OwnedFileWire")]
195pub enum OwnedFile {
196 Path(PathBuf),
197 Claim {
198 path: PathBuf,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
200 mode: Option<FileMode>,
201 },
202}
203
204/// Strict wire form of [`OwnedFile`]: the object form rejects unknown keys.
205#[derive(Deserialize)]
206#[serde(untagged)]
207enum OwnedFileWire {
208 Path(PathBuf),
209 Claim(OwnedClaimWire),
210}
211
212#[derive(Deserialize)]
213#[serde(deny_unknown_fields)]
214struct OwnedClaimWire {
215 path: PathBuf,
216 #[serde(default)]
217 mode: Option<FileMode>,
218}
219
220impl From<OwnedFileWire> for OwnedFile {
221 fn from(w: OwnedFileWire) -> Self {
222 match w {
223 OwnedFileWire::Path(p) => Self::Path(p),
224 OwnedFileWire::Claim(c) => Self::Claim {
225 path: c.path,
226 mode: c.mode,
227 },
228 }
229 }
230}
231
232impl OwnedFile {
233 pub fn path(&self) -> &Path {
234 match self {
235 Self::Path(p) | Self::Claim { path: p, .. } => p,
236 }
237 }
238
239 pub fn mode(&self) -> FileMode {
240 match self {
241 Self::Path(_) => FileMode::Exclusive,
242 Self::Claim { mode, .. } => mode.unwrap_or_default(),
243 }
244 }
245}
246
247impl From<&str> for OwnedFile {
248 fn from(s: &str) -> Self {
249 Self::Path(PathBuf::from(s))
250 }
251}
252
253impl From<PathBuf> for OwnedFile {
254 fn from(p: PathBuf) -> Self {
255 Self::Path(p)
256 }
257}
258
259/// Optional three-point effort estimate for a [`Deliverable`].
260///
261/// The three points must satisfy `0 <= optimistic <= likely <= pessimistic`
262/// and all be finite and at most [`MAX_HOURS`]; [`crate::planner`] rejects
263/// violations as [`PlannerError::InvalidGraph`].
264#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
265#[serde(deny_unknown_fields)]
266pub struct Estimate {
267 /// Best-case effort in hours.
268 pub optimistic: f32,
269 /// Most-likely effort in hours; used as the scheduled length when no
270 /// explicit effort or duration is set.
271 pub likely: f32,
272 /// Worst-case effort in hours.
273 pub pessimistic: f32,
274}
275
276/// How partial progress on a deliverable converts to earned percent for
277/// earned value ([`Deliverable::earning_rule`]).
278#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
279#[serde(rename_all = "snake_case")]
280pub enum EarningRule {
281 /// 100% only when Complete.
282 #[default]
283 ZeroHundred,
284 /// 50% once started (or any percent reported), 100% when Complete.
285 FiftyFifty,
286 /// The reported percent, 100% when Complete.
287 Weighted,
288}
289
290/// A single unit of work scheduled by the Planner.
291///
292/// `owned_files` is the load-bearing field for concurrent dispatch: the
293/// Planner guarantees that two deliverables with conflicting `owned_files`
294/// claims (exclusive conflicts with anything; append/append may share) will
295/// never be returned in the same [`Cohort`] and will never both hold active
296/// locks. This is the only mechanism the Planner uses to prevent
297/// write-write conflicts; implementations of [`crate::ports::Planner`]
298/// must therefore reject any plan that contains a deliverable whose
299/// `owned_files` are not specified up front.
300#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
301pub struct Deliverable {
302 /// Unique identifier within the plan. The Planner rejects duplicate
303 /// ids at submit time with [`PlannerError::InvalidGraph`].
304 pub id: String,
305
306 /// Exact file paths the implementer is going to write while completing
307 /// this deliverable. The absence of conflicting claims across deliverables
308 /// is the lock-contention invariant; see [`crate::ports::Planner::acquire_cohort`] semantics.
309 pub owned_files: Vec<OwnedFile>,
310
311 /// Ids of other deliverables in the same plan that must reach
312 /// [`DeliverableStatus::Complete`] before this one becomes eligible
313 /// for acquisition.
314 pub prerequisites: Vec<Prerequisite>,
315
316 /// Estimated wall-clock effort, used by critical-path math in
317 /// [`PlanStatus::critical_path`]. `None` means the planner derives an
318 /// estimate with `EffortEstimator`.
319 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub estimated_effort_hours: Option<f32>,
321
322 /// Calendar time the deliverable occupies on the schedule, in hours.
323 /// When set it replaces the effort estimate as the scheduled length;
324 /// effort stays the cost basis. Must be finite and >= 0.
325 #[serde(default, skip_serializing_if = "Option::is_none")]
326 pub duration_hours: Option<f32>,
327
328 /// Optional three-point effort estimate. Scheduled-length precedence is
329 /// `duration_hours` > `estimated_effort_hours` > `estimate.likely` >
330 /// `0` for a milestone > the estimator's default. So only when neither
331 /// `duration_hours` nor `estimated_effort_hours` is set is `likely` the
332 /// scheduled length and the basis for cost and DRAG, and only then does
333 /// Monte Carlo sample the estimate.
334 #[serde(default, skip_serializing_if = "Option::is_none")]
335 pub estimate: Option<Estimate>,
336
337 /// Free-form metadata. Conventionally carries model hints, human
338 /// descriptions, links to specs, etc. The Planner does not interpret
339 /// this field.
340 #[serde(default)]
341 pub metadata: serde_json::Value,
342
343 /// True for a milestone: a zero-effort marker whose schedule and
344 /// critical path `plan.status` reports in `milestones`. A deliverable
345 /// with `metadata.milestone == true` is treated the same way.
346 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
347 pub milestone: bool,
348
349 /// How earned value credits partial progress. `None` means
350 /// [`EarningRule::ZeroHundred`]; an explicit `zero_hundred` deserializes
351 /// as `None` and hashes like it. Part of the plan's identity (hashed).
352 #[serde(
353 default,
354 skip_serializing_if = "Option::is_none",
355 deserialize_with = "deserialize_earning_rule"
356 )]
357 pub earning_rule: Option<EarningRule>,
358}
359
360/// The default rule is normalised to `None`, so `"zero_hundred"` and an
361/// absent rule describe the same deliverable.
362fn deserialize_earning_rule<'de, D>(d: D) -> Result<Option<EarningRule>, D::Error>
363where
364 D: serde::Deserializer<'de>,
365{
366 Ok(Option::<EarningRule>::deserialize(d)?.filter(|r| *r != EarningRule::ZeroHundred))
367}
368
369impl Deliverable {
370 /// Whether this deliverable is a milestone, via the `milestone` field or
371 /// the legacy `metadata.milestone == true` convention.
372 pub fn is_milestone(&self) -> bool {
373 self.milestone
374 || self
375 .metadata
376 .get("milestone")
377 .and_then(serde_json::Value::as_bool)
378 .unwrap_or(false)
379 }
380}
381
382/// Lifecycle state of a single [`Deliverable`].
383///
384/// Transitions are driven by [`crate::ports::Planner::mark_status`] and by
385/// the Planner's own scheduling logic (e.g. `Pending` -> `Ready` when the
386/// last prerequisite completes).
387#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
388#[serde(tag = "status", rename_all = "snake_case")]
389pub enum DeliverableStatus {
390 /// At least one prerequisite is not yet complete.
391 Pending,
392 /// All prerequisites are complete; not yet acquired.
393 Ready,
394 /// A caller holds an active lock and is working on this deliverable.
395 InProgress,
396 /// The caller marked this deliverable complete. The lock has been
397 /// released.
398 Complete,
399 /// The caller marked this deliverable failed. The lock has been
400 /// released. The reason is preserved for audit and for human / planner
401 /// retry decisions.
402 Failed {
403 /// Human-readable failure reason supplied by the caller.
404 reason: String,
405 },
406}
407
408/// Request bundle for [`crate::ports::Planner::acquire_cohort`].
409///
410/// Public fields mirror the historical positional arguments so callers can
411/// construct it directly or via [`AcquireRequest::new`].
412#[derive(Debug, Clone)]
413pub struct AcquireRequest {
414 pub plan_id: PlanId,
415 pub caller_id: CallerId,
416 pub max_count: usize,
417 /// When set, only these deliverables are considered; each one that is
418 /// not leased is reported in `Cohort.blocked` with a reason code.
419 pub ids: Option<Vec<String>>,
420 /// When set, only deliverables whose `metadata` has every `(key, value)`
421 /// pair (JSON equality) are considered. Non-matches are silently skipped.
422 pub metadata_filter: Option<serde_json::Map<String, serde_json::Value>>,
423 /// Requested lease TTL for this call. `None` uses the planner
424 /// default; the planner clamps the value to its configured maximum.
425 pub ttl: Option<Duration>,
426}
427
428impl AcquireRequest {
429 pub fn new(plan_id: PlanId, caller_id: CallerId, max_count: usize) -> Self {
430 Self {
431 plan_id,
432 caller_id,
433 max_count,
434 ids: None,
435 metadata_filter: None,
436 ttl: None,
437 }
438 }
439
440 /// Restrict the acquire to these deliverable ids.
441 pub fn with_ids(mut self, ids: Vec<String>) -> Self {
442 self.ids = Some(ids);
443 self
444 }
445
446 /// Restrict the acquire to deliverables matching these metadata pairs.
447 pub fn with_metadata_filter(
448 mut self,
449 filter: serde_json::Map<String, serde_json::Value>,
450 ) -> Self {
451 self.metadata_filter = Some(filter);
452 self
453 }
454
455 /// Request a lease TTL for this acquire. `None` (the default) uses
456 /// the planner default TTL; the planner clamps the value to its
457 /// configured maximum.
458 pub fn with_ttl(mut self, ttl: Duration) -> Self {
459 self.ttl = Some(ttl);
460 self
461 }
462}
463
464/// Request bundle for [`crate::ports::Planner::mark_status`].
465///
466/// The optional progress fields feed earned value and are validated as
467/// `INVALID_ACTUALS`: `earned_pct` is 0..=100 and only accepted with
468/// `InProgress` (with `Complete` it is accepted and ignored),
469/// `actual_effort_hours` is finite and in `0..=`[`MAX_HOURS`], and
470/// `evidence` is at most [`MAX_EVIDENCE_CHARS`] characters, with at most
471/// [`MAX_EVIDENCE_ENTRIES`] kept per deliverable.
472#[derive(Debug, Clone)]
473pub struct MarkStatusRequest {
474 pub plan_id: PlanId,
475 pub deliverable_id: String,
476 pub caller_id: CallerId,
477 pub status: DeliverableStatus,
478 /// Reported percent complete; replaces the stored value.
479 pub earned_pct: Option<u8>,
480 /// Reported total effort so far, in hours; replaces the stored value and
481 /// takes precedence over leased hours for actual cost.
482 pub actual_effort_hours: Option<f32>,
483 /// One evidence note, appended to the deliverable's evidence list.
484 pub evidence: Option<String>,
485}
486
487impl MarkStatusRequest {
488 pub fn new(
489 plan_id: PlanId,
490 deliverable_id: impl Into<String>,
491 caller_id: CallerId,
492 status: DeliverableStatus,
493 ) -> Self {
494 Self {
495 plan_id,
496 deliverable_id: deliverable_id.into(),
497 caller_id,
498 status,
499 earned_pct: None,
500 actual_effort_hours: None,
501 evidence: None,
502 }
503 }
504
505 /// Report the percent complete (0..=100).
506 pub fn with_earned_pct(mut self, pct: u8) -> Self {
507 self.earned_pct = Some(pct);
508 self
509 }
510
511 /// Report the total effort spent so far, in hours.
512 pub fn with_actual_effort_hours(mut self, hours: f32) -> Self {
513 self.actual_effort_hours = Some(hours);
514 self
515 }
516
517 /// Append one evidence note.
518 pub fn with_evidence(mut self, evidence: impl Into<String>) -> Self {
519 self.evidence = Some(evidence.into());
520 self
521 }
522}
523
524/// Request bundle for [`crate::ports::Planner::heartbeat`].
525#[derive(Debug, Clone)]
526pub struct HeartbeatRequest {
527 pub plan_id: PlanId,
528 pub deliverable_id: String,
529 pub caller_id: CallerId,
530 /// Requested lease TTL for this heartbeat. `None` uses the planner
531 /// default; the planner clamps the value to its configured maximum.
532 pub ttl: Option<Duration>,
533}
534
535impl HeartbeatRequest {
536 pub fn new(plan_id: PlanId, deliverable_id: impl Into<String>, caller_id: CallerId) -> Self {
537 Self {
538 plan_id,
539 deliverable_id: deliverable_id.into(),
540 caller_id,
541 ttl: None,
542 }
543 }
544
545 /// Request a lease TTL for this heartbeat. `None` (the default)
546 /// uses the planner default TTL; the planner clamps the value to
547 /// its configured maximum.
548 pub fn with_ttl(mut self, ttl: Duration) -> Self {
549 self.ttl = Some(ttl);
550 self
551 }
552}
553
554/// Request bundle for [`crate::ports::Planner::force_release`].
555#[derive(Debug, Clone)]
556pub struct ForceReleaseRequest {
557 pub plan_id: PlanId,
558 pub deliverable_id: String,
559 pub reason: String,
560 /// Also clear the deliverable's lapse and failure counters (revives a
561 /// lapse-limited or circuit-broken deliverable). Defaults to `false`.
562 pub reset_counters: bool,
563}
564
565impl ForceReleaseRequest {
566 pub fn new(
567 plan_id: PlanId,
568 deliverable_id: impl Into<String>,
569 reason: impl Into<String>,
570 ) -> Self {
571 Self {
572 plan_id,
573 deliverable_id: deliverable_id.into(),
574 reason: reason.into(),
575 reset_counters: false,
576 }
577 }
578
579 /// Set whether the lapse and failure counters are cleared too.
580 pub fn reset_counters(mut self, yes: bool) -> Self {
581 self.reset_counters = yes;
582 self
583 }
584}
585
586/// Request bundle for [`crate::ports::Planner::accept`].
587#[derive(Debug, Clone)]
588pub struct AcceptRequest {
589 pub plan_id: PlanId,
590 pub deliverable_id: String,
591 pub accepted_by: String,
592 pub evidence: String,
593 /// Take over a live lease held by someone else. Defaults to `false`.
594 pub override_lock: bool,
595}
596
597impl AcceptRequest {
598 pub fn new(
599 plan_id: PlanId,
600 deliverable_id: impl Into<String>,
601 accepted_by: impl Into<String>,
602 evidence: impl Into<String>,
603 ) -> Self {
604 Self {
605 plan_id,
606 deliverable_id: deliverable_id.into(),
607 accepted_by: accepted_by.into(),
608 evidence: evidence.into(),
609 override_lock: false,
610 }
611 }
612
613 /// Set whether a live lease held by another caller may be taken over.
614 pub fn override_lock(mut self, yes: bool) -> Self {
615 self.override_lock = yes;
616 self
617 }
618}
619
620/// Request bundle for [`crate::ports::Planner::sync_plan`]: register or
621/// update the variant `variant` of the plan line `(project, name)`.
622#[derive(Debug, Clone)]
623pub struct SyncRequest {
624 pub project: String,
625 pub name: String,
626 pub variant: String,
627 pub graph: PlanGraph,
628 /// Where the graph was read from (relative to the project root), if it
629 /// came from a plan file. `None` for an inline graph.
630 pub source_path: Option<String>,
631 /// Hash of the source file's bytes. `None` for an inline graph, which is
632 /// then identified by its canonical graph hash.
633 pub content_hash: Option<String>,
634 /// Passed to the revision when the content changed: release live locks
635 /// of removed deliverables instead of refusing. Defaults to `false`.
636 pub force: bool,
637}
638
639impl SyncRequest {
640 pub fn new(
641 project: impl Into<String>,
642 name: impl Into<String>,
643 variant: impl Into<String>,
644 graph: PlanGraph,
645 ) -> Self {
646 Self {
647 project: project.into(),
648 name: name.into(),
649 variant: variant.into(),
650 graph,
651 source_path: None,
652 content_hash: None,
653 force: false,
654 }
655 }
656
657 /// Record the plan file the graph was read from.
658 pub fn with_source_path(mut self, path: impl Into<String>) -> Self {
659 self.source_path = Some(path.into());
660 self
661 }
662
663 /// Record the hash of the plan file's bytes.
664 pub fn with_content_hash(mut self, hash: impl Into<String>) -> Self {
665 self.content_hash = Some(hash.into());
666 self
667 }
668
669 /// Set whether a changed graph may force-release live locks.
670 pub fn force(mut self, yes: bool) -> Self {
671 self.force = yes;
672 self
673 }
674}
675
676/// Request bundle for [`crate::ports::Planner::revise_plan`].
677#[derive(Debug, Clone)]
678pub struct ReviseRequest {
679 pub plan_id: PlanId,
680 pub graph: PlanGraph,
681 /// Release live locks of removed (or claim-conflicting) deliverables
682 /// instead of refusing with `LOCK_HELD`. Defaults to `false`.
683 pub force: bool,
684}
685
686impl ReviseRequest {
687 pub fn new(plan_id: PlanId, graph: PlanGraph) -> Self {
688 Self {
689 plan_id,
690 graph,
691 force: false,
692 }
693 }
694
695 /// Set whether live locks may be force-released.
696 pub fn force(mut self, yes: bool) -> Self {
697 self.force = yes;
698 self
699 }
700}
701
702/// Request bundle for [`crate::ports::Planner::fork_plan`]: copy the head
703/// graph of the named variant `plan_id`, apply `edits`, and register the
704/// result as the new draft variant `variant` of the same plan line.
705#[derive(Debug, Clone)]
706pub struct ForkRequest {
707 pub plan_id: PlanId,
708 pub variant: String,
709 /// Applied in order with [`crate::edits::apply_edits`].
710 pub edits: Vec<crate::edits::GraphEdit>,
711 /// Where to write the new variant's plan file. Falls back to the
712 /// planner's own root; with neither, the variant is registered inline.
713 pub project_root: Option<crate::project::ProjectRoot>,
714}
715
716impl ForkRequest {
717 pub fn new(plan_id: PlanId, variant: impl Into<String>) -> Self {
718 Self {
719 plan_id,
720 variant: variant.into(),
721 edits: Vec::new(),
722 project_root: None,
723 }
724 }
725
726 /// Set the edits applied to the copied graph.
727 pub fn with_edits(mut self, edits: Vec<crate::edits::GraphEdit>) -> Self {
728 self.edits = edits;
729 self
730 }
731
732 /// Write the new variant's file under `root`.
733 pub fn with_project_root(mut self, root: crate::project::ProjectRoot) -> Self {
734 self.project_root = Some(root);
735 self
736 }
737}
738
739/// Request bundle for [`crate::ports::Planner::compare_plans`]. Exactly one
740/// of `plan_ids` (two or more plans, any variants or unnamed plans) or
741/// `plan` (`(project, name)`: every non-archived variant of that line) is
742/// given.
743#[derive(Debug, Clone)]
744pub struct ComparePlansRequest {
745 pub plan_ids: Option<Vec<PlanId>>,
746 pub plan: Option<(String, String)>,
747 pub request: crate::compare::CompareRequest,
748}
749
750impl ComparePlansRequest {
751 /// Compare the given plans, in this order.
752 pub fn by_ids(plan_ids: Vec<PlanId>, request: crate::compare::CompareRequest) -> Self {
753 Self {
754 plan_ids: Some(plan_ids),
755 plan: None,
756 request,
757 }
758 }
759
760 /// Compare every non-archived variant of the line `(project, name)`.
761 pub fn by_plan(
762 project: impl Into<String>,
763 name: impl Into<String>,
764 request: crate::compare::CompareRequest,
765 ) -> Self {
766 Self {
767 plan_ids: None,
768 plan: Some((project.into(), name.into())),
769 request,
770 }
771 }
772}
773
774/// One variant of a plan line, as reported by
775/// [`crate::ports::Planner::list_plans`].
776#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
777pub struct VariantSummary {
778 pub variant: String,
779 pub plan_id: PlanId,
780 /// True for the line's one executable variant.
781 pub selected: bool,
782 pub archived: bool,
783 pub head_revision: u32,
784 #[serde(default, skip_serializing_if = "Option::is_none")]
785 pub source_path: Option<String>,
786 /// Deliverables `Complete`.
787 pub complete: usize,
788 /// Deliverables in the head graph.
789 pub total: usize,
790 /// True when every deliverable is `Complete` (vacuously for none).
791 pub plan_complete: bool,
792 /// Head graph's CPM makespan in hours.
793 pub makespan: f32,
794}
795
796/// A named plan line and its variants, sorted by variant name.
797#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
798pub struct PlanLineSummary {
799 pub project: String,
800 pub name: String,
801 pub selected_variant: Option<String>,
802 pub archived: bool,
803 pub variants: Vec<VariantSummary>,
804}
805
806/// Result of [`crate::ports::Planner::sync_plan`].
807#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
808pub struct SyncOutcome {
809 pub plan_id: PlanId,
810 pub name: String,
811 pub variant: String,
812 /// The variant's head revision after the sync.
813 pub revision: u32,
814 /// True when this sync registered the variant.
815 pub created: bool,
816 /// True when this sync created the variant or a new revision.
817 pub changed: bool,
818 /// What the revision changed; `None` unless a new revision was made.
819 #[serde(default)]
820 pub diff: Option<crate::revise::RevisionDiff>,
821}
822
823/// Result of [`crate::ports::Planner::select_variant`]. Selection does not
824/// change any graph, so this is a dedicated summary rather than a
825/// [`crate::revise::RevisionDiff`].
826#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
827pub struct SelectOutcome {
828 pub plan_id: PlanId,
829 pub project: String,
830 pub name: String,
831 /// The variant now selected (the one `plan_id` belongs to).
832 pub variant: String,
833 /// The previously selected variant; `None` when the line had none.
834 pub previous: Option<String>,
835 /// False when `variant` was already selected (a no-op).
836 pub changed: bool,
837 /// Sorted ids whose `Complete` status was copied from the previously
838 /// selected variant (identical canonical definition in both).
839 pub carried: Vec<String>,
840 /// Sorted ids of the previous variant's live locks released by a forced
841 /// selection.
842 pub released_locks: Vec<String>,
843 /// A baseline taken automatically because the newly selected variant had
844 /// progress but no baseline (auto mode); absent otherwise.
845 #[serde(default, skip_serializing_if = "Option::is_none")]
846 pub baseline_taken: Option<BaselineTaken>,
847}
848
849/// Snapshot of a held lock. The Planner records one [`LockInfo`] per
850/// acquired deliverable and surfaces them in `Cohort::locks` and
851/// [`PlanStatus::locks_held`].
852#[derive(Debug, Clone, Serialize, Deserialize)]
853pub struct LockInfo {
854 pub plan_id: PlanId,
855 pub deliverable_id: String,
856 pub caller_id: CallerId,
857 pub acquired_at: DateTime<Utc>,
858 /// TTL deadline. After this instant, the lock is treated as expired
859 /// and the Planner is free to release it on any subsequent operation.
860 pub expires_at: DateTime<Utc>,
861}
862
863/// One row in a [`Cohort`]: a deliverable held under a single lock.
864///
865/// Pairing the deliverable with its lock structurally makes the
866/// invariant "the i-th deliverable is held under the i-th lock"
867/// unrepresentable as broken at the type level. Pre-F5 the same
868/// invariant lived in a doc comment + a runtime test assertion; a
869/// future refactor that pushed to one parallel Vec but not the other
870/// would silently violate it.
871#[derive(Debug, Clone, Serialize, Deserialize)]
872pub struct CohortRow {
873 pub deliverable: Deliverable,
874 pub lock: LockInfo,
875}
876
877/// Result of a successful [`crate::ports::Planner::acquire_cohort`]
878/// call.
879///
880/// SPEC §33 audit fixup (F5 INTERFACE_GAP-001) — the previous shape
881/// was two parallel vectors (`deliverables: Vec<Deliverable>` +
882/// `locks: Vec<LockInfo>`) with the index-pairing invariant carried
883/// only in docs. F5 tightens to `rows: Vec<CohortRow>` so the
884/// invariant is type-enforced.
885///
886/// The wire shape is preserved: `#[serde(into = "FlatCohort", try_from =
887/// "FlatCohort")]` projects to/from the historical two-array JSON so
888/// MCP clients see no breaking change. Deserialization is fallible
889/// (CMP-032): mismatched `deliverables`/`locks` lengths are a corrupt
890/// payload and are rejected rather than silently truncated.
891#[derive(Debug, Clone, Serialize, Deserialize)]
892#[serde(into = "FlatCohort", try_from = "FlatCohort")]
893pub struct Cohort {
894 pub plan_id: PlanId,
895 pub rows: Vec<CohortRow>,
896 /// Deliverables the acquire considered but did not lease, and why.
897 pub blocked: Vec<BlockedDeliverable>,
898 /// Paths in this cohort claimed in append mode by two or more
899 /// deliverables (in the cohort or against held locks).
900 pub shared_paths: Vec<PathBuf>,
901 /// A baseline taken automatically because this acquire started
902 /// execution on a plan that had none (auto mode); absent otherwise.
903 pub baseline_taken: Option<BaselineTaken>,
904}
905
906/// A deliverable the acquire considered but did not lease, and why.
907#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
908pub struct BlockedDeliverable {
909 pub id: String,
910 /// Stable code; the authoritative list is "MANUAL", "NOT_READY",
911 /// "LOCKED", "LAPSE_LIMIT", "FILE_CONFLICT" and "MAX_COUNT" (see the
912 /// server `instructions()` for when each applies).
913 pub code: String,
914 pub reason: String,
915}
916
917/// Error returned when a `FlatCohort` wire payload cannot be decoded into a
918/// [`Cohort`] — currently only the deliverables/locks length mismatch
919/// (CMP-032). Carries both lengths for triage and implements `Display` so it
920/// satisfies serde's `try_from` error bound.
921#[derive(Debug, Clone, PartialEq, Eq)]
922pub struct CohortDecodeError {
923 pub deliverables: usize,
924 pub locks: usize,
925}
926
927impl std::fmt::Display for CohortDecodeError {
928 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
929 write!(
930 f,
931 "COHORT_LENGTH_MISMATCH: deliverables ({}) and locks ({}) arrays \
932 must be the same length; each deliverable is held under exactly one lock",
933 self.deliverables, self.locks
934 )
935 }
936}
937
938impl std::error::Error for CohortDecodeError {}
939
940/// Wire-shape adapter for [`Cohort`] — keeps the historical
941/// `{plan_id, deliverables, locks}` JSON layout so the F5 in-Rust
942/// API tightening does NOT break MCP clients. NEVER referenced
943/// directly; only via the `From`/`Into` plumbing.
944#[derive(Debug, Clone, Serialize, Deserialize)]
945struct FlatCohort {
946 plan_id: PlanId,
947 deliverables: Vec<Deliverable>,
948 locks: Vec<LockInfo>,
949 #[serde(default)]
950 blocked: Vec<BlockedDeliverable>,
951 #[serde(default)]
952 shared_paths: Vec<PathBuf>,
953 #[serde(default, skip_serializing_if = "Option::is_none")]
954 baseline_taken: Option<BaselineTaken>,
955}
956
957impl From<Cohort> for FlatCohort {
958 fn from(cohort: Cohort) -> Self {
959 let mut deliverables = Vec::with_capacity(cohort.rows.len());
960 let mut locks = Vec::with_capacity(cohort.rows.len());
961 for row in cohort.rows {
962 deliverables.push(row.deliverable);
963 locks.push(row.lock);
964 }
965 FlatCohort {
966 plan_id: cohort.plan_id,
967 deliverables,
968 locks,
969 blocked: cohort.blocked,
970 shared_paths: cohort.shared_paths,
971 baseline_taken: cohort.baseline_taken,
972 }
973 }
974}
975
976impl TryFrom<FlatCohort> for Cohort {
977 type Error = CohortDecodeError;
978
979 fn try_from(flat: FlatCohort) -> Result<Self, Self::Error> {
980 // CMP-032 — mismatched lengths mean an unpaired deliverable or lock.
981 // Previously this truncated to the shorter array, silently dropping
982 // lock-grant entries (or deliverables). That hides a real corruption,
983 // so we now reject the payload outright.
984 if flat.deliverables.len() != flat.locks.len() {
985 return Err(CohortDecodeError {
986 deliverables: flat.deliverables.len(),
987 locks: flat.locks.len(),
988 });
989 }
990 let rows = flat
991 .deliverables
992 .into_iter()
993 .zip(flat.locks)
994 .map(|(deliverable, lock)| CohortRow { deliverable, lock })
995 .collect();
996 Ok(Cohort {
997 plan_id: flat.plan_id,
998 rows,
999 blocked: flat.blocked,
1000 shared_paths: flat.shared_paths,
1001 baseline_taken: flat.baseline_taken,
1002 })
1003 }
1004}
1005
1006/// Read-only snapshot of a plan's current state, returned by
1007/// [`crate::ports::Planner::status`].
1008#[derive(Debug, Clone, Serialize, Deserialize)]
1009pub struct PlanStatus {
1010 pub plan_id: PlanId,
1011 /// Per-deliverable `(id, status, attempt_count, failure_count,
1012 /// lapse_count)`. Order matches insertion order in the originally
1013 /// submitted [`PlanGraph::deliverables`] so callers can render a
1014 /// stable table. On the wire each row is a positional array —
1015 /// trailing elements were appended, so readers indexing `[0..=2]`
1016 /// keep working (backward-tolerant extension).
1017 ///
1018 /// - `attempt_count` — total leases handed out via
1019 /// [`crate::ports::Planner::acquire_cohort`] (telemetry).
1020 /// - `failure_count` — explicit `mark_status(Failed)` outcomes; this
1021 /// is the retry pressure against the failure circuit-break cap
1022 /// ([`crate::planner::MAX_ATTEMPTS`]).
1023 /// - `lapse_count` — leases lost environmentally (TTL expiry, no
1024 /// terminal mark); pressure against the lapse bound
1025 /// ([`crate::planner::MAX_LAPSES`]), never the failure breaker.
1026 pub deliverables: Vec<(String, DeliverableStatus, u32, u32, u32)>,
1027 /// Ids on the longest dependency chain, in execution order. Always
1028 /// begins with `__start__` and ends with `__finish__` (synthetic
1029 /// endpoints; an empty plan is just those two).
1030 pub critical_path: Vec<String>,
1031 /// Scheduled length plus lags along `critical_path` (= `__finish__`
1032 /// earliest finish).
1033 pub critical_path_hours: f32,
1034 /// Every lock currently active across the plan.
1035 pub locks_held: Vec<LockInfo>,
1036 /// Every zero-float deliverable (synthetic endpoints excluded), sorted by `(es, id)`.
1037 #[serde(default)]
1038 pub critical_ids: Vec<String>,
1039 /// Per-deliverable CPM schedule, in graph insertion order.
1040 #[serde(default)]
1041 pub schedule: Vec<ScheduleRow>,
1042 /// Deliverables with status `Ready` and no live lock, sorted by
1043 /// `(latest_start, float, id)` ascending (smallest latest start first,
1044 /// i.e. longest remaining tail; same order as
1045 /// [`crate::ports::Planner::acquire_cohort`] via the shared `priority_key`);
1046 /// membership is a superset: acquire may still skip deliverables at the
1047 /// failure or lapse cap, manual deliverables, or whose files overlap a
1048 /// held lock.
1049 #[serde(default)]
1050 pub ready: Vec<String>,
1051 /// True when every deliverable is `Complete` (vacuously true for an
1052 /// empty plan).
1053 #[serde(default)]
1054 pub plan_complete: bool,
1055 /// One row per milestone deliverable, in graph order.
1056 #[serde(default)]
1057 pub milestones: Vec<MilestoneRow>,
1058 /// Plan line name of a named variant; `None` for an unnamed plan.
1059 #[serde(default)]
1060 pub name: Option<String>,
1061 /// Variant name of a named variant; `None` for an unnamed plan.
1062 #[serde(default)]
1063 pub variant: Option<String>,
1064 /// Whether this variant is its line's selected (executable) variant;
1065 /// `None` for an unnamed plan.
1066 #[serde(default)]
1067 pub selected: Option<bool>,
1068 /// `Some(true)` when the variant's plan file no longer matches what was
1069 /// last synced (by content hash), `Some(false)` when it matches. `None`
1070 /// (unknown) when the variant has no source file, the planner has no
1071 /// project root for the variant's project, or the file is unreadable.
1072 #[serde(default)]
1073 pub definition_drift: Option<bool>,
1074 /// The latest earned-value baseline of the plan, or `null` before the
1075 /// first baseline.
1076 #[serde(default)]
1077 pub baseline: Option<PlanBaseline>,
1078}
1079
1080/// A milestone's schedule summary, reported by [`PlanStatus::milestones`].
1081#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1082pub struct MilestoneRow {
1083 pub id: String,
1084 /// Longest chain from `__start__` to this milestone (ends at `id`).
1085 pub critical_path: Vec<String>,
1086 /// The milestone's earliest finish, in hours from plan start.
1087 pub hours: f32,
1088 /// True once the milestone deliverable is `Complete`.
1089 pub complete: bool,
1090}
1091
1092/// Largest plan accepted by submit and the analysis tools. Bounds the
1093/// quadratic parts of validation, lint and leveling.
1094pub const MAX_DELIVERABLES: usize = 5000;
1095
1096/// Upper bound, in hours, for every effort, duration, lag and estimate
1097/// value (about 114 years). Keeps schedule arithmetic far from `f32`
1098/// overflow.
1099pub const MAX_HOURS: f32 = 1_000_000.0;
1100
1101/// Longest `evidence` note `mark_status` accepts, in characters.
1102pub const MAX_EVIDENCE_CHARS: usize = 2048;
1103
1104/// Most evidence entries one deliverable keeps; appending beyond it is
1105/// `INVALID_ACTUALS`.
1106pub const MAX_EVIDENCE_ENTRIES: usize = 100;
1107
1108/// Reserved id of the synthetic zero-effort source node in every plan's CPM.
1109pub const START_ID: &str = "__start__";
1110/// Reserved id of the synthetic zero-effort sink node in every plan's CPM.
1111pub const FINISH_ID: &str = "__finish__";
1112
1113/// The stored definition of a plan: the [`PlanGraph`] exactly as submitted,
1114/// returned by [`crate::ports::Planner::get_plan`].
1115#[derive(Debug, Clone, Serialize, Deserialize)]
1116pub struct PlanDefinition {
1117 pub plan_id: PlanId,
1118 pub graph: PlanGraph,
1119}
1120
1121/// One deliverable's CPM schedule, in hours from plan start.
1122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1123pub struct ScheduleRow {
1124 pub id: String,
1125 pub es: f32,
1126 pub ef: f32,
1127 pub ls: f32,
1128 pub lf: f32,
1129 pub float: f32,
1130 pub critical: bool,
1131 /// True for the synthetic [`START_ID`] / [`FINISH_ID`] endpoint rows.
1132 #[serde(default)]
1133 pub synthetic: bool,
1134}
1135
1136/// Errors returned by [`crate::ports::Planner`] methods.
1137///
1138/// Every variant carries enough context to log without further lookup. The
1139/// stable string token at the start of the `#[error(..)]` message doubles
1140/// as the wire-level error code surfaced by PA4's MCP server, so the
1141/// variant prefixes (`LOCK_HELD`, `LOCK_NOT_HELD`, etc.) MUST NOT change
1142/// without bumping the MCP server schema.
1143#[derive(Debug, Error)]
1144pub enum PlannerError {
1145 /// The requested deliverable is already locked by another caller. The
1146 /// `holder` field surfaces the conflicting caller for human triage.
1147 #[error("LOCK_HELD: deliverable {deliverable_id} in plan {plan_id} is locked by {holder}")]
1148 LockHeld {
1149 plan_id: String,
1150 deliverable_id: String,
1151 holder: String,
1152 },
1153
1154 /// The caller invoked an operation that requires holding a lock
1155 /// (`mark_status`, `heartbeat`) but the lock is held by someone else,
1156 /// or no lock exists at all.
1157 #[error("LOCK_NOT_HELD: caller {caller_id} does not hold lock on {deliverable_id}")]
1158 LockNotHeld {
1159 caller_id: String,
1160 deliverable_id: String,
1161 },
1162
1163 /// The lock the caller is referencing has passed its TTL. The Planner
1164 /// is free to reclaim the deliverable for another caller.
1165 #[error("LOCK_EXPIRED: lock on {deliverable_id} expired at {expired_at}")]
1166 LockExpired {
1167 deliverable_id: String,
1168 expired_at: DateTime<Utc>,
1169 },
1170
1171 /// `acquire_cohort` discovered that the candidate deliverable's
1172 /// `owned_files` overlap with files held by an existing lock. Surfaced
1173 /// as a distinct variant (rather than `LOCK_HELD`) because the
1174 /// conflict is at the *file* level, not the deliverable level.
1175 #[error(
1176 "OVERLAP_DETECTED: deliverable {deliverable_id} owns files {files:?} that overlap with \
1177 currently locked files"
1178 )]
1179 OverlapDetected {
1180 deliverable_id: String,
1181 files: Vec<PathBuf>,
1182 },
1183
1184 /// `acquire_cohort` cannot include the candidate because at least one
1185 /// of its prerequisites is not yet [`DeliverableStatus::Complete`].
1186 /// This is not a hard error for the whole call (other cohort members
1187 /// may still be returned); it surfaces when a caller explicitly
1188 /// requests an ineligible deliverable.
1189 #[error(
1190 "MISSING_PREREQUISITE: deliverable {deliverable_id} requires {prereq} which is not \
1191 Complete"
1192 )]
1193 MissingPrerequisite {
1194 deliverable_id: String,
1195 prereq: String,
1196 },
1197
1198 /// The plan id supplied to a lookup or mutation does not correspond to
1199 /// any submitted plan.
1200 #[error("PLAN_NOT_FOUND: {plan_id}")]
1201 PlanNotFound { plan_id: String },
1202
1203 /// The deliverable id supplied to a lookup or mutation does not
1204 /// correspond to any deliverable in the named plan.
1205 #[error("DELIVERABLE_NOT_FOUND: {deliverable_id} in plan {plan_id}")]
1206 DeliverableNotFound {
1207 plan_id: String,
1208 deliverable_id: String,
1209 },
1210
1211 /// A deliverable's lease has lapsed via TTL (no terminal mark — the
1212 /// driving process was killed or timed out) more times than the
1213 /// runaway bound allows. These are ENVIRONMENTAL losses, not
1214 /// implementation failures, so the deliverable is NOT auto-failed;
1215 /// instead `acquire_cohort` skips it and reports it in
1216 /// [`Cohort::blocked`] until an operator intervenes (fix the
1217 /// environment, then `force_release` with `reset_counters`). No longer
1218 /// returned by `acquire_cohort`; kept for wire compatibility.
1219 #[error(
1220 "LAPSE_LIMIT: deliverable {deliverable_id} lost {lapse_count} leases to environmental \
1221 lapses (TTL expiry with no terminal mark — killed/timed-out drivers, NOT implementation \
1222 failures; bound {max_lapses}); fix the environment, then mark_status the deliverable to \
1223 proceed; clear it with plan.force_release {{reset_counters: true}}"
1224 )]
1225 LapseLimit {
1226 deliverable_id: String,
1227 lapse_count: u32,
1228 max_lapses: u32,
1229 },
1230
1231 /// A deliverable cannot be completed without a lease (or accepted)
1232 /// while some of its prerequisites are not yet `Complete`.
1233 #[error(
1234 "PREREQUISITES_INCOMPLETE: {deliverable_id} in plan {plan_id} has incomplete \
1235 prerequisites [{}]",
1236 missing.join(", ")
1237 )]
1238 PrerequisitesIncomplete {
1239 plan_id: String,
1240 deliverable_id: String,
1241 missing: Vec<String>,
1242 },
1243
1244 /// The submitted graph fails a structural invariant: duplicate ids,
1245 /// unknown prerequisite reference, cycle, empty `owned_files`, etc.
1246 /// The `reason` is the precise failure message.
1247 #[error("INVALID_GRAPH: {reason}")]
1248 InvalidGraph { reason: String },
1249
1250 /// A project/plan-file path failed validation: not a valid slug, not of the
1251 /// form `.cpm-planner/plans/<name>/<variant>.json`, absolute, contains `..`,
1252 /// or resolves (e.g. via a symlink) outside the plans directory.
1253 #[error("INVALID_PATH: {reason}")]
1254 InvalidPath { reason: String },
1255
1256 /// `plan.schedule` was given no usable capacity (missing or zero) for
1257 /// one or more resources that scheduled work needs. `missing` is sorted.
1258 #[error("INVALID_CAPACITIES: no capacity for resources [{}]", missing.join(", "))]
1259 InvalidCapacities { missing: Vec<String> },
1260
1261 /// An execution operation (`acquire_cohort`, `heartbeat`, `mark_status`,
1262 /// `accept`, `force_release`) targeted a named plan variant that is not
1263 /// its line's selected variant. Read and analysis tools are never gated.
1264 #[error(
1265 "VARIANT_NOT_SELECTED: plan {plan_id} is variant '{variant}' of '{name}'; selected is \
1266 '{selected}'"
1267 )]
1268 VariantNotSelected {
1269 plan_id: String,
1270 name: String,
1271 variant: String,
1272 selected: String,
1273 },
1274
1275 /// An operation was refused because of archiving: archiving the selected
1276 /// variant alone, syncing into / selecting an archived variant or line,
1277 /// unarchiving a variant of an archived line, or an execution operation
1278 /// on any variant of an archived line.
1279 #[error("ARCHIVE_REFUSED: {reason}")]
1280 ArchiveRefused { reason: String },
1281
1282 /// `mark_status` progress fields failed validation: `earned_pct` above
1283 /// 100 or given with a status other than `in_progress`/`complete`,
1284 /// `actual_effort_hours` not finite or outside `0..=1000000`, or
1285 /// `evidence` longer than [`MAX_EVIDENCE_CHARS`] characters or beyond
1286 /// [`MAX_EVIDENCE_ENTRIES`] entries for the deliverable.
1287 #[error("INVALID_ACTUALS: {reason}")]
1288 InvalidActuals { reason: String },
1289
1290 /// A request carried a value that is well-typed but out of range for
1291 /// the plan it targets, e.g. a `plan.baseline` `backfill_from` in the
1292 /// future or before the plan's creation time.
1293 #[error("INVALID_VALUE: {reason}")]
1294 InvalidValue { reason: String },
1295
1296 /// An earned-value read or snapshot (`plan.ev`, `plan.snapshot`)
1297 /// targeted a plan that has no baseline yet; take one with
1298 /// `plan.baseline` first.
1299 #[error("NOT_BASELINED: plan {plan_id} has no baseline; take one with plan.baseline")]
1300 NotBaselined { plan_id: String },
1301
1302 /// The server runs in `refuse` baseline mode and an execution-starting
1303 /// call targeted a plan with no baseline yet; take one with
1304 /// `plan.baseline` first.
1305 #[error(
1306 "BASELINE_REQUIRED: plan {plan_id} has no earned-value baseline and \
1307 CPM_BASELINE_MODE=refuse; take one with plan.baseline"
1308 )]
1309 BaselineRequired { plan_id: String },
1310
1311 /// Catch-all for backend failures (DB unavailable, serialization
1312 /// errors against the persistence layer, etc.). Wraps the underlying
1313 /// `anyhow::Error` so the caller can introspect via `source()`.
1314 #[error("BACKEND_ERROR: {0}")]
1315 BackendError(#[source] anyhow::Error),
1316}
1317
1318#[cfg(test)]
1319mod tests {
1320 use super::*;
1321
1322 /// Wire-shape smoke test: constructing a `PlanGraph` with one
1323 /// deliverable and round-tripping it through `serde_json` must
1324 /// preserve every field. The MCP server in PA4 depends on this
1325 /// invariant.
1326 #[test]
1327 fn plan_graph_serde_roundtrip() -> Result<(), serde_json::Error> {
1328 let graph = PlanGraph {
1329 deliverables: vec![Deliverable {
1330 id: "d1".to_string(),
1331 owned_files: vec!["src/foo.rs".into(), "src/bar.rs".into()],
1332 prerequisites: vec!["d0".into()],
1333 estimated_effort_hours: Some(1.5),
1334 metadata: serde_json::json!({"description": "smoke test"}),
1335 duration_hours: None,
1336 estimate: None,
1337 milestone: false,
1338 earning_rule: None,
1339 }],
1340 max_chained_dispatch: Some(8),
1341 };
1342
1343 let json = serde_json::to_string(&graph)?;
1344 let back: PlanGraph = serde_json::from_str(&json)?;
1345
1346 assert_eq!(back.deliverables.len(), 1);
1347 let d = &back.deliverables[0];
1348 assert_eq!(d.id, "d1");
1349 assert_eq!(
1350 d.owned_files,
1351 vec![OwnedFile::from("src/foo.rs"), OwnedFile::from("src/bar.rs")]
1352 );
1353 assert_eq!(d.prerequisites, vec![Prerequisite::from("d0")]);
1354 assert_eq!(d.estimated_effort_hours, Some(1.5));
1355 assert_eq!(d.metadata, serde_json::json!({"description": "smoke test"}));
1356 assert_eq!(back.max_chained_dispatch, Some(8));
1357 Ok(())
1358 }
1359
1360 #[test]
1361 fn explicit_default_earning_rule_deserializes_as_absent() {
1362 let d: Deliverable = serde_json::from_value(serde_json::json!({
1363 "id": "a",
1364 "owned_files": [],
1365 "prerequisites": [],
1366 "earning_rule": "zero_hundred"
1367 }))
1368 .unwrap();
1369 assert_eq!(d.earning_rule, None);
1370 }
1371
1372 /// `DeliverableStatus` uses an internally-tagged enum representation
1373 /// so the wire form matches what PA4's MCP server publishes. Pin the
1374 /// shape with an explicit JSON check so an accidental derive change
1375 /// fails loudly.
1376 #[test]
1377 fn deliverable_status_failed_carries_reason() -> Result<(), serde_json::Error> {
1378 let status = DeliverableStatus::Failed {
1379 reason: "tests failed".to_string(),
1380 };
1381 let json = serde_json::to_value(&status)?;
1382 assert_eq!(json["status"], "failed");
1383 assert_eq!(json["reason"], "tests failed");
1384 let back: DeliverableStatus = serde_json::from_value(json)?;
1385 assert_eq!(back, status);
1386 Ok(())
1387 }
1388
1389 /// SPEC §33 audit fixup (F5 INTERFACE_GAP-001) — Cohort tightened
1390 /// from parallel vectors to `Vec<CohortRow>`, but the JSON wire
1391 /// shape MUST stay as `{plan_id, deliverables, locks}` so MCP
1392 /// clients see no breaking change. Pin both directions of the
1393 /// `serde(into/from)` adapter.
1394 #[test]
1395 fn cohort_wire_shape_preserves_two_array_layout() -> Result<(), serde_json::Error> {
1396 let plan_id = PlanId("plan_x".to_string());
1397 let now = chrono::Utc::now();
1398 let cohort = Cohort {
1399 plan_id: plan_id.clone(),
1400 blocked: vec![],
1401 shared_paths: vec![],
1402 rows: vec![
1403 CohortRow {
1404 deliverable: Deliverable {
1405 id: "d1".to_string(),
1406 owned_files: vec!["a.rs".into()],
1407 prerequisites: vec![],
1408 estimated_effort_hours: Some(1.0),
1409 metadata: serde_json::Value::Null,
1410 duration_hours: None,
1411 estimate: None,
1412 milestone: false,
1413 earning_rule: None,
1414 },
1415 lock: LockInfo {
1416 plan_id: plan_id.clone(),
1417 deliverable_id: "d1".to_string(),
1418 caller_id: CallerId("c1".to_string()),
1419 acquired_at: now,
1420 expires_at: now + chrono::Duration::seconds(60),
1421 },
1422 },
1423 CohortRow {
1424 deliverable: Deliverable {
1425 id: "d2".to_string(),
1426 owned_files: vec!["b.rs".into()],
1427 prerequisites: vec![],
1428 estimated_effort_hours: Some(2.0),
1429 metadata: serde_json::Value::Null,
1430 duration_hours: None,
1431 estimate: None,
1432 milestone: false,
1433 earning_rule: None,
1434 },
1435 lock: LockInfo {
1436 plan_id: plan_id.clone(),
1437 deliverable_id: "d2".to_string(),
1438 caller_id: CallerId("c1".to_string()),
1439 acquired_at: now,
1440 expires_at: now + chrono::Duration::seconds(60),
1441 },
1442 },
1443 ],
1444 baseline_taken: None,
1445 };
1446 let json = serde_json::to_value(&cohort)?;
1447 // Wire shape: top-level keys are plan_id + deliverables + locks
1448 // (NOT `rows`). MCP clients depending on the historical shape
1449 // continue to see it.
1450 assert!(json.get("deliverables").is_some());
1451 assert!(json.get("locks").is_some());
1452 assert!(json.get("rows").is_none());
1453 let deliverables = json["deliverables"].as_array().unwrap();
1454 let locks = json["locks"].as_array().unwrap();
1455 assert_eq!(deliverables.len(), 2);
1456 assert_eq!(locks.len(), 2);
1457 // Position-aligned pairing on the wire.
1458 assert_eq!(deliverables[0]["id"], "d1");
1459 assert_eq!(locks[0]["deliverable_id"], "d1");
1460
1461 // Round-trip back into the in-Rust row form.
1462 let back: Cohort = serde_json::from_value(json)?;
1463 assert_eq!(back.rows.len(), 2);
1464 assert_eq!(back.rows[0].deliverable.id, "d1");
1465 assert_eq!(back.rows[0].lock.deliverable_id, "d1");
1466 assert_eq!(back.rows[1].deliverable.id, "d2");
1467 assert_eq!(back.rows[1].lock.deliverable_id, "d2");
1468 Ok(())
1469 }
1470
1471 /// CMP-032 — a wire payload whose `deliverables` and `locks` arrays have
1472 /// mismatched lengths is corrupt (an unpaired lock-grant or deliverable).
1473 /// Deserialization MUST error rather than silently truncate to the shorter
1474 /// array and drop the unpaired entry.
1475 #[test]
1476 fn cohort_rejects_mismatched_deliverables_and_locks_lengths() {
1477 let now = chrono::Utc::now();
1478 // Two deliverables but only one lock — the historical truncating impl
1479 // would have dropped d2 silently.
1480 let wire = serde_json::json!({
1481 "plan_id": "plan_x",
1482 "deliverables": [
1483 {
1484 "id": "d1",
1485 "owned_files": ["a.rs"],
1486 "prerequisites": [],
1487 "estimated_effort_hours": 1.0,
1488 "metadata": null
1489 },
1490 {
1491 "id": "d2",
1492 "owned_files": ["b.rs"],
1493 "prerequisites": [],
1494 "estimated_effort_hours": 2.0,
1495 "metadata": null
1496 }
1497 ],
1498 "locks": [
1499 {
1500 "plan_id": "plan_x",
1501 "deliverable_id": "d1",
1502 "caller_id": "c1",
1503 "acquired_at": now,
1504 "expires_at": now + chrono::Duration::seconds(60)
1505 }
1506 ]
1507 });
1508
1509 let err = serde_json::from_value::<Cohort>(wire).unwrap_err();
1510 assert!(
1511 err.to_string().contains("COHORT_LENGTH_MISMATCH"),
1512 "expected COHORT_LENGTH_MISMATCH, got: {err}"
1513 );
1514 }
1515}