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