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