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