Skip to main content

cpm_planner/
planner.rs

1//! `BasicCpmPlanner` — open-source [`Planner`] implementation.
2//!
3//! Bridges the wire model in [`crate::plan`] to the internal
4//! CPM kernel in [`crate::algorithm`], enforces the lock-aware semantics
5//! described on [`Planner`], and emits an audit lifecycle for every lock
6//! state transition.
7//!
8//! # Atomicity & persistence
9//!
10//! All state lives in a [`SqlitePlanStore`]. Every mutating method runs
11//! its entire body — load `PlanState`, apply the existing in-memory
12//! scheduling logic, write back — inside ONE `BEGIN IMMEDIATE` SQLite
13//! transaction. That is what makes "acquire N disjoint deliverables
14//! together" a single observable step, and because the write lock is
15//! database-level it holds across OS processes, not just tasks in this
16//! process: an MCP server and an external `orchestrate` CLI pointed at
17//! the same database can never double-acquire.
18//!
19//! Constructors without an explicit store ([`BasicCpmPlanner::new`],
20//! [`BasicCpmPlanner::with_audit`], [`BasicCpmPlanner::with_parts`]) use a
21//! private in-memory database — the historical ephemeral behaviour. Use
22//! [`BasicCpmPlanner::with_store`] with a file-backed
23//! [`SqlitePlanStore`] for durable, cross-process state.
24//!
25//! # Audit emission
26//!
27//! Audit events are buffered into a `Vec<AuditEvent>` while the
28//! transaction is open, then drained to the [`AuditSink`] AFTER commit.
29//! A slow sink therefore never holds up concurrent acquirers.
30
31use std::collections::{HashMap, HashSet};
32use std::path::{Path, PathBuf};
33use std::sync::Arc;
34use std::time::Duration;
35
36use crate::audit::{AuditEvent, AuditSink, NullAuditSink};
37use crate::plan::{
38    AcceptRequest, AcquireRequest, BlockedDeliverable, CallerId, Cohort, CohortRow,
39    ComparePlansRequest, Deliverable, DeliverableStatus, FINISH_ID, FileMode, ForceReleaseRequest,
40    ForkRequest, HeartbeatRequest, LockInfo, MAX_DELIVERABLES, MarkStatusRequest, OwnedFile,
41    PlanDefinition, PlanGraph, PlanId, PlanLineSummary, PlanStatus, PlannerError, ReviseRequest,
42    START_ID, ScheduleRow, SelectOutcome, SyncOutcome, SyncRequest,
43};
44use crate::plan_store::SqlitePlanStore;
45use crate::portfolio::{Archived, Revised, Selected, Synced, VariantInfo};
46use crate::ports::Planner;
47use crate::project::ProjectRoot;
48use crate::revise::RevisionDiff;
49use async_trait::async_trait;
50use chrono::{DateTime, Utc};
51use execution_policy::classify::{FailureClass, RetryDecision};
52use serde_json::json;
53
54use crate::locks::{FileClaim, PlanState, add_file_claims, modes_conflict};
55
56mod ev;
57
58/// Default TTL applied to newly acquired locks. Five minutes is the
59/// open-source default called out in SPEC §33 PA3.
60pub const DEFAULT_TTL: Duration = Duration::from_secs(5 * 60);
61
62/// Default ceiling for a per-call lease TTL (`ttl_seconds`). Eight hours
63/// is generous enough for long-running work while still bounding a
64/// forgotten lease; override with `CPM_MAX_TTL_SECS` on the server or
65/// [`BasicCpmPlanner::with_max_ttl`] as a library.
66pub const DEFAULT_MAX_TTL: Duration = Duration::from_secs(8 * 60 * 60);
67
68/// Hard ceiling for the configurable maximum lease TTL (30 days).
69pub const MAX_TTL_CEILING: Duration = Duration::from_secs(30 * 24 * 60 * 60);
70
71/// Circuit-breaker: maximum number of times a deliverable may be
72/// EXPLICITLY marked failed (via [`Planner::mark_status`] with
73/// `status = Failed`) before the next [`Planner::acquire_cohort`]
74/// auto-fails it instead of re-leasing. Only real implementation
75/// attempts count: a driver got the lease, did the work, and reported
76/// failure. A lease lost environmentally (driver killed, harness
77/// timeout — the lock lapses via TTL with no terminal mark) is tracked
78/// separately as a lapse and NEVER burns a circuit-breaker life; see
79/// [`MAX_LAPSES`] for the runaway bound on those.
80///
81/// This is the retry budget for the DURABLE, cross-process deliverable lease
82/// (distinct from `execution_policy`'s in-process async retry): the decision to
83/// keep leasing vs. circuit-break is expressed through that crate's
84/// [`RetryDecision`] (`lease_retry_decision`), and a broken deliverable is
85/// classified [`FailureClass::Permanent`].
86pub const MAX_ATTEMPTS: u32 = 3;
87
88/// Runaway protection for ENVIRONMENTAL lease lapses (TTL expiry with no
89/// terminal mark). Deliberately generous — a lapse says nothing about the
90/// deliverable's buildability, so it must not trip the failure
91/// circuit-breaker — but an infinitely-crashing environment still cannot
92/// spin forever: once a deliverable's `lapse_count` reaches this bound,
93/// [`Planner::acquire_cohort`] refuses to re-lease it and surfaces
94/// [`PlannerError::LapseLimit`] (stable `LAPSE_LIMIT:` prefix) so an
95/// operator fixes the environment instead of the planner silently
96/// burning leases.
97pub const MAX_LAPSES: u32 = 10;
98
99/// Decide, from a deliverable's durable explicit-`failure_count`, whether to
100/// keep leasing it or circuit-break — expressed in `execution_policy`'s shared
101/// vocabulary. `Retry` while under [`MAX_ATTEMPTS`]; `Stop` (→ auto-fail,
102/// [`FailureClass::Permanent`]) once the budget is spent.
103fn lease_retry_decision(failure_count: u32) -> RetryDecision {
104    if failure_count >= MAX_ATTEMPTS {
105        RetryDecision::Stop
106    } else {
107        RetryDecision::Retry
108    }
109}
110
111/// Legacy flat fallback for missing effort estimates.
112///
113/// As of CMP-016 the planner no longer uses this: when a deliverable omits
114/// `estimated_effort_hours`, `crate::schedule::deliverable_to_task` asks an
115/// [`crate::estimator::EffortEstimator`] for a kind-aware estimate instead of substituting a
116/// flat one hour. The constant is retained as a documented reference value
117/// for callers that want the historical default.
118pub const DEFAULT_EFFORT_HOURS: f32 = 1.0;
119
120/// Pluggable clock. Tests inject a closure backed by a shared instant so
121/// TTL expiry is deterministic without `std::thread::sleep`.
122pub type ClockFn = Arc<dyn Fn() -> DateTime<Utc> + Send + Sync>;
123
124/// Open-source CPM planner with file-aware locking.
125pub struct BasicCpmPlanner {
126    /// Durable state: plans, statuses, locks, and the submit-dedup map.
127    /// Also the atomicity mechanism — see the module docs.
128    store: SqlitePlanStore,
129    audit: Arc<dyn AuditSink>,
130    ttl: Duration,
131    max_ttl: Duration,
132    clock: ClockFn,
133    /// The project whose plan files this planner may read and write (drift
134    /// detection, fork). `None`: file features degrade to inline/unknown.
135    project_root: Option<ProjectRoot>,
136}
137
138impl BasicCpmPlanner {
139    /// Construct a planner with a real-clock and the
140    /// [`DEFAULT_TTL`]. Audit events are dropped on the floor (use
141    /// [`Self::with_audit`] if you need them retained).
142    pub fn new() -> Self {
143        Self::with_audit(Arc::new(NullAuditSink))
144    }
145
146    /// Construct a planner with the supplied audit sink and the default
147    /// TTL. The real `Utc::now` is used as the clock.
148    pub fn with_audit(audit: Arc<dyn AuditSink>) -> Self {
149        Self::with_parts(audit, DEFAULT_TTL, Arc::new(Utc::now))
150    }
151
152    /// Simulate a stored plan's graph read-only: nothing is written and no
153    /// audit events are emitted. See [`crate::simulate::simulate`].
154    pub async fn simulate_plan(
155        &self,
156        plan_id: &PlanId,
157        req: &crate::simulate::SimulateRequest,
158    ) -> Result<crate::simulate::SimulationResult, PlannerError> {
159        let graph = self.get_plan(plan_id).await?.graph;
160        crate::simulate::simulate(&graph, req)
161    }
162
163    /// Override the lock TTL. Useful for short-lived integration tests.
164    pub fn with_ttl(mut self, ttl: Duration) -> Self {
165        self.ttl = ttl;
166        self
167    }
168
169    /// Override the maximum lease TTL a caller may request via
170    /// `ttl_seconds`. Requested values above this are clamped down to it.
171    /// The value is itself capped at [`MAX_TTL_CEILING`] (30 days).
172    pub fn with_max_ttl(mut self, max_ttl: Duration) -> Self {
173        self.max_ttl = max_ttl.min(MAX_TTL_CEILING);
174        self
175    }
176
177    /// Override the clock. Intended for deterministic TTL tests; production
178    /// code should not call this.
179    pub fn with_clock(mut self, clock: ClockFn) -> Self {
180        self.clock = clock;
181        self
182    }
183
184    /// Full-parts constructor with an ephemeral in-memory store. Public
185    /// for clients that want explicit control over every field at once.
186    pub fn with_parts(audit: Arc<dyn AuditSink>, ttl: Duration, clock: ClockFn) -> Self {
187        let store = SqlitePlanStore::open_in_memory()
188            .expect("INVARIANT: opening a private in-memory sqlite database cannot fail");
189        Self::with_store_parts(store, audit, ttl, clock)
190    }
191
192    /// Construct a planner backed by the supplied (typically file-backed)
193    /// store, with the default TTL and real clock. This is the durable,
194    /// cross-process configuration used by the MCP server binary.
195    pub fn with_store(store: SqlitePlanStore, audit: Arc<dyn AuditSink>) -> Self {
196        Self::with_store_parts(store, audit, DEFAULT_TTL, Arc::new(Utc::now))
197    }
198
199    /// Full-parts constructor over an explicit store.
200    pub fn with_store_parts(
201        store: SqlitePlanStore,
202        audit: Arc<dyn AuditSink>,
203        ttl: Duration,
204        clock: ClockFn,
205    ) -> Self {
206        Self {
207            store,
208            audit,
209            ttl,
210            max_ttl: DEFAULT_MAX_TTL,
211            clock,
212            project_root: None,
213        }
214    }
215
216    /// Give the planner a project root. [`Planner::status`] then reports
217    /// `definition_drift` for that project's file-backed variants, and
218    /// [`Planner::fork_plan`] writes the new variant's plan file there when
219    /// the request names no root of its own.
220    pub fn with_project_root(mut self, root: ProjectRoot) -> Self {
221        self.project_root = Some(root);
222        self
223    }
224
225    /// The backing store, for in-crate tests that inspect tables directly.
226    #[cfg(test)]
227    pub(crate) fn store(&self) -> &SqlitePlanStore {
228        &self.store
229    }
230
231    /// The project root given with [`Self::with_project_root`], if any. The
232    /// MCP server reads its root from here (one source of truth).
233    pub fn project_root(&self) -> Option<&ProjectRoot> {
234        self.project_root.as_ref()
235    }
236
237    /// The `(plan id, label, head graph)` inputs of a comparison, read in one
238    /// store snapshot: `plan_ids` in the given order (an unnamed plan is
239    /// labelled by its id) or every non-archived variant of the line `plan`
240    /// (sorted by variant). Both or neither, duplicate ids, or a count
241    /// outside `2..=`[`crate::compare::MAX_COMPARE_VARIANTS`] is
242    /// `INVALID_GRAPH`; an unknown plan or line is `PLAN_NOT_FOUND`. Pair it
243    /// with the pure, CPU-bound [`crate::compare::compare`].
244    pub fn compare_inputs(
245        &self,
246        req: &ComparePlansRequest,
247    ) -> Result<Vec<(PlanId, String, PlanGraph)>, PlannerError> {
248        if let Some(ids) = &req.plan_ids {
249            let distinct: HashSet<&str> = ids.iter().map(|id| id.0.as_str()).collect();
250            if distinct.len() != ids.len() {
251                return Err(PlannerError::InvalidGraph {
252                    reason: "plan_ids must be distinct".to_string(),
253                });
254            }
255            crate::compare::check_variant_count(ids.len())?;
256        }
257        self.store.read_tx(|tx| {
258            let targets: Vec<(PlanId, Option<String>)> = match (&req.plan_ids, &req.plan) {
259                (Some(ids), None) => ids.iter().map(|id| (id.clone(), None)).collect(),
260                (None, Some((project, name))) => {
261                    crate::portfolio::live_variants(tx, project, name)?
262                        .into_iter()
263                        .map(|(variant, id)| (id, Some(variant)))
264                        .collect()
265                }
266                _ => {
267                    return Err(PlannerError::InvalidGraph {
268                        reason: "compare takes exactly one of plan_ids or plan".to_string(),
269                    });
270                }
271            };
272            crate::compare::check_variant_count(targets.len())?;
273            targets
274                .into_iter()
275                .map(|(id, label)| {
276                    let (_, graph) = crate::portfolio::revision_graph(tx, &id, None)?;
277                    let label = match label {
278                        Some(variant) => variant,
279                        None => crate::portfolio::variant_info(tx, &id)?
280                            .map_or_else(|| id.0.clone(), |info| info.variant),
281                    };
282                    Ok((id, label, graph))
283                })
284                .collect::<Result<Vec<_>, PlannerError>>()
285        })
286    }
287
288    /// Refuse an export over `file` that would clobber work (see
289    /// [`Planner::export_plan`]): another variant's tracked plan file, or
290    /// this variant's own file while it holds local edits that were never
291    /// synced.
292    fn check_export_target(
293        &self,
294        plan_id: &PlanId,
295        root: &ProjectRoot,
296        file: &crate::project::PlanFileRef,
297    ) -> Result<(), PlannerError> {
298        let project = root.project_key();
299        let trackers = self.store.read_tx(|tx| {
300            crate::portfolio::variants_tracking(tx, &project, &file.rel_path)?
301                .into_iter()
302                .map(|id| {
303                    let info = crate::portfolio::variant_info(tx, &id)?;
304                    let (_, head) = crate::portfolio::revision_graph(tx, &id, None)?;
305                    let recorded = crate::portfolio::revision_content_hashes(tx, &id)?;
306                    Ok((id, info, hash_graph(&head), recorded))
307                })
308                .collect::<Result<Vec<_>, PlannerError>>()
309        })?;
310        for (id, info, head_hash, mut recorded) in trackers {
311            let Some(info) = info else { continue };
312            if id != *plan_id {
313                return Err(PlannerError::InvalidPath {
314                    reason: format!(
315                        "{} is the tracked plan file of variant '{}' of '{}'; pass force to \
316                         overwrite it",
317                        file.rel_path, info.variant, info.name
318                    ),
319                });
320            }
321            if definition_drift(Some(root), &info, &head_hash) != Some(true) {
322                continue;
323            }
324            // Drifted: refuse only if the file holds content no revision of
325            // this variant recorded (local edits). A file that merely lags
326            // the head (e.g. after an inline revise) is safe to re-export.
327            recorded.extend(info.content_hash.clone());
328            recorded.insert(head_hash);
329            if has_local_edits(root, file, &recorded) {
330                return Err(PlannerError::InvalidPath {
331                    reason: format!(
332                        "{} has local edits that were never synced (definition drift); sync it \
333                         or pass force to overwrite it",
334                        file.rel_path
335                    ),
336                });
337            }
338        }
339        Ok(())
340    }
341
342    fn now(&self) -> DateTime<Utc> {
343        (self.clock)()
344    }
345
346    /// Effective lease TTL for one call: the caller-requested value (or
347    /// the planner default) clamped to the configured maximum.
348    fn effective_ttl(&self, requested: Option<Duration>) -> Duration {
349        requested.unwrap_or(self.ttl).min(self.max_ttl)
350    }
351
352    /// Record one audit event emitted outside the planner's own operations
353    /// (e.g. `plan.review`). Sink failures are logged, never returned.
354    pub async fn record_audit(&self, event: AuditEvent) {
355        self.flush_audit(vec![event]).await;
356    }
357
358    /// Flush buffered audit events. Called after the transaction commits so a
359    /// slow sink never blocks concurrent planner callers.
360    async fn flush_audit(&self, events: Vec<AuditEvent>) {
361        for ev in events {
362            // Audit failures are intentionally swallowed at this layer:
363            // the planner's invariant is "lock state stays consistent
364            // even if observability fails". A `tracing::warn!` documents
365            // the loss without aborting the caller's operation.
366            if let Err(err) = self.audit.record(ev).await {
367                tracing::warn!(error = %err, "audit sink failed to record planner event");
368            }
369        }
370    }
371}
372
373impl Default for BasicCpmPlanner {
374    fn default() -> Self {
375        Self::new()
376    }
377}
378
379/// Drift of a variant's plan file against its head graph (`head_hash`, the
380/// [`hash_graph`] of the head read in the same snapshot as `info`).
381///
382/// `None` (unknown) unless there is a root for the variant's project, the
383/// variant has a source path, and that file is readable within
384/// [`crate::project::MAX_PLAN_FILE_BYTES`]. Otherwise: `Some(false)` when the
385/// file's bytes hash to the stored content hash; else the file is parsed and
386/// `Some(file graph != head graph)` (canonically, so re-formatting is not
387/// drift); an unparseable file is `Some(true)`.
388fn definition_drift(
389    root: Option<&ProjectRoot>,
390    info: &VariantInfo,
391    head_hash: &str,
392) -> Option<bool> {
393    let root = root?;
394    if root.project_key() != info.project {
395        return None;
396    }
397    let rel = info.source_path.as_deref()?;
398    let file = root.resolve_plan_file(rel).ok()?;
399    let bytes = root.read_bytes(&file).ok()?;
400    if info.content_hash.as_deref() == Some(crate::project::content_hash(&bytes).as_str()) {
401        return Some(false);
402    }
403    match serde_json::from_slice::<PlanGraph>(&bytes) {
404        Ok(graph) => Some(hash_graph(&graph) != head_hash),
405        Err(_) => Some(true),
406    }
407}
408
409/// True when `file` exists and its content matches none of `recorded` (by
410/// byte hash or canonical graph hash). An unreadable file counts as none.
411fn has_local_edits(
412    root: &ProjectRoot,
413    file: &crate::project::PlanFileRef,
414    recorded: &HashSet<String>,
415) -> bool {
416    let Ok(bytes) = root.read_bytes(file) else {
417        return false;
418    };
419    if recorded.contains(&crate::project::content_hash(&bytes)) {
420        return false;
421    }
422    match serde_json::from_slice::<PlanGraph>(&bytes) {
423        Ok(graph) => !recorded.contains(&hash_graph(&graph)),
424        Err(_) => true,
425    }
426}
427
428// ---------------------------------------------------------------------------
429// Graph validation + hashing
430// ---------------------------------------------------------------------------
431
432/// Canonical, order-independent JSON form of one deliverable: prerequisites
433/// and owned files sorted so declaration order never matters. Shared by
434/// [`hash_graph`], plan revision's "did the definition change" test, and
435/// variant comparison diffs.
436pub(crate) fn canonical_deliverable(d: &Deliverable) -> serde_json::Value {
437    let mut prereqs: Vec<serde_json::Value> = d
438        .prerequisites
439        .iter()
440        .map(|p| {
441            json!({
442                "id": p.id(),
443                "consumes": p.consumes(),
444                "kind": p.kind(),
445                "lag_hours": p.lag_hours() + 0.0,
446            })
447        })
448        .collect();
449    // Full-key order (serialised form) so duplicate-id edges hash
450    // independently of submission order.
451    prereqs.sort_by_cached_key(ToString::to_string);
452    let mut files: Vec<serde_json::Value> = d
453        .owned_files
454        .iter()
455        .map(|f| json!({ "path": f.path().to_string_lossy(), "mode": f.mode() }))
456        .collect();
457    files.sort_by_cached_key(ToString::to_string);
458    let mut canonical = json!({
459        "id": d.id,
460        "owned_files": files,
461        "prerequisites": prereqs,
462        "estimated_effort_hours": d.estimated_effort_hours,
463        "duration_hours": d.duration_hours,
464        "estimate": d.estimate,
465        "metadata": d.metadata,
466        "milestone": d.milestone,
467    });
468    // Only when set to a non-default rule, so graphs without one keep the
469    // hash they had before the field existed (dedup and inline sync stay
470    // stable) and an explicit `zero_hundred` hashes like an absent rule.
471    if let Some(rule) = d
472        .earning_rule
473        .filter(|r| *r != crate::plan::EarningRule::ZeroHundred)
474    {
475        canonical["earning_rule"] = json!(rule);
476    }
477    canonical
478}
479
480/// Deterministic content hash of a [`PlanGraph`]. Same logical graph -> same
481/// hash regardless of the order `deliverables` were submitted in. This is
482/// what lets `submit_plan` be idempotent, and what tells `sync_plan` whether
483/// a variant's graph changed.
484pub(crate) fn hash_graph(graph: &PlanGraph) -> String {
485    // Build a normalised JSON form: deliverables sorted by id; each
486    // deliverable's prerequisites + owned_files sorted; metadata kept
487    // as-is (callers are responsible for its determinism).
488    let mut deliverables: Vec<_> = graph
489        .deliverables
490        .iter()
491        .map(canonical_deliverable)
492        .collect();
493    deliverables.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str()));
494
495    let payload = json!({
496        "deliverables": deliverables,
497        "max_chained_dispatch": graph.max_chained_dispatch,
498    });
499
500    // Invariant: `payload` was built from a JSON object literal whose
501    // leaves are all owned `String`, primitive, or already-validated
502    // `serde_json::Value` payloads. Serialisation cannot fail for these
503    // inputs; the `expect` documents the invariant and aborts loudly if
504    // a future refactor breaks it.
505    let serialised = serde_json::to_vec(&payload)
506        .expect("INVARIANT: plan-graph hash payload is JSON-serialisable");
507    crate::skills::sha256_hex(&serialised)
508}
509
510/// Reject graphs that fail any structural invariant. Returns
511/// [`PlannerError::InvalidGraph`] with a precise `reason` on first failure.
512/// Run by submit and by every analysis entry point (`resource_schedule`,
513/// `monte_carlo`, `simulate`).
514pub(crate) fn validate_graph(graph: &PlanGraph) -> Result<(), PlannerError> {
515    if graph.deliverables.len() > MAX_DELIVERABLES {
516        return Err(PlannerError::InvalidGraph {
517            reason: format!(
518                "plan has {} deliverables; maximum is {MAX_DELIVERABLES}",
519                graph.deliverables.len()
520            ),
521        });
522    }
523    for d in &graph.deliverables {
524        if d.id == START_ID || d.id == FINISH_ID {
525            return Err(PlannerError::InvalidGraph {
526                reason: format!("deliverable id '{}' is reserved", d.id),
527            });
528        }
529    }
530
531    // Duplicate ids.
532    let mut seen_ids: HashSet<&str> = HashSet::new();
533    for d in &graph.deliverables {
534        if !seen_ids.insert(d.id.as_str()) {
535            return Err(PlannerError::InvalidGraph {
536                reason: format!("duplicate deliverable id '{}'", d.id),
537            });
538        }
539    }
540
541    // Effort, duration, estimate and lag hours: finite, within
542    // 0..=MAX_HOURS, and estimates ordered. Same messages as `plan.lint`.
543    if let Some(problem) = crate::graph::value_problems(graph).into_iter().next() {
544        return Err(PlannerError::InvalidGraph {
545            reason: problem.message,
546        });
547    }
548
549    // Prerequisite references resolve.
550    let id_set: HashSet<&str> = graph.deliverables.iter().map(|d| d.id.as_str()).collect();
551    for d in &graph.deliverables {
552        for p in crate::graph::prerequisite_ids(d) {
553            if !id_set.contains(p) {
554                return Err(PlannerError::InvalidGraph {
555                    reason: format!(
556                        "prerequisite '{p}' for deliverable '{}' does not exist",
557                        d.id
558                    ),
559                });
560            }
561        }
562    }
563
564    // Shared files: any pair of claimants where at least one claim is
565    // exclusive must be ordered by prerequisites, so one can never run
566    // while the other holds the file. Append/append needs no ordering.
567    let mut reach: Option<HashMap<String, HashSet<String>>> = None;
568    let mut claimants: HashMap<&Path, Vec<(&str, FileMode)>> = HashMap::new();
569    let mut path_order: Vec<&Path> = Vec::new();
570    for d in &graph.deliverables {
571        for f in &d.owned_files {
572            let entry = claimants.entry(f.path()).or_insert_with(|| {
573                path_order.push(f.path());
574                Vec::new()
575            });
576            entry.push((d.id.as_str(), f.mode()));
577        }
578    }
579    for path in path_order {
580        let list = &claimants[path];
581        for (i, &(x, xm)) in list.iter().enumerate() {
582            for &(y, ym) in &list[i + 1..] {
583                if x == y || (xm == FileMode::Append && ym == FileMode::Append) {
584                    continue;
585                }
586                let reach = reach.get_or_insert_with(|| crate::graph::reachability(graph));
587                let ordered = reach.get(x).is_some_and(|r| r.contains(y))
588                    || reach.get(y).is_some_and(|r| r.contains(x));
589                if !ordered {
590                    return Err(PlannerError::InvalidGraph {
591                        reason: format!(
592                            "file '{}' is claimed by '{x}' and '{y}', which are not ordered by prerequisites (one could run while the other holds it)",
593                            path.display(),
594                        ),
595                    });
596                }
597            }
598        }
599    }
600
601    // Cycle detection via Kahn's algorithm on the prerequisite DAG.
602    let mut indeg: HashMap<&str, usize> = HashMap::new();
603    let mut succs: HashMap<&str, Vec<&str>> = HashMap::new();
604    for d in &graph.deliverables {
605        indeg.entry(d.id.as_str()).or_insert(0);
606        succs.entry(d.id.as_str()).or_default();
607    }
608    for d in &graph.deliverables {
609        for p in crate::graph::prerequisite_ids(d) {
610            *indeg.entry(d.id.as_str()).or_insert(0) += 1;
611            succs.entry(p).or_default().push(d.id.as_str());
612        }
613    }
614    let mut queue: Vec<&str> = indeg
615        .iter()
616        .filter_map(|(k, v)| if *v == 0 { Some(*k) } else { None })
617        .collect();
618    let mut popped = 0_usize;
619    while let Some(node) = queue.pop() {
620        popped += 1;
621        if let Some(s) = succs.get(node).cloned() {
622            for next in s {
623                if let Some(deg) = indeg.get_mut(next) {
624                    *deg -= 1;
625                    if *deg == 0 {
626                        queue.push(next);
627                    }
628                }
629            }
630        }
631    }
632    if popped < graph.deliverables.len() {
633        // SPEC §33 audit fixup (F6 STUB-007) — name the cycle members.
634        // After Kahn's terminates, any node with residual in-degree > 0
635        // is part of (or downstream of) at least one cycle. Listing
636        // them sorted gives operators a starting set to debug from
637        // instead of "somewhere in your 50-deliverable graph there's
638        // a cycle, good luck."
639        let mut cycle_members: Vec<&str> = indeg
640            .iter()
641            .filter_map(|(k, v)| if *v > 0 { Some(*k) } else { None })
642            .collect();
643        cycle_members.sort_unstable();
644        return Err(PlannerError::InvalidGraph {
645            reason: format!(
646                "cycle detected in prerequisite graph involving deliverables: [{}]",
647                cycle_members.join(", ")
648            ),
649        });
650    }
651
652    Ok(())
653}
654
655/// Build a new plan from a validated graph: CPM result, a fresh id, and
656/// initial statuses (no prerequisites -> Ready, else Pending).
657fn initial_plan(graph: PlanGraph) -> Result<(PlanId, PlanState), PlannerError> {
658    let cached_result = crate::schedule::compute_cpm(&graph)?;
659    let mut statuses: HashMap<String, DeliverableStatus> =
660        HashMap::with_capacity(graph.deliverables.len());
661    for d in &graph.deliverables {
662        let status = if d.prerequisites.is_empty() {
663            DeliverableStatus::Ready
664        } else {
665            DeliverableStatus::Pending
666        };
667        statuses.insert(d.id.clone(), status);
668    }
669    let plan_id = PlanId(format!("plan_{}", uuid::Uuid::new_v4().simple()));
670    Ok((plan_id, PlanState::new(graph, statuses, cached_result)))
671}
672
673// ---------------------------------------------------------------------------
674// Audit helpers
675// ---------------------------------------------------------------------------
676
677fn make_portfolio_created_event(
678    outcome: &SyncOutcome,
679    project: &str,
680    selected: bool,
681) -> AuditEvent {
682    AuditEvent::new("plan.portfolio.created").with_payload(json!({
683        "plan_id": outcome.plan_id.as_str(),
684        "project": project,
685        "name": outcome.name,
686        "variant": outcome.variant,
687        "revision": outcome.revision,
688        "selected": selected,
689    }))
690}
691
692fn make_portfolio_revised_event(
693    plan_id: &PlanId,
694    revision: u32,
695    diff: &RevisionDiff,
696) -> AuditEvent {
697    AuditEvent::new("plan.portfolio.revised").with_payload(json!({
698        "plan_id": plan_id.as_str(),
699        "revision": revision,
700        "diff": diff,
701    }))
702}
703
704/// Audit trail of a committed revision: reaped (expired) locks, locks the
705/// forced revision released, then the revision itself.
706fn revision_events(plan_id: &PlanId, revised: &Revised, now: DateTime<Utc>) -> Vec<AuditEvent> {
707    if revised.no_op {
708        return Vec::new();
709    }
710    let mut events: Vec<AuditEvent> = revised
711        .reaped
712        .iter()
713        .map(|lock| make_expired_event(lock, now))
714        .collect();
715    events.extend(
716        revised
717            .released
718            .iter()
719            .map(|lock| make_released_event(lock, "revision")),
720    );
721    events.push(make_portfolio_revised_event(
722        plan_id,
723        revised.revision,
724        &revised.diff,
725    ));
726    if let Some(count) = revised.completed {
727        events.push(make_plan_completed_event(plan_id, count));
728    }
729    events
730}
731
732fn make_portfolio_selected_event(outcome: &SelectOutcome) -> AuditEvent {
733    AuditEvent::new("plan.portfolio.selected").with_payload(json!({
734        "plan_id": outcome.plan_id.as_str(),
735        "project": outcome.project,
736        "name": outcome.name,
737        "from": outcome.previous,
738        "to": outcome.variant,
739        "carried": outcome.carried,
740        "released": outcome.released_locks,
741    }))
742}
743
744/// Audit trail of a committed selection: reaped (expired) locks, locks the
745/// forced selection released, the selection itself, then `plan.completed`
746/// when the carry-over completed the newly selected plan. A no-op selection
747/// is not audited.
748fn selection_events(selected: &Selected, now: DateTime<Utc>) -> Vec<AuditEvent> {
749    if !selected.outcome.changed {
750        return Vec::new();
751    }
752    let mut events: Vec<AuditEvent> = selected
753        .reaped
754        .iter()
755        .map(|lock| make_expired_event(lock, now))
756        .collect();
757    events.extend(
758        selected
759            .released
760            .iter()
761            .map(|lock| make_released_event(lock, "variant deselected")),
762    );
763    events.push(make_portfolio_selected_event(&selected.outcome));
764    if let Some(count) = selected.completed {
765        events.push(make_plan_completed_event(&selected.outcome.plan_id, count));
766    }
767    events
768}
769
770/// Audit trail of a committed archive (`archived`) or unarchive: reaped
771/// (expired) locks, locks a forced line archive released, then
772/// `plan.portfolio.archived` / `plan.portfolio.unarchived`. Nothing when no
773/// flag changed.
774fn archive_events(
775    project: &str,
776    name: &str,
777    variant: Option<&str>,
778    archived: bool,
779    outcome: &Archived,
780    now: DateTime<Utc>,
781) -> Vec<AuditEvent> {
782    if !outcome.changed() {
783        return Vec::new();
784    }
785    let mut events: Vec<AuditEvent> = outcome
786        .reaped
787        .iter()
788        .map(|lock| make_expired_event(lock, now))
789        .collect();
790    events.extend(
791        outcome
792            .released
793            .iter()
794            .map(|lock| make_released_event(lock, "line archived")),
795    );
796    let variants: Vec<serde_json::Value> = outcome
797        .variants
798        .iter()
799        .map(|(v, plan_id)| json!({ "variant": v, "plan_id": plan_id.as_str() }))
800        .collect();
801    let event_type = if archived {
802        "plan.portfolio.archived"
803    } else {
804        "plan.portfolio.unarchived"
805    };
806    events.push(AuditEvent::new(event_type).with_payload(json!({
807        "project": project,
808        "name": name,
809        "variant": variant,
810        "line": outcome.line,
811        "variants": variants,
812        "released": outcome
813            .released
814            .iter()
815            .map(|l| l.deliverable_id.as_str())
816            .collect::<Vec<_>>(),
817    })));
818    events
819}
820
821/// The [`SyncOutcome`] of a committed sync and its audit trail.
822fn sync_outcome(
823    synced: Synced,
824    project: &str,
825    name: String,
826    variant: String,
827    now: DateTime<Utc>,
828) -> (SyncOutcome, Vec<AuditEvent>) {
829    let outcome = |plan_id, revision, created, changed, diff| SyncOutcome {
830        plan_id,
831        name: name.clone(),
832        variant: variant.clone(),
833        revision,
834        created,
835        changed,
836        diff,
837    };
838    match synced {
839        Synced::Created { plan_id, selected } => {
840            let out = outcome(plan_id, 1, true, true, None);
841            let event = make_portfolio_created_event(&out, project, selected);
842            (out, vec![event])
843        }
844        Synced::Unchanged { plan_id, revision } => {
845            (outcome(plan_id, revision, false, false, None), Vec::new())
846        }
847        Synced::Revised { plan_id, revised } => {
848            let events = revision_events(&plan_id, &revised, now);
849            let out = outcome(plan_id, revised.revision, false, true, Some(revised.diff));
850            (out, events)
851        }
852    }
853}
854
855/// Longest `project` key accepted by `sync_plan` (a path-derived key, not a
856/// slug).
857const MAX_PROJECT_LEN: usize = 512;
858
859/// Characters refused in a project key: controls (newlines included) and
860/// invisible Unicode format/separator characters that can disguise how the
861/// key reads when echoed (zero-width, bidi embeddings/overrides/isolates,
862/// word joiners, BOM, line/paragraph separators, interlinear annotations).
863fn is_disallowed_project_char(c: char) -> bool {
864    c.is_control()
865        || matches!(
866            c,
867            '\u{200B}'..='\u{200F}'
868                | '\u{202A}'..='\u{202E}'
869                | '\u{2060}'..='\u{206F}'
870                | '\u{FEFF}'
871                | '\u{2028}'
872                | '\u{2029}'
873                | '\u{FFF9}'..='\u{FFFB}'
874        )
875}
876
877/// Reject a malformed `(project, name, variant)` with `INVALID_PATH`. The
878/// project key is echoed by tools and audit events, so control and
879/// invisible format characters are refused.
880fn validate_variant_key(req: &SyncRequest) -> Result<(), PlannerError> {
881    if req.project.is_empty() || req.project.chars().count() > MAX_PROJECT_LEN {
882        return Err(PlannerError::InvalidPath {
883            reason: format!("project must be 1..={MAX_PROJECT_LEN} characters"),
884        });
885    }
886    if req.project.chars().any(is_disallowed_project_char) {
887        return Err(PlannerError::InvalidPath {
888            reason: "project must not contain control or invisible format characters".to_string(),
889        });
890    }
891    crate::project::validate_slug("name", &req.name)?;
892    crate::project::validate_slug("variant", &req.variant)
893}
894
895fn make_acquired_event(lock: &LockInfo, owned_files: &[OwnedFile]) -> AuditEvent {
896    AuditEvent::new("plan.lock.acquired")
897        .with_actor(lock.caller_id.as_str())
898        .with_payload(json!({
899            "plan_id": lock.plan_id.as_str(),
900            "deliverable_id": lock.deliverable_id,
901            "caller_id": lock.caller_id.as_str(),
902            "acquired_at": lock.acquired_at,
903            "expires_at": lock.expires_at,
904            "owned_files": owned_files,
905        }))
906}
907
908fn make_released_event(lock: &LockInfo, reason: &str) -> AuditEvent {
909    AuditEvent::new("plan.lock.released")
910        .with_actor(lock.caller_id.as_str())
911        .with_payload(json!({
912            "plan_id": lock.plan_id.as_str(),
913            "deliverable_id": lock.deliverable_id,
914            "caller_id": lock.caller_id.as_str(),
915            "reason": reason,
916        }))
917}
918
919fn make_expired_event(lock: &LockInfo, expired_at: DateTime<Utc>) -> AuditEvent {
920    AuditEvent::new("plan.lock.expired")
921        .with_actor(lock.caller_id.as_str())
922        .with_payload(json!({
923            "plan_id": lock.plan_id.as_str(),
924            "deliverable_id": lock.deliverable_id,
925            "last_caller_id": lock.caller_id.as_str(),
926            "expired_at": expired_at,
927        }))
928}
929
930fn make_circuit_break_event(
931    plan_id: &PlanId,
932    deliverable_id: &str,
933    failure_count: u32,
934    class: FailureClass,
935    reason: &str,
936) -> AuditEvent {
937    AuditEvent::new("plan.deliverable.circuit_broken").with_payload(json!({
938        "plan_id": plan_id.as_str(),
939        "deliverable_id": deliverable_id,
940        "failure_count": failure_count,
941        "max_attempts": MAX_ATTEMPTS,
942        "failure_class": format!("{class:?}"),
943        "retry_decision": format!("{:?}", RetryDecision::Stop),
944        "reason": reason,
945    }))
946}
947
948/// Validate `mark_status` progress fields (`INVALID_ACTUALS`). Takes wide
949/// types (any JSON number) so the server can check raw wire values with the
950/// same messages: a negative, fractional or > 100 `earned_pct` is refused.
951pub(crate) fn validate_actuals(
952    status: &DeliverableStatus,
953    earned_pct: Option<f64>,
954    actual_effort_hours: Option<f64>,
955    evidence: Option<&str>,
956) -> Result<(), PlannerError> {
957    let bad = |reason: String| Err(PlannerError::InvalidActuals { reason });
958    if let Some(pct) = earned_pct {
959        if !(pct.fract() == 0.0 && (0.0..=100.0).contains(&pct)) {
960            return bad(format!("earned_pct must be an integer 0..=100, got {pct}"));
961        }
962        if !matches!(
963            status,
964            DeliverableStatus::InProgress | DeliverableStatus::Complete
965        ) {
966            return bad(
967                "earned_pct is only accepted with status in_progress (or complete, where it is \
968                 ignored)"
969                    .to_string(),
970            );
971        }
972    }
973    if let Some(h) = actual_effort_hours
974        && !(h.is_finite() && (0.0..=f64::from(crate::plan::MAX_HOURS)).contains(&h))
975    {
976        return bad(format!(
977            "actual_effort_hours must be a finite number between 0 and 1000000, got {h}"
978        ));
979    }
980    if let Some(e) = evidence {
981        let chars = e.chars().count();
982        if chars > crate::plan::MAX_EVIDENCE_CHARS {
983            return bad(format!(
984                "evidence must be at most {} characters, got {chars}",
985                crate::plan::MAX_EVIDENCE_CHARS
986            ));
987        }
988    }
989    Ok(())
990}
991
992/// Missing (non-Complete) prerequisites of `deliverable_id`.
993fn incomplete_prerequisites(state: &PlanState, deliverable_id: &str) -> Vec<String> {
994    state
995        .graph
996        .deliverables
997        .iter()
998        .find(|d| d.id == deliverable_id)
999        .map(|d| {
1000            crate::graph::prerequisite_ids(d)
1001                .filter(|p| !matches!(state.statuses.get(*p), Some(DeliverableStatus::Complete)))
1002                .map(str::to_string)
1003                .collect()
1004        })
1005        .unwrap_or_default()
1006}
1007
1008/// Mark a deliverable `Complete`: end any lease at `now` (dropping its file
1009/// claims, crediting its hours, emitting a released event), set the status, and promote dependents whose
1010/// prerequisites are now all complete. Shared by `mark_status` and `accept`.
1011///
1012/// Returns true when this completion made every deliverable `Complete`
1013/// (the false -> true flip of `plan_complete`), so the caller can emit
1014/// `plan.completed` exactly once.
1015fn complete_deliverable(
1016    state: &mut PlanState,
1017    deliverable_id: &str,
1018    audit_buf: &mut Vec<AuditEvent>,
1019    release_reason: &str,
1020    now: DateTime<Utc>,
1021) -> bool {
1022    let was_complete = all_complete(state);
1023    if let Some(lock) = state.end_lease(deliverable_id, now) {
1024        audit_buf.push(make_released_event(&lock, release_reason));
1025    }
1026
1027    state
1028        .statuses
1029        .insert(deliverable_id.to_string(), DeliverableStatus::Complete);
1030
1031    let dependents: Vec<String> = state
1032        .graph
1033        .deliverables
1034        .iter()
1035        .filter(|d| crate::graph::prerequisite_ids(d).any(|p| p == deliverable_id))
1036        .map(|d| d.id.clone())
1037        .collect();
1038    for dep_id in dependents {
1039        let dep = match state.graph.deliverables.iter().find(|d| d.id == dep_id) {
1040            Some(d) => d,
1041            None => unreachable!("dependent id {dep_id} present in graph but not findable"),
1042        };
1043        let all_done = crate::graph::prerequisite_ids(dep)
1044            .all(|p| matches!(state.statuses.get(p), Some(DeliverableStatus::Complete)));
1045        let currently_pending = matches!(
1046            state.statuses.get(&dep_id),
1047            Some(DeliverableStatus::Pending)
1048        );
1049        if all_done && currently_pending {
1050            state.statuses.insert(dep_id, DeliverableStatus::Ready);
1051        }
1052    }
1053    !was_complete && all_complete(state)
1054}
1055
1056/// True when every deliverable in the plan is `Complete`.
1057pub(crate) fn all_complete(state: &PlanState) -> bool {
1058    state
1059        .graph
1060        .deliverables
1061        .iter()
1062        .all(|d| matches!(state.statuses.get(&d.id), Some(DeliverableStatus::Complete)))
1063}
1064
1065fn make_plan_completed_event(plan_id: &PlanId, deliverable_count: usize) -> AuditEvent {
1066    AuditEvent::new("plan.completed").with_payload(json!({
1067        "plan_id": plan_id.as_str(),
1068        "deliverable_count": deliverable_count,
1069    }))
1070}
1071
1072fn make_accepted_event(
1073    req: &AcceptRequest,
1074    overrode_lock_of: Option<&str>,
1075    previous_status: &DeliverableStatus,
1076) -> AuditEvent {
1077    AuditEvent::new("plan.deliverable.accepted")
1078        .with_actor(req.accepted_by.as_str())
1079        .with_payload(json!({
1080            "plan_id": req.plan_id.as_str(),
1081            "deliverable_id": req.deliverable_id,
1082            "accepted_by": req.accepted_by,
1083            "evidence": req.evidence,
1084            "overrode_lock_of": overrode_lock_of,
1085            "previous_status": previous_status,
1086        }))
1087}
1088
1089fn make_completed_without_lease_event(
1090    plan_id: &PlanId,
1091    deliverable_id: &str,
1092    caller_id: &CallerId,
1093    previous_status: &DeliverableStatus,
1094) -> AuditEvent {
1095    AuditEvent::new("plan.deliverable.completed_without_lease")
1096        .with_actor(caller_id.as_str())
1097        .with_payload(json!({
1098            "plan_id": plan_id.as_str(),
1099            "deliverable_id": deliverable_id,
1100            "caller_id": caller_id.as_str(),
1101            "previous_status": previous_status,
1102        }))
1103}
1104
1105fn make_marked_without_lease_event(
1106    plan_id: &PlanId,
1107    deliverable_id: &str,
1108    caller_id: &CallerId,
1109    status: &DeliverableStatus,
1110    previous_status: &DeliverableStatus,
1111) -> AuditEvent {
1112    AuditEvent::new("plan.deliverable.marked_without_lease")
1113        .with_actor(caller_id.as_str())
1114        .with_payload(json!({
1115            "plan_id": plan_id.as_str(),
1116            "deliverable_id": deliverable_id,
1117            "caller_id": caller_id.as_str(),
1118            "status": status,
1119            "previous_status": previous_status,
1120        }))
1121}
1122
1123fn make_force_released_event(lock: &LockInfo, reason: &str) -> AuditEvent {
1124    AuditEvent::new("plan.lock.force_released")
1125        .with_actor(lock.caller_id.as_str())
1126        .with_payload(json!({
1127            "plan_id": lock.plan_id.as_str(),
1128            "deliverable_id": lock.deliverable_id,
1129            "last_caller_id": lock.caller_id.as_str(),
1130            "reason": reason,
1131        }))
1132}
1133
1134fn make_counters_reset_event(
1135    plan_id: &PlanId,
1136    deliverable_id: &str,
1137    reason: &str,
1138    lapse_count: u32,
1139    failure_count: u32,
1140) -> AuditEvent {
1141    AuditEvent::new("plan.deliverable.counters_reset")
1142        .with_actor("operator")
1143        .with_payload(json!({
1144            "plan_id": plan_id.as_str(),
1145            "deliverable_id": deliverable_id,
1146            "reason": reason,
1147            "previous": { "lapse_count": lapse_count, "failure_count": failure_count },
1148        }))
1149}
1150
1151// ---------------------------------------------------------------------------
1152// Priority ordering for cohort selection
1153// ---------------------------------------------------------------------------
1154
1155/// Sort key for the ready-set priority pass: smallest latest start first
1156/// (longest remaining tail leads), then least total float, then id for
1157/// determinism.
1158fn priority_key(
1159    deliverable_id: &str,
1160    sched_by_id: &HashMap<&str, (f32, f32)>,
1161) -> (i64, i64, String) {
1162    let (latest_start, float) = match sched_by_id.get(deliverable_id) {
1163        Some(&v) => v,
1164        None => unreachable!(
1165            "deliverable '{deliverable_id}' is in the ready set but absent from the cached \
1166             CPM schedule — ready set and CPM result are out of sync"
1167        ),
1168    };
1169    let scale = |h: f32| (h * 1000.0).round() as i64;
1170    (
1171        scale(latest_start),
1172        scale(float),
1173        deliverable_id.to_string(),
1174    )
1175}
1176
1177// ---------------------------------------------------------------------------
1178// Planner impl
1179// ---------------------------------------------------------------------------
1180
1181#[async_trait]
1182impl Planner for BasicCpmPlanner {
1183    async fn submit_plan(&self, graph: PlanGraph) -> Result<PlanId, PlannerError> {
1184        validate_graph(&graph)?;
1185        let graph_hash = hash_graph(&graph);
1186
1187        // The dedup check + build + insert all happen inside ONE immediate
1188        // sqlite transaction, so identical concurrent submissions — even
1189        // from different processes — resolve to a single PlanId. The build
1190        // closure only runs on a dedup miss.
1191        self.store
1192            .submit_or_get(&graph_hash, move || initial_plan(graph))
1193    }
1194
1195    async fn sync_plan(&self, req: SyncRequest) -> Result<SyncOutcome, PlannerError> {
1196        validate_variant_key(&req)?;
1197        validate_graph(&req.graph)?;
1198        let graph_hash = hash_graph(&req.graph);
1199        let now = self.now();
1200        let (project, name, variant) = (req.project.clone(), req.name.clone(), req.variant.clone());
1201        // Named plans bypass the global submit dedup: create, compare and
1202        // revise all happen in one immediate transaction.
1203        let synced = self
1204            .store
1205            .write_tx(|tx| crate::portfolio::sync(tx, req, &graph_hash, now, initial_plan))?;
1206        let (outcome, events) = sync_outcome(synced, &project, name, variant, now);
1207        self.flush_audit(events).await;
1208        Ok(outcome)
1209    }
1210
1211    async fn fork_plan(&self, req: ForkRequest) -> Result<SyncOutcome, PlannerError> {
1212        let ForkRequest {
1213            plan_id,
1214            variant,
1215            edits,
1216            project_root,
1217        } = req;
1218        crate::project::validate_slug("variant", &variant)?;
1219        let (head, info, exists, line_archived) = self.store.read_tx(|tx| {
1220            let (_, head) = crate::portfolio::revision_graph(tx, &plan_id, None)?;
1221            let info = crate::portfolio::variant_info(tx, &plan_id)?.ok_or_else(|| {
1222                PlannerError::InvalidPath {
1223                    reason: "fork requires a named plan".to_string(),
1224                }
1225            })?;
1226            let exists = crate::portfolio::has_variant(tx, &info.project, &info.name, &variant)?;
1227            let line_archived = crate::portfolio::line_is_archived(tx, &info.project, &info.name)?;
1228            Ok((head, info, exists, line_archived))
1229        })?;
1230        // Both are checked again inside the creating transaction; failing
1231        // here first avoids writing a plan file for a fork that cannot be
1232        // registered. Forking FROM an archived variant of a live line is
1233        // allowed: the source stays readable and the new draft is live.
1234        if line_archived {
1235            return Err(crate::portfolio::line_archived(&info.name));
1236        }
1237        if exists {
1238            return Err(crate::portfolio::variant_exists(&variant, &info.name));
1239        }
1240        let graph = crate::edits::apply_edits(&head, &edits)?;
1241        let root = match project_root {
1242            Some(root) if root.project_key() != info.project => {
1243                return Err(PlannerError::InvalidPath {
1244                    reason: format!(
1245                        "the project root belongs to a different project than plan {}",
1246                        plan_id.0
1247                    ),
1248                });
1249            }
1250            Some(root) => Some(root),
1251            None => self
1252                .project_root
1253                .clone()
1254                .filter(|root| root.project_key() == info.project),
1255        };
1256        let mut sync_req = SyncRequest::new(
1257            info.project.clone(),
1258            info.name.clone(),
1259            variant.clone(),
1260            graph,
1261        );
1262        let mut written = None;
1263        if let Some(root) = &root {
1264            let file = root.plan_file(&info.name, &variant)?;
1265            let hash = root.write_new_graph(&file, &sync_req.graph)?;
1266            sync_req = sync_req
1267                .with_source_path(file.rel_path.clone())
1268                .with_content_hash(hash.clone());
1269            written = Some((root, file, hash));
1270        }
1271        let graph_hash = hash_graph(&sync_req.graph);
1272        let now = self.now();
1273        let synced = self
1274            .store
1275            .write_tx(|tx| crate::portfolio::sync_new(tx, sync_req, &graph_hash, now, initial_plan))
1276            .inspect_err(|_| {
1277                // The fork was never registered: do not leave its file behind.
1278                if let Some((root, file, hash)) = &written {
1279                    root.remove_plan_file_if_unchanged(file, hash);
1280                }
1281            })?;
1282        let (outcome, events) = sync_outcome(synced, &info.project, info.name, variant, now);
1283        self.flush_audit(events).await;
1284        Ok(outcome)
1285    }
1286
1287    async fn compare_plans(
1288        &self,
1289        req: ComparePlansRequest,
1290    ) -> Result<crate::compare::Comparison, PlannerError> {
1291        let inputs = self.compare_inputs(&req)?;
1292        crate::compare::compare(&inputs, &req.request)
1293    }
1294
1295    async fn export_plan(
1296        &self,
1297        plan_id: &PlanId,
1298        root: &ProjectRoot,
1299        path: Option<&str>,
1300        force: bool,
1301    ) -> Result<String, PlannerError> {
1302        let (graph, info) = self.store.read_tx(|tx| {
1303            let (_, graph) = crate::portfolio::revision_graph(tx, plan_id, None)?;
1304            Ok((graph, crate::portfolio::variant_info(tx, plan_id)?))
1305        })?;
1306        let file = match (path, &info) {
1307            (Some(path), _) => root.resolve_plan_file(path)?,
1308            (None, Some(info)) if info.project == root.project_key() => {
1309                root.plan_file(&info.name, &info.variant)?
1310            }
1311            (None, Some(_)) => {
1312                return Err(PlannerError::InvalidPath {
1313                    reason: format!(
1314                        "plan {} belongs to a different project; give a path",
1315                        plan_id.0
1316                    ),
1317                });
1318            }
1319            (None, None) => {
1320                return Err(PlannerError::InvalidPath {
1321                    reason: format!("plan {} is unnamed; export requires a path", plan_id.0),
1322                });
1323            }
1324        };
1325        if !force {
1326            self.check_export_target(plan_id, root, &file)?;
1327        }
1328        let hash = root.write_graph(&file, &graph)?;
1329        // Exported to its own tracked file: the file now matches the head,
1330        // so record its hash (drift stays false).
1331        if let Some(info) = &info
1332            && info.project == root.project_key()
1333            && info.source_path.as_deref() == Some(file.rel_path.as_str())
1334        {
1335            self.store
1336                .write_tx(|tx| crate::portfolio::set_content_hash(tx, plan_id, &hash))?;
1337        }
1338        Ok(file.rel_path)
1339    }
1340
1341    async fn baseline(
1342        &self,
1343        req: crate::earned_value::BaselineRequest,
1344    ) -> Result<crate::earned_value::BaselineOutcome, PlannerError> {
1345        self.take_baseline(req).await
1346    }
1347
1348    async fn ev(
1349        &self,
1350        plan_id: &PlanId,
1351        as_of: Option<DateTime<Utc>>,
1352    ) -> Result<crate::earned_value::EvReport, PlannerError> {
1353        self.ev_report(plan_id, as_of)
1354    }
1355
1356    async fn snapshot(
1357        &self,
1358        req: crate::earned_value::SnapshotRequest,
1359    ) -> Result<crate::earned_value::SnapshotOutcome, PlannerError> {
1360        self.take_snapshot(req)
1361    }
1362
1363    async fn list_plans(
1364        &self,
1365        project: &str,
1366        include_archived: bool,
1367    ) -> Result<Vec<PlanLineSummary>, PlannerError> {
1368        self.store
1369            .read_tx(|tx| crate::portfolio::list(tx, project, include_archived))
1370    }
1371
1372    async fn revision_graph(
1373        &self,
1374        plan_id: &PlanId,
1375        revision: Option<u32>,
1376    ) -> Result<(u32, PlanGraph), PlannerError> {
1377        self.store
1378            .read_tx(|tx| crate::portfolio::revision_graph(tx, plan_id, revision))
1379    }
1380
1381    async fn revise_plan(&self, req: ReviseRequest) -> Result<(u32, RevisionDiff), PlannerError> {
1382        let ReviseRequest {
1383            plan_id,
1384            graph,
1385            force,
1386        } = req;
1387        let now = self.now();
1388        let revised = self.store.write_tx(|tx| {
1389            crate::portfolio::ensure_revisable(tx, &plan_id)?;
1390            crate::portfolio::revise(tx, &plan_id, graph, None, None, force, now)
1391        })?;
1392        self.flush_audit(revision_events(&plan_id, &revised, now))
1393            .await;
1394        Ok((revised.revision, revised.diff))
1395    }
1396
1397    async fn select_variant(
1398        &self,
1399        plan_id: &PlanId,
1400        force: bool,
1401    ) -> Result<SelectOutcome, PlannerError> {
1402        let now = self.now();
1403        let selected = self
1404            .store
1405            .write_tx(|tx| crate::portfolio::select(tx, plan_id, force, now))?;
1406        self.flush_audit(selection_events(&selected, now)).await;
1407        Ok(selected.outcome)
1408    }
1409
1410    async fn archive(
1411        &self,
1412        project: &str,
1413        name: &str,
1414        variant: Option<&str>,
1415        archived: bool,
1416        force: bool,
1417    ) -> Result<(), PlannerError> {
1418        let now = self.now();
1419        let outcome = self.store.write_tx(|tx| {
1420            crate::portfolio::archive(tx, project, name, variant, archived, force, now)
1421        })?;
1422        self.flush_audit(archive_events(
1423            project, name, variant, archived, &outcome, now,
1424        ))
1425        .await;
1426        Ok(())
1427    }
1428
1429    async fn acquire_cohort(&self, req: AcquireRequest) -> Result<Cohort, PlannerError> {
1430        let AcquireRequest {
1431            plan_id,
1432            caller_id,
1433            max_count,
1434            ids,
1435            metadata_filter,
1436            ttl,
1437        } = req;
1438        let now = self.now();
1439        let expires_at = now
1440            + chrono::Duration::from_std(self.effective_ttl(ttl))
1441                .expect("INVARIANT: planner TTL fits in chrono::Duration");
1442
1443        // Whole acquire body runs inside one immediate sqlite transaction —
1444        // that's what gives us atomicity against concurrent acquirers,
1445        // including acquirers in other OS processes.
1446        let mut audit_buf: Vec<AuditEvent> = Vec::new();
1447        let cohort = self.store.mutate_executable_plan(&plan_id, |state| {
1448            // 1. Reap expired locks, emitting expiry events.
1449            let reaped = state.reap_expired(now);
1450            for lock in &reaped {
1451                audit_buf.push(make_expired_event(lock, now));
1452            }
1453
1454            // 2a. Targeting: validate requested ids, then work out which
1455            //     deliverables this acquire considers. Manual deliverables
1456            //     (metadata.kind == "manual") are never leased; a metadata
1457            //     filter silently narrows the field.
1458            if let Some(req_ids) = &ids {
1459                for rid in req_ids {
1460                    if !state.graph.deliverables.iter().any(|d| &d.id == rid) {
1461                        return Err(PlannerError::DeliverableNotFound {
1462                            plan_id: plan_id.0.clone(),
1463                            deliverable_id: rid.clone(),
1464                        });
1465                    }
1466                }
1467            }
1468            let is_requested = |id: &str| ids.as_ref().is_some_and(|v| v.iter().any(|r| r == id));
1469            let is_manual =
1470                |d: &Deliverable| d.metadata.get("kind").and_then(|k| k.as_str()) == Some("manual");
1471            let matches_filter = |d: &Deliverable| {
1472                metadata_filter
1473                    .as_ref()
1474                    .is_none_or(|f| f.iter().all(|(k, v)| d.metadata.get(k) == Some(v)))
1475            };
1476            let in_scope =
1477                |d: &Deliverable| (ids.is_none() || is_requested(&d.id)) && matches_filter(d);
1478
1479            // 2b-prelim. Lapse bound: a Ready deliverable whose lease has lapsed
1480            //     environmentally MAX_LAPSES times is evidence of a broken
1481            //     ENVIRONMENT (drivers keep getting killed before they can
1482            //     report), not a broken deliverable. Do NOT auto-fail it —
1483            //     that would misdiagnose a healthy deliverable — but stop
1484            //     re-leasing it: skip it, leave the rest of the plan
1485            //     leasable, and report it in `blocked` so an operator
1486            //     intervenes (force_release with reset_counters).
1487
1488            // 2b. Circuit-break: a Ready deliverable that has already been
1489            //     EXPLICITLY marked failed MAX_ATTEMPTS times is a poison
1490            //     item — every prior lease ended with the driver reporting
1491            //     a real implementation failure, and re-leasing it would
1492            //     loop forever. Transition it to Failed and skip it. It
1493            //     holds no lock (it is Ready), it is never handed out
1494            //     again (Failed is terminal), and its dependents simply
1495            //     never become Ready — the plan converges to `exhausted`
1496            //     instead of retrying unboundedly. Environmental lapses
1497            //     deliberately do NOT feed this counter.
1498            let poisoned: Vec<(String, u32)> = state
1499                .graph
1500                .deliverables
1501                .iter()
1502                .filter(|d| {
1503                    matches!(state.statuses.get(&d.id), Some(DeliverableStatus::Ready))
1504                        && lease_retry_decision(state.failure_count(&d.id)) == RetryDecision::Stop
1505                })
1506                .map(|d| (d.id.clone(), state.failure_count(&d.id)))
1507                .collect();
1508            for (id, failures) in poisoned {
1509                // A spent retry budget is a PERMANENT failure (not retryable) in
1510                // execution_policy's classification — the deliverable is out.
1511                let reason = format!("circuit-break: exceeded {MAX_ATTEMPTS} failed attempts");
1512                audit_buf.push(make_circuit_break_event(
1513                    &plan_id,
1514                    &id,
1515                    failures,
1516                    FailureClass::Permanent,
1517                    &reason,
1518                ));
1519                state
1520                    .statuses
1521                    .insert(id, DeliverableStatus::Failed { reason });
1522            }
1523
1524            // 2c. Lapse-limited set, computed AFTER the circuit-break so a
1525            //     deliverable at both caps is reported only as Failed.
1526            let lapse_blocked: Vec<BlockedDeliverable> = state
1527                .graph
1528                .deliverables
1529                .iter()
1530                .filter(|d| {
1531                    in_scope(d)
1532                        && !is_manual(d)
1533                        && matches!(state.statuses.get(&d.id), Some(DeliverableStatus::Ready))
1534                        && !state.locks.contains_key(&d.id)
1535                        && state.lapse_count(&d.id) >= MAX_LAPSES
1536                })
1537                .map(|d| BlockedDeliverable {
1538                    id: d.id.clone(),
1539                    code: "LAPSE_LIMIT".to_string(),
1540                    reason: format!(
1541                        "lease lapsed {} times (limit {MAX_LAPSES}); clear with \
1542                         plan.force_release {{reset_counters: true}}",
1543                        state.lapse_count(&d.id)
1544                    ),
1545                })
1546                .collect();
1547
1548            // 3. Build the (latest_start, float) lookup table.
1549            let sched_by_id: HashMap<&str, (f32, f32)> = state
1550                .cached_result
1551                .tasks
1552                .iter()
1553                .map(|t| (t.id.as_str(), (t.latest_start, t.float)))
1554                .collect();
1555
1556            // 4. Build the ready set (in scope, non-manual, Ready, unlocked,
1557            //    not lapse-limited) and, for requested ids, classify every id
1558            //    that cannot be leased. Without `ids`, only LAPSE_LIMIT is
1559            //    reported (manual deliverables are skipped quietly).
1560            let mut blocked: Vec<BlockedDeliverable> = Vec::new();
1561            let mut ready: Vec<&Deliverable> = Vec::new();
1562            if let Some(req_ids) = &ids {
1563                let mut seen: HashSet<&str> = HashSet::new();
1564                for rid in req_ids {
1565                    if !seen.insert(rid.as_str()) {
1566                        continue;
1567                    }
1568                    let d = state
1569                        .graph
1570                        .deliverables
1571                        .iter()
1572                        .find(|d| &d.id == rid)
1573                        .expect("INVARIANT: requested ids validated above");
1574                    if !matches_filter(d) {
1575                        continue;
1576                    }
1577                    let mut block = |code: &str, reason: String| {
1578                        blocked.push(BlockedDeliverable {
1579                            id: d.id.clone(),
1580                            code: code.to_string(),
1581                            reason,
1582                        });
1583                    };
1584                    let status = state.statuses.get(&d.id);
1585                    if is_manual(d) {
1586                        block("MANUAL", "manual deliverable; never leased".to_string());
1587                    } else if state.locks.contains_key(&d.id) {
1588                        block("LOCKED", "held by an active lease".to_string());
1589                    } else if !matches!(status, Some(DeliverableStatus::Ready)) {
1590                        block(
1591                            "NOT_READY",
1592                            format!(
1593                                "status is {}",
1594                                status.map_or("unknown".to_string(), |s| format!("{s:?}"))
1595                            ),
1596                        );
1597                    } else if let Some(lb) = lapse_blocked.iter().find(|b| b.id == d.id) {
1598                        block("LAPSE_LIMIT", lb.reason.clone());
1599                    } else {
1600                        ready.push(d);
1601                    }
1602                }
1603            } else {
1604                blocked = lapse_blocked.clone();
1605                ready.extend(state.graph.deliverables.iter().filter(|d| {
1606                    in_scope(d)
1607                        && !is_manual(d)
1608                        && matches!(state.statuses.get(&d.id), Some(DeliverableStatus::Ready))
1609                        && !state.locks.contains_key(&d.id)
1610                        && !lapse_blocked.iter().any(|b| b.id == d.id)
1611                }));
1612            }
1613            ready.sort_by_key(|d| priority_key(&d.id, &sched_by_id));
1614
1615            // 5. Greedy fill with file-disjointness check. Requested ids that
1616            //    are skipped are reported as FILE_CONFLICT / MAX_COUNT.
1617            let mut selected: Vec<Deliverable> = Vec::new();
1618            let mut selected_files: HashMap<PathBuf, FileMode> = HashMap::new();
1619            for candidate in ready {
1620                if selected.len() == max_count {
1621                    if ids.is_none() {
1622                        break;
1623                    }
1624                    blocked.push(BlockedDeliverable {
1625                        id: candidate.id.clone(),
1626                        code: "MAX_COUNT".to_string(),
1627                        reason: format!("cohort already holds max_count ({max_count})"),
1628                    });
1629                    continue;
1630                }
1631                let conflict = candidate.owned_files.iter().any(|f| {
1632                    selected_files
1633                        .get(f.path())
1634                        .is_some_and(|m| modes_conflict(f.mode(), *m))
1635                        || state
1636                            .file_claims
1637                            .get(f.path())
1638                            .is_some_and(|c| c.conflicts_with(f.mode()))
1639                });
1640                if conflict {
1641                    if ids.is_some() {
1642                        blocked.push(BlockedDeliverable {
1643                            id: candidate.id.clone(),
1644                            code: "FILE_CONFLICT".to_string(),
1645                            reason: "owned files overlap a held lock or an earlier pick"
1646                                .to_string(),
1647                        });
1648                    }
1649                    continue;
1650                }
1651                for f in &candidate.owned_files {
1652                    // Exclusive wins if a path is somehow listed twice.
1653                    let e = selected_files
1654                        .entry(f.path().to_path_buf())
1655                        .or_insert(f.mode());
1656                    if f.mode() == FileMode::Exclusive {
1657                        *e = FileMode::Exclusive;
1658                    }
1659                }
1660                selected.push(candidate.clone());
1661            }
1662
1663            // 6. Atomically acquire: status -> InProgress, locks inserted,
1664            //    file index updated, attempt counted, audit events buffered.
1665            //
1666            // F5 INTERFACE_GAP-001: build `Vec<CohortRow>` directly so
1667            // the pairing invariant is type-enforced — pre-F5 we
1668            // collected into `Vec<(Deliverable, LockInfo)>` then
1669            // unzipped into two parallel vectors that were doc-only
1670            // aligned.
1671            let mut rows: Vec<CohortRow> = Vec::with_capacity(selected.len());
1672            for d in selected {
1673                let lock = LockInfo {
1674                    plan_id: plan_id.clone(),
1675                    deliverable_id: d.id.clone(),
1676                    caller_id: caller_id.clone(),
1677                    acquired_at: now,
1678                    expires_at,
1679                };
1680                state
1681                    .statuses
1682                    .insert(d.id.clone(), DeliverableStatus::InProgress);
1683                // Increment on LEASE, not on candidate evaluation: only a
1684                // deliverable actually handed to a driver counts as an
1685                // attempt (a file-conflict skip above does not).
1686                *state.attempt_counts.entry(d.id.clone()).or_insert(0) += 1;
1687                add_file_claims(&mut state.file_claims, &d.id, &d.owned_files);
1688                state.locks.insert(d.id.clone(), lock.clone());
1689                // In progress by lease from here on: a lost lock row is an orphan.
1690                state.lockless.remove(&d.id);
1691                audit_buf.push(make_acquired_event(&lock, &d.owned_files));
1692                rows.push(CohortRow {
1693                    deliverable: d,
1694                    lock,
1695                });
1696            }
1697
1698            // Append paths in this cohort that two or more deliverables hold
1699            // (claims were just recorded, so the set includes cohort members).
1700            let mut shared_paths: Vec<PathBuf> = rows
1701                .iter()
1702                .flat_map(|r| r.deliverable.owned_files.iter())
1703                .filter(|f| f.mode() == FileMode::Append)
1704                .filter(|f| {
1705                    matches!(state.file_claims.get(f.path()),
1706                        Some(FileClaim::Append(set)) if set.len() >= 2)
1707                })
1708                .map(|f| f.path().to_path_buf())
1709                .collect();
1710            shared_paths.sort();
1711            shared_paths.dedup();
1712
1713            Ok(Cohort {
1714                plan_id: plan_id.clone(),
1715                rows,
1716                blocked,
1717                shared_paths,
1718            })
1719        })?;
1720
1721        self.flush_audit(audit_buf).await;
1722        Ok(cohort)
1723    }
1724
1725    async fn mark_status(&self, req: MarkStatusRequest) -> Result<(), PlannerError> {
1726        let MarkStatusRequest {
1727            plan_id,
1728            deliverable_id,
1729            caller_id,
1730            status,
1731            earned_pct,
1732            actual_effort_hours,
1733            evidence,
1734        } = req;
1735        validate_actuals(
1736            &status,
1737            earned_pct.map(f64::from),
1738            actual_effort_hours.map(f64::from),
1739            evidence.as_deref(),
1740        )?;
1741        let report = crate::ev_store::ReportedActuals {
1742            // Accepted alongside Complete, but ignored there.
1743            earned_pct: earned_pct.filter(|_| status == DeliverableStatus::InProgress),
1744            actual_hours: actual_effort_hours,
1745            evidence,
1746        };
1747        let deliverable_id = deliverable_id.as_str();
1748        let caller_id = &caller_id;
1749        let now = self.now();
1750        let mut audit_buf: Vec<AuditEvent> = Vec::new();
1751        self.store.mutate_executable_plan(&plan_id, |state| {
1752            // Deliverable existence.
1753            if !state
1754                .graph
1755                .deliverables
1756                .iter()
1757                .any(|d| d.id == deliverable_id)
1758            {
1759                return Err(PlannerError::DeliverableNotFound {
1760                    plan_id: plan_id.0.clone(),
1761                    deliverable_id: deliverable_id.to_string(),
1762                });
1763            }
1764
1765            // If a lock exists it must belong to caller_id.
1766            if let Some(lock) = state.locks.get(deliverable_id)
1767                && lock.caller_id != *caller_id
1768            {
1769                return Err(PlannerError::LockNotHeld {
1770                    caller_id: caller_id.0.clone(),
1771                    deliverable_id: deliverable_id.to_string(),
1772                });
1773            }
1774
1775            let is_complete = matches!(status, DeliverableStatus::Complete);
1776            let previous_status = state
1777                .statuses
1778                .get(deliverable_id)
1779                .cloned()
1780                .unwrap_or(DeliverableStatus::Pending);
1781
1782            // No lock and already Complete: only an idempotent Complete is
1783            // allowed; a late holder of an overridden lease cannot undo an
1784            // accept.
1785            if !state.locks.contains_key(deliverable_id)
1786                && matches!(previous_status, DeliverableStatus::Complete)
1787                && !is_complete
1788            {
1789                return Err(PlannerError::LockNotHeld {
1790                    caller_id: caller_id.0.clone(),
1791                    deliverable_id: deliverable_id.to_string(),
1792                });
1793            }
1794
1795            // Progress fields ride along with every accepted mark (also an
1796            // idempotent Complete); a refused mark rolls them back.
1797            state.report_actuals(deliverable_id, report, now);
1798
1799            // Completing without a lease: every prerequisite must be done,
1800            // and the bypass is audited.
1801            if is_complete && !state.locks.contains_key(deliverable_id) {
1802                // Already Complete: idempotent no-op, nothing to audit.
1803                if matches!(
1804                    state.statuses.get(deliverable_id),
1805                    Some(DeliverableStatus::Complete)
1806                ) {
1807                    return Ok(());
1808                }
1809                let missing = incomplete_prerequisites(state, deliverable_id);
1810                if !missing.is_empty() {
1811                    return Err(PlannerError::PrerequisitesIncomplete {
1812                        plan_id: plan_id.0.clone(),
1813                        deliverable_id: deliverable_id.to_string(),
1814                        missing,
1815                    });
1816                }
1817                audit_buf.push(make_completed_without_lease_event(
1818                    &plan_id,
1819                    deliverable_id,
1820                    caller_id,
1821                    &previous_status,
1822                ));
1823            } else if !is_complete && !state.locks.contains_key(deliverable_id) {
1824                // Putting a deliverable back to work without a lease must
1825                // not bypass dependency order.
1826                if matches!(
1827                    status,
1828                    DeliverableStatus::Ready | DeliverableStatus::InProgress
1829                ) {
1830                    let missing = incomplete_prerequisites(state, deliverable_id);
1831                    if !missing.is_empty() {
1832                        return Err(PlannerError::PrerequisitesIncomplete {
1833                            plan_id: plan_id.0.clone(),
1834                            deliverable_id: deliverable_id.to_string(),
1835                            missing,
1836                        });
1837                    }
1838                }
1839                audit_buf.push(make_marked_without_lease_event(
1840                    &plan_id,
1841                    deliverable_id,
1842                    caller_id,
1843                    &status,
1844                    &previous_status,
1845                ));
1846            }
1847
1848            // Lock release on Failed.
1849            if matches!(status, DeliverableStatus::Failed { .. })
1850                && let Some(lock) = state.end_lease(deliverable_id, now)
1851            {
1852                audit_buf.push(make_released_event(&lock, "failed"));
1853            }
1854
1855            // An EXPLICIT Failed mark is a real implementation attempt —
1856            // this (and only this) feeds the failure circuit-breaker.
1857            // Guarded on the prior status so an idempotent re-mark of an
1858            // already-Failed deliverable does not double-charge, and the
1859            // acquire-path auto-fail (which writes Failed directly) never
1860            // routes through here.
1861            if matches!(status, DeliverableStatus::Failed { .. })
1862                && !matches!(
1863                    state.statuses.get(deliverable_id),
1864                    Some(DeliverableStatus::Failed { .. })
1865                )
1866            {
1867                *state
1868                    .failure_counts
1869                    .entry(deliverable_id.to_string())
1870                    .or_insert(0) += 1;
1871            }
1872
1873            // Lease provenance for the startup sweep: a lockless
1874            // in_progress mark is a legitimate persisted state (owner or
1875            // manual work), not an orphan of a lost lease. Any other mark,
1876            // or one under a held lease, clears it.
1877            if status == DeliverableStatus::InProgress && !state.locks.contains_key(deliverable_id)
1878            {
1879                state.lockless.insert(deliverable_id.to_string());
1880            } else {
1881                state.lockless.remove(deliverable_id);
1882            }
1883
1884            if is_complete {
1885                if complete_deliverable(state, deliverable_id, &mut audit_buf, "completed", now) {
1886                    audit_buf.push(make_plan_completed_event(
1887                        &plan_id,
1888                        state.graph.deliverables.len(),
1889                    ));
1890                }
1891            } else {
1892                state
1893                    .statuses
1894                    .insert(deliverable_id.to_string(), status.clone());
1895            }
1896
1897            Ok(())
1898        })?;
1899
1900        // SPEC §33 audit fixup (F6 ORPHAN-001): the previous
1901        // `let _ = status_recompute_needed;` extension marker was
1902        // computed but never consumed. YAGNI — the CPM algorithm
1903        // doesn't drift with status alone, and a future "filter on
1904        // non-complete tasks" refactor can recompute the flag when
1905        // it actually needs it.
1906
1907        self.flush_audit(audit_buf).await;
1908        Ok(())
1909    }
1910
1911    async fn heartbeat(&self, req: HeartbeatRequest) -> Result<(), PlannerError> {
1912        let HeartbeatRequest {
1913            plan_id,
1914            deliverable_id,
1915            caller_id,
1916            ttl,
1917        } = req;
1918        let deliverable_id = deliverable_id.as_str();
1919        let caller_id = &caller_id;
1920        let now = self.now();
1921        let explicit_ttl = ttl.is_some();
1922        let expires_at = now
1923            + chrono::Duration::from_std(self.effective_ttl(ttl))
1924                .expect("INVARIANT: planner TTL fits in chrono::Duration");
1925
1926        self.store.mutate_executable_plan(&plan_id, |state| {
1927            let lock =
1928                state
1929                    .locks
1930                    .get_mut(deliverable_id)
1931                    .ok_or_else(|| PlannerError::LockNotHeld {
1932                        caller_id: caller_id.0.clone(),
1933                        deliverable_id: deliverable_id.to_string(),
1934                    })?;
1935
1936            if lock.caller_id != *caller_id {
1937                return Err(PlannerError::LockNotHeld {
1938                    caller_id: caller_id.0.clone(),
1939                    deliverable_id: deliverable_id.to_string(),
1940                });
1941            }
1942
1943            // TTL already lapsed at the moment of the heartbeat — surface it so
1944            // the caller knows their work item may have been reclaimed.
1945            if lock.expires_at < now {
1946                return Err(PlannerError::LockExpired {
1947                    deliverable_id: deliverable_id.to_string(),
1948                    expired_at: lock.expires_at,
1949                });
1950            }
1951
1952            // A heartbeat without an explicit ttl never shortens a lease
1953            // (e.g. one acquired with a long ttl_seconds); an explicit ttl
1954            // sets now + ttl.
1955            lock.expires_at = if explicit_ttl {
1956                expires_at
1957            } else {
1958                lock.expires_at.max(expires_at)
1959            };
1960            Ok(())
1961        })
1962    }
1963
1964    async fn get_plan(&self, plan_id: &PlanId) -> Result<PlanDefinition, PlannerError> {
1965        self.store.read_plan(plan_id, |state| PlanDefinition {
1966            plan_id: plan_id.clone(),
1967            graph: state.graph.clone(),
1968        })
1969    }
1970
1971    async fn status(&self, plan_id: &PlanId) -> Result<PlanStatus, PlannerError> {
1972        let ((mut status, head_hash), info) =
1973            self.store.read_plan_and_variant(plan_id, |state| {
1974                // Preserve insertion order from the original graph for stable UI.
1975                let deliverables: Vec<(String, DeliverableStatus, u32, u32, u32)> = state
1976                    .graph
1977                    .deliverables
1978                    .iter()
1979                    .map(|d| {
1980                        let status = state
1981                            .statuses
1982                            .get(&d.id)
1983                            .cloned()
1984                            .unwrap_or(DeliverableStatus::Pending);
1985                        (
1986                            d.id.clone(),
1987                            status,
1988                            state.attempt_count(&d.id),
1989                            state.failure_count(&d.id),
1990                            state.lapse_count(&d.id),
1991                        )
1992                    })
1993                    .collect();
1994
1995                let schedule = crate::schedule::schedule_rows(&state.graph, &state.cached_result);
1996                let mut ready_rows: Vec<&ScheduleRow> = schedule
1997                    .iter()
1998                    .filter(|r| {
1999                        matches!(state.statuses.get(&r.id), Some(DeliverableStatus::Ready))
2000                            && !state.locks.contains_key(&r.id)
2001                    })
2002                    .collect();
2003                // Same ordering as acquire_cohort (shared priority_key). Membership is a
2004                // superset: acquire may still skip deliverables at the failure or lapse
2005                // cap, manual deliverables, or ones whose files overlap a held lock.
2006                let sched_by_id: HashMap<&str, (f32, f32)> = state
2007                    .cached_result
2008                    .tasks
2009                    .iter()
2010                    .map(|t| (t.id.as_str(), (t.latest_start, t.float)))
2011                    .collect();
2012                ready_rows.sort_by_key(|r| priority_key(&r.id, &sched_by_id));
2013                let ready: Vec<String> = ready_rows.iter().map(|r| r.id.clone()).collect();
2014
2015                let milestones =
2016                    crate::schedule::milestone_rows(&state.graph, &state.cached_result, |id| {
2017                        matches!(state.statuses.get(id), Some(DeliverableStatus::Complete))
2018                    });
2019
2020                let status = PlanStatus {
2021                    plan_id: plan_id.clone(),
2022                    milestones,
2023                    critical_ids: state
2024                        .cached_result
2025                        .critical_ids
2026                        .iter()
2027                        .filter(|id| id.as_str() != START_ID && id.as_str() != FINISH_ID)
2028                        .cloned()
2029                        .collect(),
2030                    plan_complete: all_complete(state),
2031                    schedule,
2032                    ready,
2033                    deliverables,
2034                    critical_path: state.cached_result.critical_path.clone(),
2035                    critical_path_hours: state.cached_result.critical_path_duration,
2036                    locks_held: state.locks.values().cloned().collect(),
2037                    name: None,
2038                    variant: None,
2039                    selected: None,
2040                    definition_drift: None,
2041                };
2042                (status, hash_graph(&state.graph))
2043            })?;
2044        if let Some(info) = info {
2045            // File I/O outside the read snapshot; the head hash is from it.
2046            status.definition_drift =
2047                definition_drift(self.project_root.as_ref(), &info, &head_hash);
2048            status.name = Some(info.name);
2049            status.variant = Some(info.variant);
2050            status.selected = Some(info.selected);
2051        }
2052        Ok(status)
2053    }
2054
2055    async fn accept(&self, req: AcceptRequest) -> Result<(), PlannerError> {
2056        let mut audit_buf: Vec<AuditEvent> = Vec::new();
2057        let plan_id = req.plan_id.clone();
2058        let deliverable_id = req.deliverable_id.as_str();
2059        let now = self.now();
2060        self.store.mutate_executable_plan(&plan_id, |state| {
2061            if !state
2062                .graph
2063                .deliverables
2064                .iter()
2065                .any(|d| d.id == deliverable_id)
2066            {
2067                return Err(PlannerError::DeliverableNotFound {
2068                    plan_id: plan_id.0.clone(),
2069                    deliverable_id: deliverable_id.to_string(),
2070                });
2071            }
2072
2073            // Only LIVE leases block acceptance: reap expired ones first.
2074            for lock in &state.reap_expired(now) {
2075                audit_buf.push(make_expired_event(lock, now));
2076            }
2077
2078            // Already Complete: idempotent no-op (no state change, no audit).
2079            let previous_status = state
2080                .statuses
2081                .get(deliverable_id)
2082                .cloned()
2083                .unwrap_or(DeliverableStatus::Pending);
2084            if matches!(previous_status, DeliverableStatus::Complete) {
2085                return Ok(());
2086            }
2087
2088            // ANY live lease needs an explicit override, whoever the
2089            // acceptor claims to be.
2090            let mut overrode: Option<String> = None;
2091            if let Some(lock) = state.locks.get(deliverable_id) {
2092                if !req.override_lock {
2093                    return Err(PlannerError::LockHeld {
2094                        plan_id: plan_id.0.clone(),
2095                        deliverable_id: deliverable_id.to_string(),
2096                        holder: lock.caller_id.0.clone(),
2097                    });
2098                }
2099                overrode = Some(lock.caller_id.0.clone());
2100            }
2101
2102            let missing = incomplete_prerequisites(state, deliverable_id);
2103            if !missing.is_empty() {
2104                return Err(PlannerError::PrerequisitesIncomplete {
2105                    plan_id: plan_id.0.clone(),
2106                    deliverable_id: deliverable_id.to_string(),
2107                    missing,
2108                });
2109            }
2110
2111            let plan_done =
2112                complete_deliverable(state, deliverable_id, &mut audit_buf, "accepted", now);
2113            audit_buf.push(make_accepted_event(
2114                &req,
2115                overrode.as_deref(),
2116                &previous_status,
2117            ));
2118            if plan_done {
2119                audit_buf.push(make_plan_completed_event(
2120                    &plan_id,
2121                    state.graph.deliverables.len(),
2122                ));
2123            }
2124            Ok(())
2125        })?;
2126
2127        self.flush_audit(audit_buf).await;
2128        Ok(())
2129    }
2130
2131    async fn force_release(&self, req: ForceReleaseRequest) -> Result<(), PlannerError> {
2132        let ForceReleaseRequest {
2133            plan_id,
2134            deliverable_id,
2135            reason,
2136            reset_counters,
2137        } = req;
2138        let deliverable_id = deliverable_id.as_str();
2139        let reason = reason.as_str();
2140        let now = self.now();
2141        let mut audit_buf: Vec<AuditEvent> = Vec::new();
2142        self.store.mutate_executable_plan(&plan_id, |state| {
2143            if !state
2144                .graph
2145                .deliverables
2146                .iter()
2147                .any(|d| d.id == deliverable_id)
2148            {
2149                return Err(PlannerError::DeliverableNotFound {
2150                    plan_id: plan_id.0.clone(),
2151                    deliverable_id: deliverable_id.to_string(),
2152                });
2153            }
2154
2155            if let Some(lock) = state.end_lease(deliverable_id, now) {
2156                state
2157                    .statuses
2158                    .insert(deliverable_id.to_string(), DeliverableStatus::Ready);
2159                audit_buf.push(make_force_released_event(&lock, reason));
2160            }
2161
2162            if reset_counters {
2163                let lapse_count = state.lapse_counts.remove(deliverable_id).unwrap_or(0);
2164                let failure_count = state.failure_counts.remove(deliverable_id).unwrap_or(0);
2165                // A circuit-broken deliverable is Failed; revive it.
2166                if matches!(
2167                    state.statuses.get(deliverable_id),
2168                    Some(DeliverableStatus::Failed { .. })
2169                ) {
2170                    let prereqs_complete = state
2171                        .graph
2172                        .deliverables
2173                        .iter()
2174                        .find(|d| d.id == deliverable_id)
2175                        .is_some_and(|d| {
2176                            crate::graph::prerequisite_ids(d).all(|p| {
2177                                matches!(state.statuses.get(p), Some(DeliverableStatus::Complete))
2178                            })
2179                        });
2180                    let revived = if prereqs_complete {
2181                        DeliverableStatus::Ready
2182                    } else {
2183                        DeliverableStatus::Pending
2184                    };
2185                    state.statuses.insert(deliverable_id.to_string(), revived);
2186                }
2187                audit_buf.push(make_counters_reset_event(
2188                    &plan_id,
2189                    deliverable_id,
2190                    reason,
2191                    lapse_count,
2192                    failure_count,
2193                ));
2194            }
2195
2196            Ok(())
2197        })?;
2198
2199        self.flush_audit(audit_buf).await;
2200        Ok(())
2201    }
2202}
2203
2204#[cfg(test)]
2205#[allow(clippy::float_cmp)]
2206mod tests {
2207    use super::*;
2208    use crate::estimator::EffortEstimator;
2209    use crate::schedule::deliverable_to_task;
2210    use crate::task::TaskKind;
2211
2212    fn deliverable(id: &str, effort: Option<f32>, metadata: serde_json::Value) -> Deliverable {
2213        Deliverable {
2214            id: id.to_string(),
2215            owned_files: Vec::new(),
2216            prerequisites: Vec::new(),
2217            estimated_effort_hours: effort,
2218            metadata,
2219            duration_hours: None,
2220            estimate: None,
2221            milestone: false,
2222            earning_rule: None,
2223        }
2224    }
2225
2226    #[test]
2227    fn explicit_default_earning_rule_hashes_like_absent() {
2228        let absent = deliverable("D1", Some(1.0), json!({}));
2229        let mut explicit = absent.clone();
2230        explicit.earning_rule = Some(crate::plan::EarningRule::ZeroHundred);
2231        let graph = |d: Deliverable| PlanGraph {
2232            deliverables: vec![d],
2233            max_chained_dispatch: None,
2234        };
2235        assert_eq!(hash_graph(&graph(explicit)), hash_graph(&graph(absent)));
2236    }
2237
2238    #[test]
2239    fn canonical_form_omits_an_absent_earning_rule() {
2240        let d = deliverable("D1", Some(1.0), json!({}));
2241        assert!(canonical_deliverable(&d).get("earning_rule").is_none());
2242    }
2243
2244    #[test]
2245    fn explicit_effort_wins_over_estimator() {
2246        let estimator = EffortEstimator::new();
2247        let d = deliverable("D1", Some(2.5), json!({}));
2248        let task = deliverable_to_task(&d, &estimator);
2249        assert_eq!(task.effort_hours, 2.5);
2250    }
2251
2252    #[test]
2253    fn missing_effort_uses_estimator_not_flat_default() {
2254        // No explicit estimate -> estimator's Custom base (default 4.0),
2255        // which must differ from the legacy flat DEFAULT_EFFORT_HOURS (1.0).
2256        let estimator = EffortEstimator::new();
2257        let d = deliverable("D1", None, json!({}));
2258        let task = deliverable_to_task(&d, &estimator);
2259        let expected = estimator.estimate(
2260            &TaskKind::Custom {
2261                description: String::new(),
2262            },
2263            false,
2264        );
2265        assert_eq!(task.effort_hours, expected);
2266        assert_ne!(task.effort_hours, DEFAULT_EFFORT_HOURS);
2267    }
2268
2269    #[test]
2270    fn complexity_metadata_hint_raises_estimate() {
2271        let estimator = EffortEstimator::new();
2272        let simple = deliverable_to_task(&deliverable("S", None, json!({})), &estimator);
2273        let complex = deliverable_to_task(
2274            &deliverable("C", None, json!({ "complexity": true })),
2275            &estimator,
2276        );
2277        assert!(complex.effort_hours > simple.effort_hours);
2278    }
2279
2280    fn sched<'a>(entries: &[(&'a str, f32, f32)]) -> HashMap<&'a str, (f32, f32)> {
2281        entries
2282            .iter()
2283            .map(|&(id, latest_start, float)| (id, (latest_start, float)))
2284            .collect()
2285    }
2286
2287    #[test]
2288    fn priority_key_orders_lower_latest_start_first() {
2289        let s = sched(&[("A", 5.0, 0.0), ("B", 0.0, 9.0)]);
2290        assert!(priority_key("B", &s) < priority_key("A", &s));
2291    }
2292
2293    #[test]
2294    fn priority_key_breaks_latest_start_ties_by_float() {
2295        let s = sched(&[("X", 1.0, 3.0), ("Y", 1.0, 1.0)]);
2296        assert!(priority_key("Y", &s) < priority_key("X", &s));
2297    }
2298
2299    #[test]
2300    fn priority_key_breaks_float_ties_by_id() {
2301        let s = sched(&[("X", 1.0, 1.0), ("Y", 1.0, 1.0)]);
2302        assert!(priority_key("X", &s) < priority_key("Y", &s));
2303    }
2304
2305    #[test]
2306    #[should_panic(expected = "absent from the cached CPM schedule")]
2307    fn priority_key_missing_entry_is_invariant_breach() {
2308        let s: HashMap<&str, (f32, f32)> = HashMap::new();
2309        let _ = priority_key("ghost", &s);
2310    }
2311}