Skip to main content

perspt_core/
types.rs

1//! SRBN Types
2//!
3//! Core types for the Stabilized Recursive Barrier Network.
4//! Based on PSP-000004 and PSP-000005 specifications.
5
6use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8use std::time::SystemTime;
9
10/// Model tier for different agent roles
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12pub enum ModelTier {
13    /// Deep reasoning model for planning and architecture
14    Architect,
15    /// Fast coding model for implementation
16    Actuator,
17    /// Sensor for LSP + Contract checking
18    Verifier,
19    /// Fast lookahead for speculation
20    Speculator,
21}
22
23impl ModelTier {
24    /// Get the recommended default model for this tier.
25    ///
26    /// Architect and Verifier tiers prefer higher-capability models for
27    /// reasoning and evaluation. Actuator and Speculator default to the
28    /// faster lower-cost Gemini tier. All defaults can be overridden per-tier
29    /// via CLI.
30    pub fn default_model(&self) -> &'static str {
31        match self {
32            ModelTier::Architect => "gemini-3.1-pro-preview",
33            ModelTier::Verifier => "gemini-3.1-pro-preview",
34            ModelTier::Actuator => "gemini-3.1-flash-lite-preview",
35            ModelTier::Speculator => "gemini-3.1-flash-lite-preview",
36        }
37    }
38
39    /// Get the default model name (static, for use when no instance is available).
40    /// Returns the Actuator default as the general-purpose fallback.
41    pub fn default_model_name() -> &'static str {
42        "gemini-3.1-flash-lite-preview"
43    }
44}
45
46#[cfg(test)]
47mod tests {
48    use super::ModelTier;
49
50    #[test]
51    fn gemini_defaults_use_requested_latest_models() {
52        assert_eq!(
53            ModelTier::Architect.default_model(),
54            "gemini-3.1-pro-preview"
55        );
56        assert_eq!(
57            ModelTier::Verifier.default_model(),
58            "gemini-3.1-pro-preview"
59        );
60        assert_eq!(
61            ModelTier::Actuator.default_model(),
62            "gemini-3.1-flash-lite-preview"
63        );
64        assert_eq!(
65            ModelTier::Speculator.default_model(),
66            "gemini-3.1-flash-lite-preview"
67        );
68        assert_eq!(
69            ModelTier::default_model_name(),
70            "gemini-3.1-flash-lite-preview"
71        );
72    }
73}
74
75/// Test criticality levels for weighted tests
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77pub enum Criticality {
78    /// Critical tests - highest energy penalty on failure
79    Critical,
80    /// High priority tests
81    High,
82    /// Low priority tests
83    Low,
84}
85
86impl Criticality {
87    /// Get the energy weight multiplier
88    pub fn weight(&self) -> f32 {
89        match self {
90            Criticality::Critical => 10.0,
91            Criticality::High => 3.0,
92            Criticality::Low => 1.0,
93        }
94    }
95}
96
97/// Weighted test definition
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct WeightedTest {
100    /// Test name or pattern
101    pub test_name: String,
102    /// Criticality level
103    pub criticality: Criticality,
104}
105
106/// Behavioral contract for a node
107///
108/// Defines the constraints and expectations for an SRBN node.
109#[derive(Debug, Clone, Default, Serialize, Deserialize)]
110pub struct BehavioralContract {
111    /// Required public API signature (hard constraint)
112    pub interface_signature: String,
113    /// Semantic constraints (e.g., "Use RS256 algorithm")
114    pub invariants: Vec<String>,
115    /// Anti-patterns to reject (e.g., "no unwrap()")
116    pub forbidden_patterns: Vec<String>,
117    /// Weighted test cases
118    pub weighted_tests: Vec<WeightedTest>,
119    /// Energy weights (alpha, beta, gamma) for V(x) calculation
120    /// Default: (1.0, 0.5, 2.0) - Logic failures weighted highest
121    pub energy_weights: (f32, f32, f32),
122}
123
124impl BehavioralContract {
125    /// Create a new contract with default weights
126    pub fn new() -> Self {
127        Self {
128            interface_signature: String::new(),
129            invariants: Vec::new(),
130            forbidden_patterns: Vec::new(),
131            weighted_tests: Vec::new(),
132            energy_weights: (1.0, 0.5, 2.0), // alpha, beta, gamma from PSP
133        }
134    }
135
136    /// Get the alpha weight (syntactic energy)
137    pub fn alpha(&self) -> f32 {
138        self.energy_weights.0
139    }
140
141    /// Get the beta weight (structural energy)
142    pub fn beta(&self) -> f32 {
143        self.energy_weights.1
144    }
145
146    /// Get the gamma weight (logic energy)
147    pub fn gamma(&self) -> f32 {
148        self.energy_weights.2
149    }
150}
151
152/// Error type for determining retry limits per PSP-4
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
154pub enum ErrorType {
155    /// Compilation/syntax/type errors (3 attempts)
156    #[default]
157    Compilation,
158    /// Tool execution failures (5 attempts)
159    ToolFailure,
160    /// User/reviewer rejection (3 rejections)
161    ReviewRejection,
162    /// Unknown/other errors (3 attempts default)
163    Other,
164}
165
166/// Retry policy configuration per PSP-4 specification
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct RetryPolicy {
169    /// Max retries for compilation errors (default: 3)
170    pub max_compilation_retries: usize,
171    /// Max retries for tool failures (default: 5)
172    pub max_tool_retries: usize,
173    /// Max reviewer rejections before escalation (default: 3)
174    pub max_review_rejections: usize,
175    /// Current consecutive failures by type
176    pub compilation_failures: usize,
177    pub tool_failures: usize,
178    pub review_rejections: usize,
179    /// Last error type encountered
180    pub last_error_type: Option<ErrorType>,
181}
182
183impl Default for RetryPolicy {
184    fn default() -> Self {
185        Self {
186            // PSP-4 specified limits
187            max_compilation_retries: 3,
188            max_tool_retries: 5,
189            max_review_rejections: 3,
190            compilation_failures: 0,
191            tool_failures: 0,
192            review_rejections: 0,
193            last_error_type: None,
194        }
195    }
196}
197
198impl RetryPolicy {
199    /// Record a failure of a specific type
200    pub fn record_failure(&mut self, error_type: ErrorType) {
201        self.last_error_type = Some(error_type);
202        match error_type {
203            ErrorType::Compilation => self.compilation_failures += 1,
204            ErrorType::ToolFailure => self.tool_failures += 1,
205            ErrorType::ReviewRejection => self.review_rejections += 1,
206            ErrorType::Other => self.compilation_failures += 1, // Treat as compilation
207        }
208    }
209
210    /// Check if we should escalate for a specific error type
211    pub fn should_escalate(&self, error_type: ErrorType) -> bool {
212        match error_type {
213            ErrorType::Compilation | ErrorType::Other => {
214                self.compilation_failures >= self.max_compilation_retries
215            }
216            ErrorType::ToolFailure => self.tool_failures >= self.max_tool_retries,
217            ErrorType::ReviewRejection => self.review_rejections >= self.max_review_rejections,
218        }
219    }
220
221    /// Check if any error type has exceeded its limit
222    pub fn any_exceeded(&self) -> bool {
223        self.compilation_failures >= self.max_compilation_retries
224            || self.tool_failures >= self.max_tool_retries
225            || self.review_rejections >= self.max_review_rejections
226    }
227
228    /// Get remaining attempts for an error type
229    pub fn remaining_attempts(&self, error_type: ErrorType) -> usize {
230        match error_type {
231            ErrorType::Compilation | ErrorType::Other => self
232                .max_compilation_retries
233                .saturating_sub(self.compilation_failures),
234            ErrorType::ToolFailure => self.max_tool_retries.saturating_sub(self.tool_failures),
235            ErrorType::ReviewRejection => self
236                .max_review_rejections
237                .saturating_sub(self.review_rejections),
238        }
239    }
240
241    /// Get a formatted summary
242    pub fn summary(&self) -> String {
243        format!(
244            "Retries: comp {}/{}, tool {}/{}, review {}/{}",
245            self.compilation_failures,
246            self.max_compilation_retries,
247            self.tool_failures,
248            self.max_tool_retries,
249            self.review_rejections,
250            self.max_review_rejections
251        )
252    }
253}
254
255/// Stability monitor for tracking Lyapunov Energy
256#[derive(Debug, Clone, Default, Serialize, Deserialize)]
257pub struct StabilityMonitor {
258    /// History of V(x) values
259    pub energy_history: Vec<f32>,
260    /// Number of convergence attempts
261    pub attempt_count: usize,
262    /// Whether the node has converged to stability
263    pub stable: bool,
264    /// Stability threshold (epsilon)
265    pub stability_epsilon: f32,
266    /// Maximum retry attempts before escalation (legacy, use retry_policy)
267    pub max_retries: usize,
268    /// Retry policy with PSP-4 compliant limits
269    pub retry_policy: RetryPolicy,
270}
271
272impl StabilityMonitor {
273    /// Create with default epsilon = 0.1
274    pub fn new() -> Self {
275        Self {
276            energy_history: Vec::new(),
277            attempt_count: 0,
278            stable: false,
279            stability_epsilon: 0.1,
280            max_retries: 3,
281            retry_policy: RetryPolicy::default(),
282        }
283    }
284
285    /// Record a new energy value
286    pub fn record_energy(&mut self, energy: f32) {
287        self.energy_history.push(energy);
288        self.attempt_count += 1;
289        self.stable = energy < self.stability_epsilon;
290    }
291
292    /// Record a failure with error type
293    pub fn record_failure(&mut self, error_type: ErrorType) {
294        self.retry_policy.record_failure(error_type);
295    }
296
297    /// Check if we should escalate (exceeded retries without stability)
298    pub fn should_escalate(&self) -> bool {
299        // Legacy check or new policy check
300        (self.attempt_count >= self.max_retries && !self.stable) || self.retry_policy.any_exceeded()
301    }
302
303    /// Get remaining attempts for current error type
304    pub fn remaining_attempts(&self) -> usize {
305        match self.retry_policy.last_error_type {
306            Some(et) => self.retry_policy.remaining_attempts(et),
307            None => self.max_retries.saturating_sub(self.attempt_count),
308        }
309    }
310
311    /// Get the current energy level (last recorded)
312    pub fn current_energy(&self) -> f32 {
313        self.energy_history.last().copied().unwrap_or(f32::INFINITY)
314    }
315
316    /// Check if energy is decreasing (converging)
317    pub fn is_converging(&self) -> bool {
318        if self.energy_history.len() < 2 {
319            return true; // Not enough data
320        }
321        let last = self.energy_history.last().unwrap();
322        let prev = &self.energy_history[self.energy_history.len() - 2];
323        last < prev
324    }
325
326    /// Reset monitor state for a subgraph replan, preserving history but
327    /// clearing attempt count and stability flag so the node can be retried.
328    pub fn reset_for_replan(&mut self) {
329        self.attempt_count = 0;
330        self.stable = false;
331        self.retry_policy = RetryPolicy::default();
332    }
333}
334
335/// SRBN Node - the fundamental unit of control
336#[derive(Debug, Clone, Serialize, Deserialize)]
337pub struct SRBNNode {
338    /// Unique node identifier
339    pub node_id: String,
340    /// High-level goal description for LLM reasoning
341    pub goal: String,
342    /// Files the LLM MUST read for context
343    pub context_files: Vec<PathBuf>,
344    /// Files the LLM MUST modify
345    pub output_targets: Vec<PathBuf>,
346    /// Behavioral contract defining constraints
347    pub contract: BehavioralContract,
348    /// Model tier for this node
349    pub tier: ModelTier,
350    /// Stability monitor
351    pub monitor: StabilityMonitor,
352    /// Current state
353    pub state: NodeState,
354    /// Parent node ID (for DAG structure)
355    pub parent_id: Option<String>,
356    /// Child node IDs
357    pub children: Vec<String>,
358    /// PSP-5 Phase 2: Node class (Interface / Implementation / Integration)
359    pub node_class: NodeClass,
360    /// PSP-5 Phase 2: The language plugin that owns this node's files
361    pub owner_plugin: String,
362    /// PSP-5 Phase 6: Provisional branch ID if this node is executing speculatively
363    pub provisional_branch_id: Option<String>,
364    /// PSP-5 Phase 6: Interface seal hash once this node's public interface is sealed
365    pub interface_seal_hash: Option<[u8; 32]>,
366    /// Declared dependency expectations from the architect plan.
367    pub dependency_expectations: DependencyExpectation,
368}
369
370impl SRBNNode {
371    /// Create a new node with the given goal
372    pub fn new(node_id: String, goal: String, tier: ModelTier) -> Self {
373        Self {
374            node_id,
375            goal,
376            context_files: Vec::new(),
377            output_targets: Vec::new(),
378            contract: BehavioralContract::new(),
379            tier,
380            monitor: StabilityMonitor::new(),
381            state: NodeState::TaskQueued,
382            parent_id: None,
383            children: Vec::new(),
384            node_class: NodeClass::default(),
385            owner_plugin: String::new(),
386            provisional_branch_id: None,
387            interface_seal_hash: None,
388            dependency_expectations: DependencyExpectation::default(),
389        }
390    }
391}
392
393/// Outcome of a full orchestration session.
394#[derive(Debug, Clone, Copy, PartialEq, Eq)]
395pub enum SessionOutcome {
396    /// All nodes completed successfully
397    Success,
398    /// Some nodes completed, some escalated or failed
399    PartialSuccess,
400    /// Critical failure or all nodes escalated/failed
401    Failed,
402}
403
404/// Node execution state (from PSP state machine)
405#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
406pub enum NodeState {
407    /// Task is queued for execution
408    TaskQueued,
409    /// Planning phase
410    Planning,
411    /// Coding/implementation phase
412    Coding,
413    /// Verification phase (LSP + Tests)
414    Verifying,
415    /// Retry loop (convergence)
416    Retry,
417    /// Sheaf consistency check
418    SheafCheck,
419    /// Committing stable state
420    Committing,
421    /// Escalated to user
422    Escalated,
423    /// Successfully completed
424    Completed,
425    /// Failed after max retries
426    Failed,
427    /// Aborted by user
428    Aborted,
429    /// Superseded by a plan amendment (Phase 14)
430    Superseded,
431}
432
433impl NodeState {
434    /// Check if this is a terminal state
435    pub fn is_terminal(&self) -> bool {
436        matches!(
437            self,
438            NodeState::Completed | NodeState::Failed | NodeState::Aborted | NodeState::Superseded
439        )
440    }
441
442    /// Check if the node finished successfully
443    pub fn is_success(&self) -> bool {
444        matches!(self, NodeState::Completed)
445    }
446
447    /// Check if the node is actively running (non-terminal, non-queued)
448    pub fn is_active(&self) -> bool {
449        matches!(
450            self,
451            NodeState::Planning
452                | NodeState::Coding
453                | NodeState::Verifying
454                | NodeState::Retry
455                | NodeState::SheafCheck
456                | NodeState::Committing
457        )
458    }
459
460    /// Parse a state string from the database or display layer.
461    ///
462    /// Handles PascalCase, UPPERCASE, and lowercase variants that appear in
463    /// the store, CLI, and dashboard.  Unknown strings map to `TaskQueued`.
464    pub fn from_display_str(s: &str) -> Self {
465        match s.to_ascii_lowercase().as_str() {
466            "taskqueued" | "queued" | "task_queued" => NodeState::TaskQueued,
467            "planning" => NodeState::Planning,
468            "coding" | "in_progress" | "in-progress" | "running" => NodeState::Coding,
469            "verifying" => NodeState::Verifying,
470            "retry" | "retrying" => NodeState::Retry,
471            "sheafcheck" | "sheaf_check" => NodeState::SheafCheck,
472            "committing" | "committed" => NodeState::Committing,
473            "escalated" => NodeState::Escalated,
474            "completed" | "stable" | "verified" => NodeState::Completed,
475            "failed" | "error" => NodeState::Failed,
476            "aborted" => NodeState::Aborted,
477            "superseded" => NodeState::Superseded,
478            _ => NodeState::TaskQueued,
479        }
480    }
481}
482
483impl std::fmt::Display for NodeState {
484    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
485        let label = match self {
486            NodeState::TaskQueued => "queued",
487            NodeState::Planning => "planning",
488            NodeState::Coding => "coding",
489            NodeState::Verifying => "verifying",
490            NodeState::Retry => "retrying",
491            NodeState::SheafCheck => "sheaf_check",
492            NodeState::Committing => "committing",
493            NodeState::Escalated => "escalated",
494            NodeState::Completed => "completed",
495            NodeState::Failed => "failed",
496            NodeState::Aborted => "aborted",
497            NodeState::Superseded => "superseded",
498        };
499        f.write_str(label)
500    }
501}
502
503/// Token budget tracking for cost control
504///
505/// Tracks input/output token usage and enforces limits per PSP-4 --max-cost.
506#[derive(Debug, Clone, Serialize, Deserialize)]
507pub struct TokenBudget {
508    /// Maximum total tokens allowed (input + output)
509    pub max_tokens: usize,
510    /// Maximum cost in dollars (optional)
511    pub max_cost_usd: Option<f64>,
512    /// Input tokens used
513    pub input_tokens_used: usize,
514    /// Output tokens used
515    pub output_tokens_used: usize,
516    /// Estimated cost so far (in USD)
517    pub cost_usd: f64,
518    /// Cost per 1K input tokens (varies by model)
519    pub input_cost_per_1k: f64,
520    /// Cost per 1K output tokens (varies by model)
521    pub output_cost_per_1k: f64,
522}
523
524impl Default for TokenBudget {
525    fn default() -> Self {
526        Self {
527            max_tokens: 100_000, // 100K default (PSP-4 mentions 100k+ context)
528            max_cost_usd: None,  // No cost limit by default
529            input_tokens_used: 0,
530            output_tokens_used: 0,
531            cost_usd: 0.0,
532            // Default to Gemini Flash pricing (roughly)
533            input_cost_per_1k: 0.075 / 1000.0, // $0.075 per 1M = $0.000075 per 1K
534            output_cost_per_1k: 0.30 / 1000.0, // $0.30 per 1M = $0.0003 per 1K
535        }
536    }
537}
538
539impl TokenBudget {
540    /// Create a new token budget with limits
541    pub fn new(max_tokens: usize, max_cost_usd: Option<f64>) -> Self {
542        Self {
543            max_tokens,
544            max_cost_usd,
545            ..Default::default()
546        }
547    }
548
549    /// Record token usage from an LLM call
550    pub fn record_usage(&mut self, input_tokens: usize, output_tokens: usize) {
551        self.input_tokens_used += input_tokens;
552        self.output_tokens_used += output_tokens;
553
554        // Update cost estimate
555        let input_cost = (input_tokens as f64 / 1000.0) * self.input_cost_per_1k;
556        let output_cost = (output_tokens as f64 / 1000.0) * self.output_cost_per_1k;
557        self.cost_usd += input_cost + output_cost;
558    }
559
560    /// Get total tokens used
561    pub fn total_tokens_used(&self) -> usize {
562        self.input_tokens_used + self.output_tokens_used
563    }
564
565    /// Get remaining token budget
566    pub fn remaining_tokens(&self) -> usize {
567        self.max_tokens.saturating_sub(self.total_tokens_used())
568    }
569
570    /// Check if budget is exhausted
571    pub fn is_exhausted(&self) -> bool {
572        self.total_tokens_used() >= self.max_tokens
573    }
574
575    /// Check if cost limit exceeded
576    pub fn cost_exceeded(&self) -> bool {
577        if let Some(max_cost) = self.max_cost_usd {
578            self.cost_usd >= max_cost
579        } else {
580            false
581        }
582    }
583
584    /// Check if we should stop due to budget
585    pub fn should_stop(&self) -> bool {
586        self.is_exhausted() || self.cost_exceeded()
587    }
588
589    /// Get budget usage percentage
590    pub fn usage_percent(&self) -> f32 {
591        if self.max_tokens == 0 {
592            0.0
593        } else {
594            (self.total_tokens_used() as f32 / self.max_tokens as f32) * 100.0
595        }
596    }
597
598    /// Set model-specific pricing
599    pub fn set_pricing(&mut self, input_per_1k: f64, output_per_1k: f64) {
600        self.input_cost_per_1k = input_per_1k;
601        self.output_cost_per_1k = output_per_1k;
602    }
603
604    /// Get formatted summary
605    pub fn summary(&self) -> String {
606        format!(
607            "Tokens: {}/{} ({:.1}%), Cost: ${:.4}{}",
608            self.total_tokens_used(),
609            self.max_tokens,
610            self.usage_percent(),
611            self.cost_usd,
612            self.max_cost_usd
613                .map(|m| format!(" / ${:.2}", m))
614                .unwrap_or_default()
615        )
616    }
617}
618
619/// Agent context containing workspace state
620#[derive(Debug, Clone, Serialize, Deserialize)]
621pub struct AgentContext {
622    /// Working directory for the agent
623    pub working_dir: PathBuf,
624    /// Conversation history
625    pub history: Vec<AgentMessage>,
626    /// Merkle root hash of current state
627    pub merkle_root: [u8; 32],
628    /// Complexity threshold K for sub-graph approval
629    pub complexity_k: usize,
630    /// Session ID
631    pub session_id: String,
632    /// Auto-approve mode
633    pub auto_approve: bool,
634    /// Defer tests until sheaf validation (skip V_log during coding)
635    pub defer_tests: bool,
636    /// Log all LLM requests/responses to database
637    pub log_llm: bool,
638    /// Last diagnostics from LSP (for correction prompts)
639    #[serde(skip)]
640    pub last_diagnostics: Vec<lsp_types::Diagnostic>,
641    /// Token budget for cost control
642    pub token_budget: TokenBudget,
643    /// Last test output for correction prompts
644    #[serde(skip)]
645    pub last_test_output: Option<String>,
646    /// PSP-5: Execution mode (Project vs Solo)
647    #[serde(default)]
648    pub execution_mode: ExecutionMode,
649    /// PSP-5: Verifier strictness preset
650    #[serde(default)]
651    pub verifier_strictness: VerifierStrictness,
652    /// PSP-5: Active language plugins detected for this workspace
653    #[serde(default)]
654    pub active_plugins: Vec<String>,
655    /// PSP-5: Workspace state classification (existing, greenfield, or ambiguous)
656    #[serde(default)]
657    pub workspace_state: WorkspaceState,
658    /// PSP-5 Phase 2: Ownership manifest for file-to-node bindings
659    #[serde(default)]
660    pub ownership_manifest: OwnershipManifest,
661}
662
663impl Default for AgentContext {
664    fn default() -> Self {
665        Self {
666            working_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
667            history: Vec::new(),
668            merkle_root: [0u8; 32],
669            complexity_k: 5, // Default from PSP
670            session_id: uuid::Uuid::new_v4().to_string(),
671            auto_approve: false,
672            defer_tests: false,
673            log_llm: false,
674            last_diagnostics: Vec::new(),
675            token_budget: TokenBudget::default(),
676            last_test_output: None,
677            execution_mode: ExecutionMode::default(),
678            verifier_strictness: VerifierStrictness::default(),
679            active_plugins: Vec::new(),
680            workspace_state: WorkspaceState::default(),
681            ownership_manifest: OwnershipManifest::default(),
682        }
683    }
684}
685
686/// Agent message in conversation history
687#[derive(Debug, Clone, Serialize, Deserialize)]
688pub struct AgentMessage {
689    /// Role/tier of the sender
690    pub role: ModelTier,
691    /// Message content
692    pub content: String,
693    /// Timestamp
694    pub timestamp: SystemTime,
695    /// Associated node ID
696    pub node_id: Option<String>,
697}
698
699impl AgentMessage {
700    /// Create a new message
701    pub fn new(role: ModelTier, content: String) -> Self {
702        Self {
703            role,
704            content,
705            timestamp: SystemTime::now(),
706            node_id: None,
707        }
708    }
709}
710
711/// Energy components for Lyapunov calculation
712#[derive(Debug, Clone, Default, Serialize, Deserialize)]
713pub struct EnergyComponents {
714    /// Syntactic energy (from LSP diagnostics)
715    pub v_syn: f32,
716    /// Structural energy (from contract verification)
717    pub v_str: f32,
718    /// Logic energy (from test results)
719    pub v_log: f32,
720    /// Bootstrapping energy (from command exit codes)
721    pub v_boot: f32,
722    /// Sheaf validation energy (cross-node consistency)
723    pub v_sheaf: f32,
724}
725
726impl EnergyComponents {
727    /// Calculate total energy: V(x) = α*V_syn + β*V_str + γ*V_log + V_boot + V_sheaf
728    pub fn total(&self, contract: &BehavioralContract) -> f32 {
729        contract.alpha() * self.v_syn
730            + contract.beta() * self.v_str
731            + contract.gamma() * self.v_log
732            + self.v_boot
733            + self.v_sheaf
734    }
735
736    /// Calculate total energy for Solo Mode (implicit weights = 1.0)
737    /// Used when no BehavioralContract is available
738    pub fn total_simple(&self) -> f32 {
739        self.v_syn + self.v_str + self.v_log + self.v_boot + self.v_sheaf
740    }
741}
742
743// =============================================================================
744// Task Plan Types - Structured output from Architect
745// =============================================================================
746
747/// Task type classification for planning
748#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
749#[serde(rename_all = "snake_case")]
750pub enum TaskType {
751    /// Implementation code
752    #[default]
753    Code,
754    /// Shell command execution (e.g., cargo new, npm init)
755    Command,
756    /// Unit tests
757    UnitTest,
758    /// Integration/E2E tests
759    IntegrationTest,
760    /// Refactoring existing code
761    Refactor,
762    /// Documentation
763    Documentation,
764}
765
766/// Structured task plan from Architect
767/// Output as JSON for reliable parsing
768#[derive(Debug, Clone, Serialize, Deserialize)]
769pub struct TaskPlan {
770    /// List of tasks to execute
771    pub tasks: Vec<PlannedTask>,
772}
773
774impl TaskPlan {
775    /// Create an empty plan
776    pub fn new() -> Self {
777        Self { tasks: Vec::new() }
778    }
779
780    /// Get the total number of tasks
781    pub fn len(&self) -> usize {
782        self.tasks.len()
783    }
784
785    /// Check if plan is empty
786    pub fn is_empty(&self) -> bool {
787        self.tasks.is_empty()
788    }
789
790    /// Get task by ID
791    pub fn get_task(&self, id: &str) -> Option<&PlannedTask> {
792        self.tasks.iter().find(|t| t.id == id)
793    }
794
795    /// Validate the plan structure
796    pub fn validate(&self) -> Result<(), String> {
797        if self.tasks.is_empty() {
798            return Err("Plan has no tasks".to_string());
799        }
800
801        // Check for duplicate IDs
802        let mut seen_ids = std::collections::HashSet::new();
803        for task in &self.tasks {
804            if !seen_ids.insert(&task.id) {
805                return Err(format!("Duplicate task ID: {}", task.id));
806            }
807            if task.goal.is_empty() {
808                return Err(format!("Task {} has empty goal", task.id));
809            }
810        }
811
812        // Check for invalid dependencies
813        for task in &self.tasks {
814            for dep in &task.dependencies {
815                if !seen_ids.contains(dep) {
816                    return Err(format!("Task {} has unknown dependency: {}", task.id, dep));
817                }
818            }
819        }
820
821        // PSP-5: Check for duplicate output_files across tasks (ownership closure)
822        let mut file_owners: std::collections::HashMap<&str, &str> =
823            std::collections::HashMap::new();
824        for task in &self.tasks {
825            for file in &task.output_files {
826                if let Some(prev_owner) = file_owners.insert(file.as_str(), task.id.as_str()) {
827                    return Err(format!(
828                        "Ownership violation in plan: file '{}' claimed by both '{}' and '{}'. \
829                         Each output file must appear in exactly one task's output_files.",
830                        file, prev_owner, task.id
831                    ));
832                }
833            }
834        }
835
836        // PSP-7: Cycle detection via topological sort (Kahn's algorithm)
837        {
838            let id_to_idx: std::collections::HashMap<&str, usize> = self
839                .tasks
840                .iter()
841                .enumerate()
842                .map(|(i, t)| (t.id.as_str(), i))
843                .collect();
844            let n = self.tasks.len();
845            let mut in_degree = vec![0usize; n];
846            let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
847            for (i, task) in self.tasks.iter().enumerate() {
848                for dep in &task.dependencies {
849                    if let Some(&dep_idx) = id_to_idx.get(dep.as_str()) {
850                        adj[dep_idx].push(i);
851                        in_degree[i] += 1;
852                    }
853                }
854            }
855            let mut queue: std::collections::VecDeque<usize> = in_degree
856                .iter()
857                .enumerate()
858                .filter_map(|(i, &d)| if d == 0 { Some(i) } else { None })
859                .collect();
860            let mut visited = 0usize;
861            while let Some(node) = queue.pop_front() {
862                visited += 1;
863                for &next in &adj[node] {
864                    in_degree[next] -= 1;
865                    if in_degree[next] == 0 {
866                        queue.push_back(next);
867                    }
868                }
869            }
870            if visited != n {
871                return Err("Plan contains a dependency cycle".to_string());
872            }
873        }
874
875        // PSP-7: Implicit dependency enforcement — if task A reads a file that
876        // task B produces (context_files ∩ output_files), A must depend on B.
877        for task in &self.tasks {
878            for ctx_file in &task.context_files {
879                if let Some(&owner) = file_owners.get(ctx_file.as_str()) {
880                    if owner != task.id && !task.dependencies.iter().any(|d| d == owner) {
881                        return Err(format!(
882                            "Task '{}' reads '{}' produced by '{}' but does not declare it as a dependency",
883                            task.id, ctx_file, owner
884                        ));
885                    }
886                }
887            }
888        }
889
890        // PSP-7: Test-task dependency inference via plugin test_file_patterns().
891        // If a task produces only test files (matching some plugin's test patterns),
892        // it must depend on any task that produces non-test source files.
893        let registry = crate::plugin::PluginRegistry::new();
894        let all_test_patterns: Vec<&str> = registry
895            .all()
896            .iter()
897            .flat_map(|p| p.test_file_patterns().iter().copied())
898            .collect();
899        if !all_test_patterns.is_empty() {
900            let is_test_file = |path: &str| -> bool {
901                all_test_patterns
902                    .iter()
903                    .any(|pat| glob_matches_simple(pat, path))
904            };
905            // Identify test-only tasks and source tasks
906            let source_task_ids: Vec<&str> = self
907                .tasks
908                .iter()
909                .filter(|t| {
910                    !t.output_files.is_empty() && t.output_files.iter().any(|f| !is_test_file(f))
911                })
912                .map(|t| t.id.as_str())
913                .collect();
914            for task in &self.tasks {
915                if task.output_files.is_empty() {
916                    continue;
917                }
918                let all_tests = task.output_files.iter().all(|f| is_test_file(f));
919                if !all_tests {
920                    continue;
921                }
922                // This is a test-only task — it should depend on at least one source task
923                for &src_id in &source_task_ids {
924                    if src_id != task.id && !task.dependencies.iter().any(|d| d == src_id) {
925                        return Err(format!(
926                            "Test task '{}' produces only test files but does not depend on source task '{}'",
927                            task.id, src_id
928                        ));
929                    }
930                }
931            }
932        }
933
934        Ok(())
935    }
936}
937
938/// Simple glob matching for test file patterns.
939///
940/// Supports `*` (any within component) and `**` (any path segment).
941/// This is intentionally minimal — only used for plan validation heuristics.
942fn glob_matches_simple(pattern: &str, path: &str) -> bool {
943    let pat_parts: Vec<&str> = pattern.split('/').collect();
944    let path_parts: Vec<&str> = path.split('/').collect();
945    glob_match_parts(&pat_parts, &path_parts)
946}
947
948fn glob_match_parts(pat: &[&str], path: &[&str]) -> bool {
949    if pat.is_empty() {
950        return path.is_empty();
951    }
952    if pat[0] == "**" {
953        // ** matches zero or more path segments
954        for i in 0..=path.len() {
955            if glob_match_parts(&pat[1..], &path[i..]) {
956                return true;
957            }
958        }
959        return false;
960    }
961    if path.is_empty() {
962        return false;
963    }
964    if glob_match_component(pat[0], path[0]) {
965        glob_match_parts(&pat[1..], &path[1..])
966    } else {
967        false
968    }
969}
970
971fn glob_match_component(pattern: &str, component: &str) -> bool {
972    // Simple wildcard matching within a single path component
973    if pattern == "*" {
974        return true;
975    }
976    if !pattern.contains('*') {
977        return pattern == component;
978    }
979    let parts: Vec<&str> = pattern.split('*').collect();
980    let mut pos = 0;
981    for (i, part) in parts.iter().enumerate() {
982        if part.is_empty() {
983            continue;
984        }
985        if let Some(found) = component[pos..].find(part) {
986            if i == 0 && found != 0 {
987                return false; // First part must match at start
988            }
989            pos += found + part.len();
990        } else {
991            return false;
992        }
993    }
994    if let Some(last) = parts.last() {
995        if !last.is_empty() {
996            return component.ends_with(last);
997        }
998    }
999    true
1000}
1001
1002impl Default for TaskPlan {
1003    fn default() -> Self {
1004        Self::new()
1005    }
1006}
1007
1008/// A planned task from the Architect
1009#[derive(Debug, Clone, Serialize, Deserialize)]
1010pub struct PlannedTask {
1011    /// Unique task identifier (e.g., "task_1", "test_auth")
1012    pub id: String,
1013    /// Human-readable goal description
1014    pub goal: String,
1015    /// Files to read for context
1016    #[serde(default)]
1017    pub context_files: Vec<String>,
1018    /// Files to create or modify
1019    #[serde(default)]
1020    pub output_files: Vec<String>,
1021    /// Task IDs this depends on (must complete first)
1022    #[serde(default)]
1023    pub dependencies: Vec<String>,
1024    /// Type of task
1025    #[serde(default)]
1026    pub task_type: TaskType,
1027    /// Behavioral contract for this task
1028    #[serde(default)]
1029    pub contract: PlannedContract,
1030    /// Command contract (only for TaskType::Command)
1031    #[serde(default)]
1032    pub command_contract: Option<CommandContract>,
1033    /// PSP-5: Node class (Interface / Implementation / Integration)
1034    #[serde(default)]
1035    pub node_class: NodeClass,
1036    /// Declared dependency expectations (packages, setup, toolchain).
1037    #[serde(default)]
1038    pub dependency_expectations: DependencyExpectation,
1039}
1040
1041impl PlannedTask {
1042    /// Create a simple task
1043    pub fn new(id: impl Into<String>, goal: impl Into<String>) -> Self {
1044        Self {
1045            id: id.into(),
1046            goal: goal.into(),
1047            context_files: Vec::new(),
1048            output_files: Vec::new(),
1049            dependencies: Vec::new(),
1050            task_type: TaskType::Code,
1051            contract: PlannedContract::default(),
1052            command_contract: None,
1053            node_class: NodeClass::default(),
1054            dependency_expectations: DependencyExpectation::default(),
1055        }
1056    }
1057
1058    /// Convert to SRBNNode
1059    pub fn to_srbn_node(&self, tier: ModelTier) -> SRBNNode {
1060        let mut node = SRBNNode::new(self.id.clone(), self.goal.clone(), tier);
1061        node.context_files = self.context_files.iter().map(PathBuf::from).collect();
1062        node.output_targets = self.output_files.iter().map(PathBuf::from).collect();
1063        node.contract = self.contract.to_behavioral_contract();
1064        node.node_class = self.node_class;
1065        node.dependency_expectations = self.dependency_expectations.clone();
1066        node
1067    }
1068}
1069
1070/// Contract specified in the plan
1071#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1072pub struct PlannedContract {
1073    /// Required public API signature
1074    #[serde(default)]
1075    pub interface_signature: Option<String>,
1076    /// Semantic constraints
1077    #[serde(default)]
1078    pub invariants: Vec<String>,
1079    /// Patterns to avoid
1080    #[serde(default)]
1081    pub forbidden_patterns: Vec<String>,
1082    /// Test cases with criticality
1083    #[serde(default)]
1084    pub tests: Vec<PlannedTest>,
1085}
1086
1087impl PlannedContract {
1088    /// Convert to BehavioralContract
1089    pub fn to_behavioral_contract(&self) -> BehavioralContract {
1090        BehavioralContract {
1091            interface_signature: self.interface_signature.clone().unwrap_or_default(),
1092            invariants: self.invariants.clone(),
1093            forbidden_patterns: self.forbidden_patterns.clone(),
1094            weighted_tests: self
1095                .tests
1096                .iter()
1097                .map(|t| WeightedTest {
1098                    test_name: t.name.clone(),
1099                    criticality: t.criticality,
1100                })
1101                .collect(),
1102            energy_weights: (1.0, 0.5, 2.0),
1103        }
1104    }
1105}
1106
1107/// A test case in the plan
1108#[derive(Debug, Clone, Serialize, Deserialize)]
1109pub struct PlannedTest {
1110    /// Test name or pattern
1111    pub name: String,
1112    /// Criticality level
1113    #[serde(default = "default_criticality")]
1114    pub criticality: Criticality,
1115}
1116
1117fn default_criticality() -> Criticality {
1118    Criticality::High
1119}
1120
1121/// Contract for command-type tasks (shell commands)
1122/// Defines expected outcomes for V_boot calculation
1123#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1124pub struct CommandContract {
1125    /// The shell command to execute
1126    pub command: String,
1127    /// Expected exit code (default: 0)
1128    #[serde(default)]
1129    pub expected_exit_code: i32,
1130    /// Files that should exist after command completes
1131    #[serde(default)]
1132    pub expected_files: Vec<String>,
1133    /// Patterns that should NOT appear in stderr
1134    #[serde(default)]
1135    pub forbidden_stderr_patterns: Vec<String>,
1136    /// Working directory for the command (relative to project root)
1137    #[serde(default)]
1138    pub working_dir: Option<String>,
1139}
1140
1141impl CommandContract {
1142    /// Create a new command contract
1143    pub fn new(command: impl Into<String>) -> Self {
1144        Self {
1145            command: command.into(),
1146            expected_exit_code: 0,
1147            expected_files: Vec::new(),
1148            forbidden_stderr_patterns: Vec::new(),
1149            working_dir: None,
1150        }
1151    }
1152
1153    /// Calculate V_boot energy from command result
1154    pub fn calculate_energy(&self, exit_code: i32, stderr: &str, existing_files: &[String]) -> f32 {
1155        let mut energy = 0.0;
1156
1157        // Exit code mismatch
1158        if exit_code != self.expected_exit_code {
1159            energy += 1.0;
1160        }
1161
1162        // Missing expected files
1163        for expected in &self.expected_files {
1164            if !existing_files.contains(expected) {
1165                energy += 0.5;
1166            }
1167        }
1168
1169        // Forbidden stderr patterns
1170        for pattern in &self.forbidden_stderr_patterns {
1171            if stderr.contains(pattern) {
1172                energy += 0.3;
1173            }
1174        }
1175
1176        energy
1177    }
1178}
1179
1180// =============================================================================
1181// PSP-000005 Types — Project-First Execution Model
1182// =============================================================================
1183
1184/// PSP-5: Execution mode for the runtime
1185///
1186/// Project mode is the default. Solo mode only activates on explicit single-file
1187/// intent keywords or via `--single-file` CLI flag.
1188#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1189#[serde(rename_all = "snake_case")]
1190pub enum ExecutionMode {
1191    /// Default: treat task as a multi-file project
1192    #[default]
1193    Project,
1194    /// Explicit single-file execution
1195    Solo,
1196}
1197
1198impl std::fmt::Display for ExecutionMode {
1199    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1200        match self {
1201            ExecutionMode::Project => write!(f, "project"),
1202            ExecutionMode::Solo => write!(f, "solo"),
1203        }
1204    }
1205}
1206
1207/// PSP-5: Workspace state classification
1208///
1209/// Determined at session start by inspecting the working directory for project
1210/// metadata and cross-referencing with the task description. Drives the
1211/// init/bootstrap/context strategy for the rest of the session.
1212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1213#[serde(rename_all = "snake_case")]
1214pub enum WorkspaceState {
1215    /// Directory contains recognized project metadata (Cargo.toml, pyproject.toml, etc.)
1216    ExistingProject {
1217        /// Plugin names detected in the workspace
1218        plugins: Vec<String>,
1219    },
1220    /// Empty or non-project directory; language inferred from the task description
1221    Greenfield {
1222        /// Language inferred from task keywords (e.g. "rust", "python")
1223        inferred_lang: Option<String>,
1224    },
1225    /// Directory has files but no recognized project metadata and no language inferred
1226    #[default]
1227    Ambiguous,
1228}
1229
1230impl std::fmt::Display for WorkspaceState {
1231    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1232        match self {
1233            WorkspaceState::ExistingProject { plugins } => {
1234                write!(f, "existing-project({})", plugins.join(", "))
1235            }
1236            WorkspaceState::Greenfield { inferred_lang } => {
1237                write!(
1238                    f,
1239                    "greenfield({})",
1240                    inferred_lang.as_deref().unwrap_or("unknown")
1241                )
1242            }
1243            WorkspaceState::Ambiguous => write!(f, "ambiguous"),
1244        }
1245    }
1246}
1247
1248/// PSP-5: Node class distinguishing interface, implementation, and integration nodes
1249///
1250/// - **Interface** nodes define exported signatures, schemas, and verifier scope.
1251/// - **Implementation** nodes operate on node-owned files plus sealed interfaces.
1252/// - **Integration** nodes reconcile cross-owner or cross-plugin boundaries.
1253#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1254#[serde(rename_all = "snake_case")]
1255pub enum NodeClass {
1256    /// Defines exported signatures, schemas, ownership manifests
1257    Interface,
1258    /// Operates on node-owned files plus adjacent sealed interfaces
1259    #[default]
1260    Implementation,
1261    /// Reconciles cross-owner or cross-plugin boundaries
1262    Integration,
1263}
1264
1265impl std::fmt::Display for NodeClass {
1266    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1267        match self {
1268            NodeClass::Interface => write!(f, "interface"),
1269            NodeClass::Implementation => write!(f, "implementation"),
1270            NodeClass::Integration => write!(f, "integration"),
1271        }
1272    }
1273}
1274
1275/// PSP-5: Verifier strictness presets
1276///
1277/// Controls which verification stages are required for stability.
1278#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1279#[serde(rename_all = "snake_case")]
1280pub enum VerifierStrictness {
1281    /// Default: compilation + tests required, warnings allowed
1282    #[default]
1283    Default,
1284    /// Strict: compilation + tests + linting (e.g. clippy -D warnings)
1285    Strict,
1286    /// Minimal: syntax/parse check only, no tests required
1287    Minimal,
1288}
1289
1290// =============================================================================
1291// PSP-5 Phase 2: Ownership Manifests
1292// =============================================================================
1293
1294/// PSP-5 Phase 2: A single ownership entry mapping a file to its owning node
1295#[derive(Debug, Clone, Serialize, Deserialize)]
1296pub struct OwnershipEntry {
1297    /// The node ID that owns this file
1298    pub owner_node_id: String,
1299    /// The language plugin responsible for this file
1300    pub owner_plugin: String,
1301    /// The node class of the owning node
1302    pub node_class: NodeClass,
1303}
1304
1305/// PSP-5 Phase 2: Ownership manifest tracking file-to-node bindings
1306///
1307/// Enforces ownership closure: a node may only modify files it owns,
1308/// unless it is an Integration node (which may cross ownership boundaries).
1309#[derive(Debug, Clone, Serialize, Deserialize)]
1310pub struct OwnershipManifest {
1311    /// File path → ownership entry
1312    entries: std::collections::HashMap<String, OwnershipEntry>,
1313    /// Maximum files a single node may touch (bounded fanout)
1314    #[serde(default = "OwnershipManifest::default_fanout")]
1315    fanout_limit: usize,
1316}
1317
1318impl Default for OwnershipManifest {
1319    fn default() -> Self {
1320        Self::new()
1321    }
1322}
1323
1324impl OwnershipManifest {
1325    /// Create a new empty manifest with the default fanout limit
1326    pub fn new() -> Self {
1327        Self {
1328            entries: std::collections::HashMap::new(),
1329            fanout_limit: Self::default_fanout(),
1330        }
1331    }
1332
1333    /// Create with a custom fanout limit
1334    pub fn with_fanout_limit(limit: usize) -> Self {
1335        Self {
1336            entries: std::collections::HashMap::new(),
1337            fanout_limit: limit,
1338        }
1339    }
1340
1341    fn default_fanout() -> usize {
1342        20
1343    }
1344
1345    /// Assign a file to an owning node.
1346    ///
1347    /// The path is normalized before insertion so that `src/main.rs` and
1348    /// `./src/main.rs` resolve to the same key.
1349    pub fn assign(
1350        &mut self,
1351        path: impl Into<String>,
1352        owner_node_id: impl Into<String>,
1353        owner_plugin: impl Into<String>,
1354        node_class: NodeClass,
1355    ) {
1356        let key = crate::path::normalize_path_key(&path.into()).unwrap_or_default();
1357        if key.is_empty() {
1358            return; // silently skip invalid paths
1359        }
1360        self.entries.insert(
1361            key,
1362            OwnershipEntry {
1363                owner_node_id: owner_node_id.into(),
1364                owner_plugin: owner_plugin.into(),
1365                node_class,
1366            },
1367        );
1368    }
1369
1370    /// Look up the owner of a file path.
1371    ///
1372    /// The path is normalized before lookup.
1373    pub fn owner_of(&self, path: &str) -> Option<&OwnershipEntry> {
1374        let key = crate::path::normalize_path_key(path)?;
1375        self.entries.get(&key)
1376    }
1377
1378    /// List all files owned by a specific node
1379    pub fn files_owned_by(&self, node_id: &str) -> Vec<&str> {
1380        self.entries
1381            .iter()
1382            .filter(|(_, entry)| entry.owner_node_id == node_id)
1383            .map(|(path, _)| path.as_str())
1384            .collect()
1385    }
1386
1387    /// Get the total number of entries
1388    pub fn len(&self) -> usize {
1389        self.entries.len()
1390    }
1391
1392    /// Check if the manifest is empty
1393    pub fn is_empty(&self) -> bool {
1394        self.entries.is_empty()
1395    }
1396
1397    /// Get the fanout limit
1398    pub fn fanout_limit(&self) -> usize {
1399        self.fanout_limit
1400    }
1401
1402    /// Validate that a bundle respects ownership boundaries
1403    ///
1404    /// Rules:
1405    /// - **Implementation** nodes: all paths must be owned by this node
1406    /// - **Interface** nodes: all paths must be owned by this node
1407    /// - **Integration** nodes: paths may cross ownership boundaries
1408    /// - Fanout limit: bundle must not exceed max files per node
1409    /// - Unregistered paths (new files) are allowed and will be auto-assigned
1410    pub fn validate_bundle(
1411        &self,
1412        bundle: &ArtifactBundle,
1413        node_id: &str,
1414        node_class: NodeClass,
1415    ) -> Result<(), String> {
1416        let artifact_count = bundle.len();
1417
1418        // Check fanout limit
1419        if artifact_count > self.fanout_limit {
1420            return Err(format!(
1421                "Bundle has {} artifacts, exceeding fanout limit of {}",
1422                artifact_count, self.fanout_limit
1423            ));
1424        }
1425
1426        // Integration nodes can cross ownership boundaries
1427        if node_class == NodeClass::Integration {
1428            return Ok(());
1429        }
1430
1431        // For Interface and Implementation nodes, check ownership
1432        for op in &bundle.artifacts {
1433            let raw_path = op.path();
1434            let key =
1435                crate::path::normalize_path_key(raw_path).unwrap_or_else(|| raw_path.to_string());
1436            if let Some(entry) = self.entries.get(&key) {
1437                if entry.owner_node_id != node_id {
1438                    return Err(format!(
1439                        "Ownership violation: file '{}' is owned by node '{}', \
1440                         but node '{}' ({}) attempted to modify it. \
1441                         Only Integration nodes may cross ownership boundaries.",
1442                        raw_path, entry.owner_node_id, node_id, node_class
1443                    ));
1444                }
1445            }
1446            // Unregistered paths (new files) are allowed — they'll be assigned to this node
1447        }
1448
1449        Ok(())
1450    }
1451
1452    /// Auto-assign unregistered paths from a bundle to a node
1453    ///
1454    /// Called after validate_bundle succeeds, this registers any new paths
1455    /// in the manifest so future nodes can't claim them.
1456    pub fn assign_new_paths(
1457        &mut self,
1458        bundle: &ArtifactBundle,
1459        node_id: &str,
1460        owner_plugin: &str,
1461        node_class: NodeClass,
1462    ) {
1463        for op in &bundle.artifacts {
1464            let raw_path = op.path();
1465            let key =
1466                crate::path::normalize_path_key(raw_path).unwrap_or_else(|| raw_path.to_string());
1467            if !self.entries.contains_key(&key) {
1468                self.assign(raw_path, node_id, owner_plugin, node_class);
1469            }
1470        }
1471    }
1472}
1473
1474/// PSP-5: A single artifact operation within an artifact bundle
1475///
1476/// Each operation represents one file mutation: either a full write or a diff patch.
1477#[derive(Debug, Clone, Serialize, Deserialize)]
1478#[serde(tag = "operation", rename_all = "snake_case")]
1479pub enum ArtifactOperation {
1480    /// Write the full file contents
1481    Write {
1482        /// Relative path within the workspace
1483        path: String,
1484        /// Full file content
1485        content: String,
1486    },
1487    /// Apply a unified diff patch
1488    Diff {
1489        /// Relative path within the workspace
1490        path: String,
1491        /// Unified diff content
1492        patch: String,
1493    },
1494    /// Delete a file from the workspace
1495    Delete {
1496        /// Relative path to delete
1497        path: String,
1498    },
1499    /// Move/rename a file within the workspace
1500    Move {
1501        /// Current relative path
1502        from: String,
1503        /// New relative path
1504        to: String,
1505    },
1506}
1507
1508impl ArtifactOperation {
1509    /// Get the primary file path this operation targets
1510    pub fn path(&self) -> &str {
1511        match self {
1512            ArtifactOperation::Write { path, .. } => path,
1513            ArtifactOperation::Diff { path, .. } => path,
1514            ArtifactOperation::Delete { path } => path,
1515            ArtifactOperation::Move { from, .. } => from,
1516        }
1517    }
1518
1519    /// Check if this is a write (new file) operation
1520    pub fn is_write(&self) -> bool {
1521        matches!(self, ArtifactOperation::Write { .. })
1522    }
1523
1524    /// Check if this is a diff (patch) operation
1525    pub fn is_diff(&self) -> bool {
1526        matches!(self, ArtifactOperation::Diff { .. })
1527    }
1528
1529    /// Check if this is a delete operation
1530    pub fn is_delete(&self) -> bool {
1531        matches!(self, ArtifactOperation::Delete { .. })
1532    }
1533
1534    /// Check if this is a move/rename operation
1535    pub fn is_move(&self) -> bool {
1536        matches!(self, ArtifactOperation::Move { .. })
1537    }
1538}
1539
1540/// PSP-5: Multi-artifact bundle from the Actuator
1541///
1542/// A node response containing one or more file operations applied as a unit.
1543/// The orchestrator SHALL parse all operations before mutating the workspace
1544/// and SHALL fail atomically if any operation is invalid.
1545#[derive(Debug, Clone, Serialize, Deserialize)]
1546pub struct ArtifactBundle {
1547    /// File operations to apply
1548    pub artifacts: Vec<ArtifactOperation>,
1549    /// Optional commands to run after file operations
1550    #[serde(default)]
1551    pub commands: Vec<String>,
1552}
1553
1554impl ArtifactBundle {
1555    /// Create an empty bundle
1556    pub fn new() -> Self {
1557        Self {
1558            artifacts: Vec::new(),
1559            commands: Vec::new(),
1560        }
1561    }
1562
1563    /// Number of file operations
1564    pub fn len(&self) -> usize {
1565        self.artifacts.len()
1566    }
1567
1568    /// Check if bundle is empty
1569    pub fn is_empty(&self) -> bool {
1570        self.artifacts.is_empty()
1571    }
1572
1573    /// Get all unique file paths affected by this bundle
1574    pub fn affected_paths(&self) -> Vec<&str> {
1575        let mut paths: Vec<&str> = self.artifacts.iter().map(|a| a.path()).collect();
1576        // For Move operations, also include the destination path
1577        for op in &self.artifacts {
1578            if let ArtifactOperation::Move { to, .. } = op {
1579                paths.push(to.as_str());
1580            }
1581        }
1582        paths.sort();
1583        paths.dedup();
1584        paths
1585    }
1586
1587    /// Count of file writes (new files)
1588    pub fn writes_count(&self) -> usize {
1589        self.artifacts.iter().filter(|a| a.is_write()).count()
1590    }
1591
1592    /// Count of file diffs (patches)
1593    pub fn diffs_count(&self) -> usize {
1594        self.artifacts.iter().filter(|a| a.is_diff()).count()
1595    }
1596
1597    /// Count of file deletes
1598    pub fn deletes_count(&self) -> usize {
1599        self.artifacts.iter().filter(|a| a.is_delete()).count()
1600    }
1601
1602    /// Count of file moves
1603    pub fn moves_count(&self) -> usize {
1604        self.artifacts.iter().filter(|a| a.is_move()).count()
1605    }
1606
1607    /// Validate the bundle: checks for empty paths and duplicate targets
1608    pub fn validate(&self) -> Result<(), String> {
1609        if self.artifacts.is_empty() {
1610            return Err("Artifact bundle is empty".to_string());
1611        }
1612
1613        for (i, op) in self.artifacts.iter().enumerate() {
1614            // Validate the primary path
1615            Self::validate_path(op.path(), i)?;
1616
1617            // For Move operations, also validate the destination path
1618            if let ArtifactOperation::Move { to, .. } = op {
1619                if to.is_empty() {
1620                    return Err(format!("Artifact {} (move) has empty destination path", i));
1621                }
1622                Self::validate_path(to, i)?;
1623            }
1624        }
1625
1626        Ok(())
1627    }
1628
1629    /// Validate a single path: reject empty, absolute, and traversal paths.
1630    ///
1631    /// Uses the canonical `normalize_artifact_path` utility so that all path
1632    /// consumers (bundle validation, ownership manifest, policy checks) agree
1633    /// on path identity.
1634    fn validate_path(path: &str, artifact_index: usize) -> Result<(), String> {
1635        use crate::path::{normalize_artifact_path, PathError};
1636        match normalize_artifact_path(path) {
1637            Ok(_) => Ok(()),
1638            Err(PathError::Empty) => Err(format!("Artifact {} has empty path", artifact_index)),
1639            Err(PathError::Absolute(_)) => Err(format!(
1640                "Artifact {} has absolute path '{}', must be relative",
1641                artifact_index, path
1642            )),
1643            Err(PathError::Escapes(_)) => Err(format!(
1644                "Artifact {} has path traversal in '{}'",
1645                artifact_index, path
1646            )),
1647            Err(PathError::Invalid(_)) => Err(format!(
1648                "Artifact {} has invalid path '{}'",
1649                artifact_index, path
1650            )),
1651        }
1652    }
1653}
1654
1655impl Default for ArtifactBundle {
1656    fn default() -> Self {
1657        Self::new()
1658    }
1659}
1660
1661/// PSP-5: Structured verification result from a plugin-driven verifier
1662///
1663/// Holds the outcome of running syntax checks, build, tests, and lint
1664/// through the active language plugin's toolchain.
1665#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1666pub struct VerificationResult {
1667    /// Whether the syntax/type check passed
1668    pub syntax_ok: bool,
1669    /// Whether the build succeeded
1670    pub build_ok: bool,
1671    /// Whether tests passed
1672    pub tests_ok: bool,
1673    /// Whether lint passed (only in Strict mode)
1674    pub lint_ok: bool,
1675    /// Number of diagnostics from LSP / compiler
1676    pub diagnostics_count: usize,
1677    /// Number of tests passed
1678    pub tests_passed: usize,
1679    /// Number of tests failed
1680    pub tests_failed: usize,
1681    /// Summary output from verification tools
1682    pub summary: String,
1683    /// Raw tool output (for correction prompts)
1684    pub raw_output: Option<String>,
1685    /// Whether verification ran in degraded mode (missing tools)
1686    pub degraded: bool,
1687    /// Reason for degraded mode
1688    pub degraded_reason: Option<String>,
1689    /// Per-stage outcomes with sensor status
1690    #[serde(default)]
1691    pub stage_outcomes: Vec<StageOutcome>,
1692}
1693
1694impl VerificationResult {
1695    /// Check if all verification stages passed
1696    pub fn all_passed(&self) -> bool {
1697        self.syntax_ok && self.build_ok && self.tests_ok && !self.degraded
1698    }
1699
1700    /// Create a degraded result when tools are unavailable
1701    pub fn degraded(reason: impl Into<String>) -> Self {
1702        Self {
1703            degraded: true,
1704            degraded_reason: Some(reason.into()),
1705            summary: "Verification ran in degraded mode".to_string(),
1706            ..Default::default()
1707        }
1708    }
1709
1710    /// Check whether any stage ran with a fallback or unavailable sensor.
1711    ///
1712    /// When true the caller should NOT treat a passing result as a genuine
1713    /// stability proof — the energy surface was only partially observable.
1714    pub fn has_degraded_stages(&self) -> bool {
1715        self.stage_outcomes
1716            .iter()
1717            .any(|s| !matches!(s.sensor_status, SensorStatus::Available))
1718    }
1719
1720    /// Collect human-readable descriptions of all degraded stages.
1721    pub fn degraded_stage_reasons(&self) -> Vec<String> {
1722        self.stage_outcomes
1723            .iter()
1724            .filter_map(|s| match &s.sensor_status {
1725                SensorStatus::Available => None,
1726                SensorStatus::Fallback { actual, reason } => Some(format!(
1727                    "{}: used fallback '{}' ({})",
1728                    s.stage, actual, reason
1729                )),
1730                SensorStatus::Unavailable { reason } => {
1731                    Some(format!("{}: unavailable ({})", s.stage, reason))
1732                }
1733            })
1734            .collect()
1735    }
1736}
1737
1738/// Sensor availability status for a single verification stage.
1739///
1740/// Tells downstream consumers whether the preferred tool was available,
1741/// a fallback was used, or the stage had no usable sensor at all.
1742#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1743pub enum SensorStatus {
1744    /// The preferred tool ran successfully.
1745    Available,
1746    /// A fallback tool was used instead of the primary.
1747    Fallback {
1748        /// Name of the tool that actually ran.
1749        actual: String,
1750        /// Why the primary was not available.
1751        reason: String,
1752    },
1753    /// No tool was available for this stage.
1754    Unavailable {
1755        /// What went wrong.
1756        reason: String,
1757    },
1758}
1759
1760impl std::fmt::Display for SensorStatus {
1761    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1762        match self {
1763            SensorStatus::Available => write!(f, "available"),
1764            SensorStatus::Fallback { actual, .. } => write!(f, "fallback({})", actual),
1765            SensorStatus::Unavailable { reason } => write!(f, "unavailable({})", reason),
1766        }
1767    }
1768}
1769
1770/// Outcome of a single verification stage (syntax, build, test, lint).
1771#[derive(Debug, Clone, Serialize, Deserialize)]
1772pub struct StageOutcome {
1773    /// Which verification stage this covers.
1774    pub stage: String,
1775    /// Whether the stage passed.
1776    pub passed: bool,
1777    /// Sensor status for this stage.
1778    pub sensor_status: SensorStatus,
1779    /// Optional output captured from the tool.
1780    pub output: Option<String>,
1781}
1782
1783// =============================================================================
1784// PSP-5 Phase 3: Context Provenance, Structural Digests, Restriction Maps
1785// =============================================================================
1786
1787/// PSP-5 Phase 3: Kind of structural artifact being digested
1788#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1789#[serde(rename_all = "snake_case")]
1790pub enum ArtifactKind {
1791    /// Exported function/trait/class signature
1792    Signature,
1793    /// API schema (JSON schema, protobuf, etc.)
1794    Schema,
1795    /// Module-level symbol inventory
1796    SymbolInventory,
1797    /// Interface seal for dependency checking
1798    InterfaceSeal,
1799}
1800
1801impl std::fmt::Display for ArtifactKind {
1802    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1803        match self {
1804            ArtifactKind::Signature => write!(f, "signature"),
1805            ArtifactKind::Schema => write!(f, "schema"),
1806            ArtifactKind::SymbolInventory => write!(f, "symbol_inventory"),
1807            ArtifactKind::InterfaceSeal => write!(f, "interface_seal"),
1808        }
1809    }
1810}
1811
1812/// PSP-5 Phase 3: Hash of a compile-critical structural artifact
1813///
1814/// Structural digests represent machine-verifiable content (exported signatures,
1815/// schemas, symbol inventories) that nodes depend on. When the digest changes,
1816/// dependent nodes must re-verify.
1817#[derive(Debug, Clone, Serialize, Deserialize)]
1818pub struct StructuralDigest {
1819    /// Unique digest identifier
1820    pub digest_id: String,
1821    /// What kind of structural artifact this is
1822    pub artifact_kind: ArtifactKind,
1823    /// SHA-256 hash of the artifact content
1824    pub hash: [u8; 32],
1825    /// Node that produced this artifact
1826    pub source_node_id: String,
1827    /// Source file path (relative to workspace)
1828    pub source_path: String,
1829    /// Monotonically increasing version
1830    pub version: u32,
1831}
1832
1833impl StructuralDigest {
1834    /// Create a new digest from raw content
1835    pub fn from_content(
1836        source_node_id: impl Into<String>,
1837        source_path: impl Into<String>,
1838        artifact_kind: ArtifactKind,
1839        content: &[u8],
1840    ) -> Self {
1841        use std::collections::hash_map::DefaultHasher;
1842        use std::hash::{Hash, Hasher};
1843
1844        let mut sha = [0u8; 32];
1845        // Use a simple hash for the digest (real impl would use SHA-256)
1846        let mut hasher = DefaultHasher::new();
1847        content.hash(&mut hasher);
1848        let h = hasher.finish().to_le_bytes();
1849        sha[..8].copy_from_slice(&h);
1850
1851        let node_id = source_node_id.into();
1852        let path = source_path.into();
1853        let digest_id = format!("{}:{}:{}", node_id, path, artifact_kind);
1854
1855        Self {
1856            digest_id,
1857            artifact_kind,
1858            hash: sha,
1859            source_node_id: node_id,
1860            source_path: path,
1861            version: 1,
1862        }
1863    }
1864
1865    /// Check if this digest matches another (same content hash)
1866    pub fn matches(&self, other: &Self) -> bool {
1867        self.hash == other.hash
1868    }
1869}
1870
1871/// PSP-5 Phase 3: Kind of semantic summary being digested
1872#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1873#[serde(rename_all = "snake_case")]
1874pub enum SummaryKind {
1875    /// Intent summary from parent/architect
1876    IntentSummary,
1877    /// Verifier results summary
1878    VerifierResults,
1879    /// Design rationale
1880    DesignRationale,
1881}
1882
1883/// PSP-5 Phase 3: Condensed summary with hash for provenance tracking
1884///
1885/// Summary digests represent advisory semantic content (intent summaries,
1886/// verifier results) whose hashes are recorded for reproducibility.
1887#[derive(Debug, Clone, Serialize, Deserialize)]
1888pub struct SummaryDigest {
1889    /// Unique identifier
1890    pub digest_id: String,
1891    /// Node that produced this summary
1892    pub source_node_id: String,
1893    /// What kind of summary this is
1894    pub kind: SummaryKind,
1895    /// SHA-256 hash of the summary content
1896    pub hash: [u8; 32],
1897    /// Byte length of original content
1898    pub original_byte_length: usize,
1899    /// The condensed summary text
1900    pub summary_text: String,
1901}
1902
1903/// PSP-5 Phase 3: Context budget controlling node context assembly
1904#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1905pub struct ContextBudget {
1906    /// Maximum total bytes for the context package
1907    pub byte_limit: usize,
1908    /// Maximum number of files to include
1909    pub file_count_limit: usize,
1910}
1911
1912impl Default for ContextBudget {
1913    fn default() -> Self {
1914        Self {
1915            byte_limit: 100 * 1024, // 100KB default
1916            file_count_limit: 20,
1917        }
1918    }
1919}
1920
1921/// PSP-5 Phase 3: Restriction map defining a node's context boundary
1922///
1923/// The restriction map bounds what a node can see. It is derived from the
1924/// task graph, ownership manifest, and parent scope. A node SHALL NOT receive
1925/// the full repository by default.
1926#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1927pub struct RestrictionMap {
1928    /// The node this restriction applies to
1929    pub node_id: String,
1930    /// Context budget (byte and file-count limits)
1931    #[serde(default)]
1932    pub budget: ContextBudget,
1933    /// Files the node owns and can see in full
1934    #[serde(default)]
1935    pub owned_files: Vec<String>,
1936    /// Adjacent sealed interfaces the node can reference
1937    #[serde(default)]
1938    pub sealed_interfaces: Vec<String>,
1939    /// Structural digests for external dependencies (preferred over raw files)
1940    #[serde(default)]
1941    pub structural_digests: Vec<StructuralDigest>,
1942    /// Summary digests for advisory context
1943    #[serde(default)]
1944    pub summary_digests: Vec<SummaryDigest>,
1945    /// Dependency commit hashes this node relies on
1946    #[serde(default)]
1947    pub dependency_commits: std::collections::HashMap<String, Vec<u8>>,
1948}
1949
1950impl RestrictionMap {
1951    /// Create a restriction map for a node with default budget
1952    pub fn for_node(node_id: impl Into<String>) -> Self {
1953        Self {
1954            node_id: node_id.into(),
1955            ..Default::default()
1956        }
1957    }
1958
1959    /// Total structural bytes (approximation)
1960    pub fn structural_bytes(&self) -> usize {
1961        self.structural_digests
1962            .iter()
1963            .map(|d| d.source_path.len() + 64)
1964            .sum::<usize>()
1965            + self.sealed_interfaces.len() * 128
1966    }
1967}
1968
1969/// PSP-5 Phase 3: Reproducible context package for node execution
1970///
1971/// A context package is the complete, bounded input assembled for a node's
1972/// LLM prompt. It records exactly what was included so the same context can
1973/// be reconstructed from the ledger and repository state.
1974#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1975pub struct ContextPackage {
1976    /// Unique package identifier
1977    pub package_id: String,
1978    /// The node this context was assembled for
1979    pub node_id: String,
1980    /// The restriction map used
1981    pub restriction_map: RestrictionMap,
1982    /// Raw file contents included (path → content)
1983    #[serde(default)]
1984    pub included_files: std::collections::HashMap<String, String>,
1985    /// Structural digests included in this package
1986    #[serde(default)]
1987    pub structural_digests: Vec<StructuralDigest>,
1988    /// Summary digests included in this package
1989    #[serde(default)]
1990    pub summary_digests: Vec<SummaryDigest>,
1991    /// Total byte size of the assembled context
1992    pub total_bytes: usize,
1993    /// Whether budget was exceeded and content was trimmed
1994    pub budget_exceeded: bool,
1995    /// Timestamp of assembly
1996    pub created_at: i64,
1997}
1998
1999impl ContextPackage {
2000    /// Create a new empty context package for a node
2001    pub fn new(node_id: impl Into<String>) -> Self {
2002        let nid = node_id.into();
2003        let ts = std::time::SystemTime::now()
2004            .duration_since(std::time::UNIX_EPOCH)
2005            .unwrap_or_default()
2006            .as_secs() as i64;
2007        Self {
2008            package_id: format!("ctx_{}_{}", nid, ts),
2009            node_id: nid,
2010            created_at: ts,
2011            ..Default::default()
2012        }
2013    }
2014
2015    /// Add a file to the context package, respecting budget
2016    pub fn add_file(&mut self, path: &str, content: String) -> bool {
2017        let new_bytes = self.total_bytes + content.len();
2018        if new_bytes > self.restriction_map.budget.byte_limit {
2019            self.budget_exceeded = true;
2020            return false;
2021        }
2022        if self.included_files.len() >= self.restriction_map.budget.file_count_limit {
2023            self.budget_exceeded = true;
2024            return false;
2025        }
2026        self.total_bytes = new_bytes;
2027        self.included_files.insert(path.to_string(), content);
2028        true
2029    }
2030
2031    /// Add a structural digest (always fits, they're small)
2032    pub fn add_structural_digest(&mut self, digest: StructuralDigest) {
2033        self.structural_digests.push(digest);
2034    }
2035
2036    /// Add a summary digest
2037    pub fn add_summary_digest(&mut self, digest: SummaryDigest) {
2038        self.total_bytes += digest.summary_text.len();
2039        self.summary_digests.push(digest);
2040    }
2041
2042    /// Get the provenance record for this package
2043    pub fn provenance(&self) -> ContextProvenance {
2044        ContextProvenance {
2045            node_id: self.node_id.clone(),
2046            context_package_id: self.package_id.clone(),
2047            structural_digest_hashes: self
2048                .structural_digests
2049                .iter()
2050                .map(|d| (d.digest_id.clone(), d.hash))
2051                .collect(),
2052            summary_digest_hashes: self
2053                .summary_digests
2054                .iter()
2055                .map(|d| (d.digest_id.clone(), d.hash))
2056                .collect(),
2057            dependency_commit_hashes: self
2058                .restriction_map
2059                .dependency_commits
2060                .iter()
2061                .map(|(k, v)| (k.clone(), v.clone()))
2062                .collect(),
2063            included_file_count: self.included_files.len(),
2064            total_bytes: self.total_bytes,
2065            created_at: self.created_at,
2066        }
2067    }
2068}
2069
2070/// PSP-5 Phase 3: Provenance record tracking what context was used
2071///
2072/// Records the hashes of all summaries, contracts, and dependency commits
2073/// used to derive a node's prompt context. This enables reproducibility:
2074/// the same context package can be reconstructed from persisted state.
2075#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2076pub struct ContextProvenance {
2077    /// Node this provenance belongs to
2078    pub node_id: String,
2079    /// Context package ID
2080    pub context_package_id: String,
2081    /// Structural digest ID → hash pairs used
2082    #[serde(default)]
2083    pub structural_digest_hashes: Vec<(String, [u8; 32])>,
2084    /// Summary digest ID → hash pairs used
2085    #[serde(default)]
2086    pub summary_digest_hashes: Vec<(String, [u8; 32])>,
2087    /// Dependency node → commit hash pairs
2088    #[serde(default)]
2089    pub dependency_commit_hashes: Vec<(String, Vec<u8>)>,
2090    /// Number of raw files included
2091    pub included_file_count: usize,
2092    /// Total bytes in context package
2093    pub total_bytes: usize,
2094    /// When this provenance was recorded
2095    pub created_at: i64,
2096}
2097
2098// =============================================================================
2099// PSP-5 Phase 5: Escalation Semantics, Local Graph Rewrite, Sheaf Targeting
2100// =============================================================================
2101
2102/// PSP-5 Phase 5: Category of non-convergence detected by the verifier.
2103///
2104/// When a node exceeds its retry budget or fails to decrease energy, the
2105/// orchestrator classifies the failure into one of these categories so the
2106/// runtime can choose a targeted repair action instead of only escalating.
2107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2108#[serde(rename_all = "snake_case")]
2109pub enum EscalationCategory {
2110    /// Compilation, type, or syntax errors that remain after retries.
2111    ImplementationError,
2112    /// Node output violates its behavioral contract or interface seal.
2113    ContractMismatch,
2114    /// Model is unable to produce acceptable output for this node's tier.
2115    InsufficientModelCapability,
2116    /// Required verifier tools are missing or degraded.
2117    DegradedSensors,
2118    /// Node scope does not match ownership or dependency graph structure.
2119    TopologyMismatch,
2120}
2121
2122impl std::fmt::Display for EscalationCategory {
2123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2124        match self {
2125            EscalationCategory::ImplementationError => write!(f, "implementation_error"),
2126            EscalationCategory::ContractMismatch => write!(f, "contract_mismatch"),
2127            EscalationCategory::InsufficientModelCapability => {
2128                write!(f, "insufficient_model_capability")
2129            }
2130            EscalationCategory::DegradedSensors => write!(f, "degraded_sensors"),
2131            EscalationCategory::TopologyMismatch => write!(f, "topology_mismatch"),
2132        }
2133    }
2134}
2135
2136/// PSP-5 Phase 5: Repair action chosen by the orchestrator after classifying
2137/// non-convergence.
2138///
2139/// Actions are ordered from least destructive (retry with evidence) to most
2140/// disruptive (user escalation).  The orchestrator picks the first action
2141/// that is safe given the current evidence.
2142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2143#[serde(rename_all = "snake_case")]
2144pub enum RewriteAction {
2145    /// Re-attempt the node with a correction prompt grounded in verifier output.
2146    GroundedRetry {
2147        /// Human-readable summary of the evidence fed back to the LLM.
2148        evidence_summary: String,
2149    },
2150    /// Refine or tighten the node's behavioral contract or interface seal.
2151    ContractRepair {
2152        /// Which contract fields need adjustment.
2153        fields: Vec<String>,
2154    },
2155    /// Promote the node to a higher-capability model tier.
2156    CapabilityPromotion {
2157        /// Current tier.
2158        from_tier: ModelTier,
2159        /// Proposed tier.
2160        to_tier: ModelTier,
2161    },
2162    /// Attempt to recover a degraded sensor or stop with explicit degradation.
2163    SensorRecovery {
2164        /// Stages that are degraded.
2165        degraded_stages: Vec<String>,
2166    },
2167    /// Stop the node with an explicit degraded-validation marker rather than
2168    /// claiming false stability.
2169    DegradedValidationStop {
2170        /// Reason the runtime is stopping without full verification.
2171        reason: String,
2172    },
2173    /// Split the current node by ownership closure into smaller nodes.
2174    NodeSplit {
2175        /// Proposed child node IDs after splitting.
2176        proposed_children: Vec<String>,
2177    },
2178    /// Insert an interface node between this node and its dependents.
2179    InterfaceInsertion {
2180        /// The boundary that motivated the insertion.
2181        boundary: String,
2182    },
2183    /// Re-plan a local subgraph rooted at the failing node.
2184    SubgraphReplan {
2185        /// Node IDs in the affected subgraph.
2186        affected_nodes: Vec<String>,
2187    },
2188    /// Escalate to the user with stored evidence (last resort).
2189    UserEscalation {
2190        /// Structured evidence for the user.
2191        evidence: String,
2192    },
2193}
2194
2195impl std::fmt::Display for RewriteAction {
2196    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2197        match self {
2198            RewriteAction::GroundedRetry { .. } => write!(f, "grounded_retry"),
2199            RewriteAction::ContractRepair { .. } => write!(f, "contract_repair"),
2200            RewriteAction::CapabilityPromotion { .. } => write!(f, "capability_promotion"),
2201            RewriteAction::SensorRecovery { .. } => write!(f, "sensor_recovery"),
2202            RewriteAction::DegradedValidationStop { .. } => {
2203                write!(f, "degraded_validation_stop")
2204            }
2205            RewriteAction::NodeSplit { .. } => write!(f, "node_split"),
2206            RewriteAction::InterfaceInsertion { .. } => write!(f, "interface_insertion"),
2207            RewriteAction::SubgraphReplan { .. } => write!(f, "subgraph_replan"),
2208            RewriteAction::UserEscalation { .. } => write!(f, "user_escalation"),
2209        }
2210    }
2211}
2212
2213/// PSP-5 Phase 5: Sheaf validator class.
2214///
2215/// Each class checks a different cross-node consistency property after child
2216/// nodes converge and before the parent node is committed.
2217#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2218#[serde(rename_all = "snake_case")]
2219pub enum SheafValidatorClass {
2220    /// Exported symbols, trait impls, and module imports match dependency interfaces.
2221    ExportImportConsistency,
2222    /// Repository dependency edges remain acyclic and node-local changes do not
2223    /// introduce invalid module or package references.
2224    DependencyGraphConsistency,
2225    /// JSON schemas, API types, and serialization contracts remain compatible.
2226    SchemaContractCompatibility,
2227    /// Plugin-selected build targets remain satisfiable for the affected subgraph.
2228    BuildGraphConsistency,
2229    /// Failing tests are attributed to the owning node or interface boundary.
2230    TestOwnershipConsistency,
2231    /// FFI layers, generated clients, and protocol bindings across plugin boundaries.
2232    CrossLanguageBoundary,
2233    /// Repository-wide invariants and forbidden patterns still hold.
2234    PolicyInvariantConsistency,
2235}
2236
2237impl std::fmt::Display for SheafValidatorClass {
2238    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2239        match self {
2240            SheafValidatorClass::ExportImportConsistency => write!(f, "export_import"),
2241            SheafValidatorClass::DependencyGraphConsistency => write!(f, "dependency_graph"),
2242            SheafValidatorClass::SchemaContractCompatibility => write!(f, "schema_contract"),
2243            SheafValidatorClass::BuildGraphConsistency => write!(f, "build_graph"),
2244            SheafValidatorClass::TestOwnershipConsistency => write!(f, "test_ownership"),
2245            SheafValidatorClass::CrossLanguageBoundary => write!(f, "cross_language"),
2246            SheafValidatorClass::PolicyInvariantConsistency => write!(f, "policy_invariant"),
2247        }
2248    }
2249}
2250
2251/// PSP-5 Phase 5: Result of a single sheaf validation pass.
2252#[derive(Debug, Clone, Serialize, Deserialize)]
2253pub struct SheafValidationResult {
2254    /// Which validator class produced this result.
2255    pub validator_class: SheafValidatorClass,
2256    /// Plugin that owns the validator (if any).
2257    pub plugin_source: Option<String>,
2258    /// Whether the validation passed.
2259    pub passed: bool,
2260    /// Boundaries that were validated.
2261    pub validated_boundaries: Vec<String>,
2262    /// Evidence summary when validation fails.
2263    pub evidence_summary: String,
2264    /// Files or interfaces affected by the failure.
2265    pub affected_files: Vec<String>,
2266    /// Energy contribution to V_sheaf.
2267    pub v_sheaf_contribution: f32,
2268    /// Node IDs recommended for requeue on failure.
2269    pub requeue_targets: Vec<String>,
2270}
2271
2272impl SheafValidationResult {
2273    /// Create a passing result.
2274    pub fn passed(class: SheafValidatorClass, boundaries: Vec<String>) -> Self {
2275        Self {
2276            validator_class: class,
2277            plugin_source: None,
2278            passed: true,
2279            validated_boundaries: boundaries,
2280            evidence_summary: String::new(),
2281            affected_files: Vec::new(),
2282            v_sheaf_contribution: 0.0,
2283            requeue_targets: Vec::new(),
2284        }
2285    }
2286
2287    /// Create a failing result with evidence.
2288    pub fn failed(
2289        class: SheafValidatorClass,
2290        evidence: impl Into<String>,
2291        affected: Vec<String>,
2292        requeue: Vec<String>,
2293        v_sheaf: f32,
2294    ) -> Self {
2295        Self {
2296            validator_class: class,
2297            plugin_source: None,
2298            passed: false,
2299            validated_boundaries: Vec::new(),
2300            evidence_summary: evidence.into(),
2301            affected_files: affected,
2302            v_sheaf_contribution: v_sheaf,
2303            requeue_targets: requeue,
2304        }
2305    }
2306}
2307
2308/// PSP-5 Phase 5: Full escalation report assembled by the orchestrator.
2309///
2310/// Captures everything needed for persistence, user display, and later
2311/// resume: the failing node, the classified category, the chosen repair
2312/// action, verifier evidence, and energy snapshot.
2313#[derive(Debug, Clone, Serialize, Deserialize)]
2314pub struct EscalationReport {
2315    /// Node that triggered escalation.
2316    pub node_id: String,
2317    /// Session this report belongs to.
2318    pub session_id: String,
2319    /// Classified failure category.
2320    pub category: EscalationCategory,
2321    /// Repair action chosen (or UserEscalation if none was safe).
2322    pub action: RewriteAction,
2323    /// Energy at the time of escalation.
2324    pub energy_snapshot: EnergyComponents,
2325    /// Verifier stage outcomes at the time of escalation.
2326    pub stage_outcomes: Vec<StageOutcome>,
2327    /// Human-readable evidence summary.
2328    pub evidence: String,
2329    /// Node IDs affected by the chosen action (requeue targets).
2330    pub affected_node_ids: Vec<String>,
2331    /// Timestamp (epoch seconds).
2332    pub timestamp: i64,
2333}
2334
2335/// PSP-5 Phase 5: Record of a local graph rewrite applied by the orchestrator.
2336///
2337/// Stored in the ledger so Phase 8 resume can replay or audit rewrite history.
2338#[derive(Debug, Clone, Serialize, Deserialize)]
2339pub struct RewriteRecord {
2340    /// Node that was rewritten.
2341    pub node_id: String,
2342    /// Session this record belongs to.
2343    pub session_id: String,
2344    /// The rewrite action that was applied.
2345    pub action: RewriteAction,
2346    /// Category that triggered the rewrite.
2347    pub category: EscalationCategory,
2348    /// Node IDs that were requeued as a result.
2349    pub requeued_nodes: Vec<String>,
2350    /// Node IDs that were newly inserted (e.g. interface insertion).
2351    pub inserted_nodes: Vec<String>,
2352    /// Timestamp (epoch seconds).
2353    pub timestamp: i64,
2354}
2355
2356/// PSP-5 Phase 5: Targeted requeue entry.
2357///
2358/// When a sheaf validator or escalation identifies a subset of nodes for
2359/// re-execution, this record tracks the targeting metadata.
2360#[derive(Debug, Clone, Serialize, Deserialize)]
2361pub struct TargetedRequeue {
2362    /// Node IDs targeted for requeue.
2363    pub node_ids: Vec<String>,
2364    /// Reason for the requeue (validator class or escalation category).
2365    pub reason: String,
2366    /// Evidence that justified targeting these specific nodes.
2367    pub evidence: String,
2368    /// Sheaf validation results that triggered this requeue (if any).
2369    pub sheaf_results: Vec<SheafValidationResult>,
2370    /// Timestamp (epoch seconds).
2371    pub timestamp: i64,
2372}
2373
2374// =============================================================================
2375// PSP-5 Phase 6: Provisional Branch Ledger and Interface-Sealed Speculation
2376// =============================================================================
2377
2378/// PSP-5 Phase 6: State of a provisional branch.
2379///
2380/// Provisional branches store speculative child work separately from committed
2381/// ledger state.  A branch transitions through Active → Sealed → Merged or
2382/// Flushed, and never enters committed node state without explicit merge.
2383#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2384#[serde(rename_all = "snake_case")]
2385pub enum ProvisionalBranchState {
2386    /// Branch is executing speculatively; verification has not yet completed.
2387    Active,
2388    /// Interface for the branch's parent node is sealed; child work may proceed.
2389    Sealed,
2390    /// Branch was merged into committed state after parent met stability threshold.
2391    Merged,
2392    /// Branch was discarded because parent verification failed.
2393    Flushed,
2394}
2395
2396impl std::fmt::Display for ProvisionalBranchState {
2397    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2398        match self {
2399            ProvisionalBranchState::Active => write!(f, "active"),
2400            ProvisionalBranchState::Sealed => write!(f, "sealed"),
2401            ProvisionalBranchState::Merged => write!(f, "merged"),
2402            ProvisionalBranchState::Flushed => write!(f, "flushed"),
2403        }
2404    }
2405}
2406
2407/// PSP-5 Phase 6: Provisional branch tracking speculative child work.
2408///
2409/// Created before speculative generation begins so the runtime can track
2410/// branch lifecycle, enforce seal prerequisites, and flush on parent failure.
2411#[derive(Debug, Clone, Serialize, Deserialize)]
2412pub struct ProvisionalBranch {
2413    /// Unique branch identifier.
2414    pub branch_id: String,
2415    /// Session this branch belongs to.
2416    pub session_id: String,
2417    /// The node executing speculatively in this branch.
2418    pub node_id: String,
2419    /// Parent node whose interface this branch depends on.
2420    pub parent_node_id: String,
2421    /// Current branch state.
2422    pub state: ProvisionalBranchState,
2423    /// SHA-256 hash of the parent interface seal this branch depends on.
2424    /// `None` if the parent has not yet produced a seal.
2425    pub parent_seal_hash: Option<[u8; 32]>,
2426    /// Sandbox workspace directory (if verification ran in sandbox).
2427    pub sandbox_dir: Option<String>,
2428    /// Timestamp of branch creation (epoch seconds).
2429    pub created_at: i64,
2430    /// Timestamp of last state transition (epoch seconds).
2431    pub updated_at: i64,
2432}
2433
2434impl ProvisionalBranch {
2435    /// Create a new active provisional branch.
2436    pub fn new(
2437        branch_id: impl Into<String>,
2438        session_id: impl Into<String>,
2439        node_id: impl Into<String>,
2440        parent_node_id: impl Into<String>,
2441    ) -> Self {
2442        let now = epoch_secs();
2443        Self {
2444            branch_id: branch_id.into(),
2445            session_id: session_id.into(),
2446            node_id: node_id.into(),
2447            parent_node_id: parent_node_id.into(),
2448            state: ProvisionalBranchState::Active,
2449            parent_seal_hash: None,
2450            sandbox_dir: None,
2451            created_at: now,
2452            updated_at: now,
2453        }
2454    }
2455
2456    /// Whether this branch is still eligible for merge (active or sealed).
2457    pub fn is_live(&self) -> bool {
2458        matches!(
2459            self.state,
2460            ProvisionalBranchState::Active | ProvisionalBranchState::Sealed
2461        )
2462    }
2463
2464    /// Whether this branch has been discarded.
2465    pub fn is_flushed(&self) -> bool {
2466        self.state == ProvisionalBranchState::Flushed
2467    }
2468}
2469
2470/// PSP-5 Phase 6: Parent → child branch lineage record.
2471///
2472/// Records the dependency edge between a parent branch (or committed node)
2473/// and a child provisional branch.  Used by flush propagation to find all
2474/// descendants that must be discarded when a parent fails.
2475#[derive(Debug, Clone, Serialize, Deserialize)]
2476pub struct BranchLineage {
2477    /// Unique lineage record ID.
2478    pub lineage_id: String,
2479    /// Parent branch ID (or committed node ID if the parent is committed).
2480    pub parent_branch_id: String,
2481    /// Child branch ID.
2482    pub child_branch_id: String,
2483    /// Whether the dependency is on the parent's sealed interface (vs. full output).
2484    pub depends_on_seal: bool,
2485}
2486
2487/// PSP-5 Phase 6: Record of a sealed interface produced by a node.
2488///
2489/// An interface seal is a hash over the exported signatures, schemas, or symbol
2490/// inventories that downstream nodes depend on.  Once sealed, the interface is
2491/// immutable within the current SRBN iteration — dependent context is assembled
2492/// from the seal rather than from mutable parent implementation files.
2493#[derive(Debug, Clone, Serialize, Deserialize)]
2494pub struct InterfaceSealRecord {
2495    /// Unique seal identifier.
2496    pub seal_id: String,
2497    /// Session this seal belongs to.
2498    pub session_id: String,
2499    /// Node that produced (and owns) this seal.
2500    pub node_id: String,
2501    /// Path of the sealed artifact (relative to workspace).
2502    pub sealed_path: String,
2503    /// The kind of structural artifact that was sealed.
2504    pub artifact_kind: ArtifactKind,
2505    /// SHA-256 hash of the sealed content.
2506    pub seal_hash: [u8; 32],
2507    /// Monotonically increasing version (incremented on re-seal after parent retry).
2508    pub version: u32,
2509    /// Timestamp of seal creation (epoch seconds).
2510    pub created_at: i64,
2511}
2512
2513impl InterfaceSealRecord {
2514    /// Create a new seal from existing structural digest data.
2515    pub fn from_digest(
2516        session_id: impl Into<String>,
2517        node_id: impl Into<String>,
2518        digest: &StructuralDigest,
2519    ) -> Self {
2520        let nid = node_id.into();
2521        let sid = session_id.into();
2522        let seal_id = format!("seal_{}_{}", nid, digest.source_path);
2523        Self {
2524            seal_id,
2525            session_id: sid,
2526            node_id: nid,
2527            sealed_path: digest.source_path.clone(),
2528            artifact_kind: digest.artifact_kind,
2529            seal_hash: digest.hash,
2530            version: digest.version,
2531            created_at: epoch_secs(),
2532        }
2533    }
2534
2535    /// Check whether this seal matches a given digest hash.
2536    pub fn matches_hash(&self, hash: &[u8; 32]) -> bool {
2537        self.seal_hash == *hash
2538    }
2539}
2540
2541/// PSP-5 Phase 6: Record of a branch flush decision.
2542///
2543/// Persisted so that resume and status surfaces can show why speculative work
2544/// was discarded and which nodes need re-execution.
2545#[derive(Debug, Clone, Serialize, Deserialize)]
2546pub struct BranchFlushRecord {
2547    /// Unique flush record ID.
2548    pub flush_id: String,
2549    /// Session this flush belongs to.
2550    pub session_id: String,
2551    /// Parent node whose failure triggered the flush.
2552    pub parent_node_id: String,
2553    /// Branch IDs that were flushed.
2554    pub flushed_branch_ids: Vec<String>,
2555    /// Node IDs that should be requeued after the parent stabilizes.
2556    pub requeue_node_ids: Vec<String>,
2557    /// Human-readable reason for the flush.
2558    pub reason: String,
2559    /// Timestamp of the flush decision (epoch seconds).
2560    pub created_at: i64,
2561}
2562
2563impl BranchFlushRecord {
2564    /// Create a new flush record.
2565    pub fn new(
2566        session_id: impl Into<String>,
2567        parent_node_id: impl Into<String>,
2568        flushed_branch_ids: Vec<String>,
2569        requeue_node_ids: Vec<String>,
2570        reason: impl Into<String>,
2571    ) -> Self {
2572        Self {
2573            flush_id: format!("flush_{}", uuid_v4()),
2574            session_id: session_id.into(),
2575            parent_node_id: parent_node_id.into(),
2576            flushed_branch_ids,
2577            requeue_node_ids,
2578            reason: reason.into(),
2579            created_at: epoch_secs(),
2580        }
2581    }
2582}
2583
2584/// PSP-5 Phase 6: Dependency tracking for nodes blocked on a parent seal.
2585///
2586/// When a child node depends on a parent's sealed interface that has not yet
2587/// been produced, the child is registered as a blocked dependent.  Once the
2588/// parent seals its interface, blocked dependents are unblocked.
2589#[derive(Debug, Clone, Serialize, Deserialize)]
2590pub struct BlockedDependency {
2591    /// Child node that is blocked.
2592    pub child_node_id: String,
2593    /// Parent node whose seal the child requires.
2594    pub parent_node_id: String,
2595    /// Sealed interface paths the child depends on.
2596    pub required_seal_paths: Vec<String>,
2597    /// Timestamp when the block was registered (epoch seconds).
2598    pub blocked_at: i64,
2599}
2600
2601impl BlockedDependency {
2602    /// Create a new blocked dependency record.
2603    pub fn new(
2604        child_node_id: impl Into<String>,
2605        parent_node_id: impl Into<String>,
2606        required_seal_paths: Vec<String>,
2607    ) -> Self {
2608        Self {
2609            child_node_id: child_node_id.into(),
2610            parent_node_id: parent_node_id.into(),
2611            required_seal_paths,
2612            blocked_at: epoch_secs(),
2613        }
2614    }
2615}
2616
2617// =========================================================================
2618// Plan Revision and Repair Domain Types
2619// =========================================================================
2620
2621/// Status of a plan revision within a session.
2622///
2623/// Each session may produce multiple plan revisions as the architect responds
2624/// to verification failures, scope changes, or governance policies.  Only one
2625/// revision is active at any time; previous revisions are superseded.
2626#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2627#[serde(rename_all = "snake_case")]
2628pub enum PlanRevisionStatus {
2629    /// The revision is the current active plan driving execution.
2630    #[default]
2631    Active,
2632    /// A newer revision has replaced this one.
2633    Superseded,
2634    /// The revision was explicitly abandoned (e.g., user abort).
2635    Cancelled,
2636}
2637
2638impl std::fmt::Display for PlanRevisionStatus {
2639    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2640        match self {
2641            Self::Active => write!(f, "active"),
2642            Self::Superseded => write!(f, "superseded"),
2643            Self::Cancelled => write!(f, "cancelled"),
2644        }
2645    }
2646}
2647
2648/// A single plan revision within a session.
2649///
2650/// Tracks the evolution of the architect's plan over time.  When the verifier
2651/// or governance policy triggers a replan, a new `PlanRevision` is created,
2652/// the previous one is marked `Superseded`, and the new revision becomes
2653/// the active plan.
2654#[derive(Debug, Clone, Serialize, Deserialize)]
2655pub struct PlanRevision {
2656    /// Unique revision identifier.
2657    pub revision_id: String,
2658    /// Session this revision belongs to.
2659    pub session_id: String,
2660    /// Monotonically-increasing sequence number within the session (1-based).
2661    pub sequence: u32,
2662    /// The plan content.
2663    pub plan: TaskPlan,
2664    /// Why this revision was created (`"initial"`, `"verification_failure"`,
2665    /// `"scope_change"`, `"governance_budget_exceeded"`, …).
2666    pub reason: String,
2667    /// If this revision supersedes an earlier one, its ID.
2668    pub supersedes: Option<String>,
2669    /// Current status of this revision.
2670    pub status: PlanRevisionStatus,
2671    /// Epoch seconds when this revision was created.
2672    pub created_at: i64,
2673}
2674
2675impl PlanRevision {
2676    /// Create the initial plan revision for a session.
2677    pub fn initial(session_id: impl Into<String>, plan: TaskPlan) -> Self {
2678        Self {
2679            revision_id: uuid_v4(),
2680            session_id: session_id.into(),
2681            sequence: 1,
2682            plan,
2683            reason: "initial".to_string(),
2684            supersedes: None,
2685            status: PlanRevisionStatus::Active,
2686            created_at: epoch_secs(),
2687        }
2688    }
2689
2690    /// Create a successor revision that supersedes `previous`.
2691    pub fn successor(previous: &PlanRevision, plan: TaskPlan, reason: impl Into<String>) -> Self {
2692        Self {
2693            revision_id: uuid_v4(),
2694            session_id: previous.session_id.clone(),
2695            sequence: previous.sequence + 1,
2696            plan,
2697            reason: reason.into(),
2698            supersedes: Some(previous.revision_id.clone()),
2699            status: PlanRevisionStatus::Active,
2700            created_at: epoch_secs(),
2701        }
2702    }
2703
2704    /// Whether this is the current active revision.
2705    pub fn is_active(&self) -> bool {
2706        self.status == PlanRevisionStatus::Active
2707    }
2708}
2709
2710/// Adaptive planning policy that selects the agent phase stack
2711/// based on task scale and workspace type.
2712///
2713/// Each variant maps to a different level of orchestration complexity:
2714/// - `LocalEdit` — Actuator + Verifier only; no architect needed
2715/// - `FeatureIncrement` — Architect + Actuator + Verifier
2716/// - `LargeFeature` — Full 4-agent stack with Speculator
2717/// - `GreenfieldBuild` — Full stack with workspace-setup node first
2718/// - `ArchitecturalRevision` — Architect + Speculator first, then execution
2719#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
2720pub enum PlanningPolicy {
2721    /// Small, localized change: skip architect planning.
2722    LocalEdit,
2723    /// Mid-size feature: architect decomposes, actuator implements.
2724    #[default]
2725    FeatureIncrement,
2726    /// Large feature: full SRBN loop with speculative execution.
2727    LargeFeature,
2728    /// New project: full stack with bootstrap ordering.
2729    GreenfieldBuild,
2730    /// Cross-cutting redesign: plan-first, execute later.
2731    ArchitecturalRevision,
2732}
2733
2734impl PlanningPolicy {
2735    /// Whether this policy requires architect planning.
2736    pub fn needs_architect(&self) -> bool {
2737        !matches!(self, Self::LocalEdit)
2738    }
2739
2740    /// Whether this policy activates the speculator.
2741    pub fn needs_speculator(&self) -> bool {
2742        matches!(
2743            self,
2744            Self::LargeFeature | Self::GreenfieldBuild | Self::ArchitecturalRevision
2745        )
2746    }
2747}
2748
2749/// A scoping document that constrains what the architect may plan.
2750///
2751/// The `FeatureCharter` sits above individual task plans and provides
2752/// boundaries: maximum module count, maximum files, language policy,
2753/// and a human-readable description of the intended outcome.
2754#[derive(Debug, Clone, Serialize, Deserialize)]
2755pub struct FeatureCharter {
2756    /// Unique charter identifier (typically per session).
2757    pub charter_id: String,
2758    /// Session ID.
2759    pub session_id: String,
2760    /// Human-readable scope description (the user's original request).
2761    pub scope_description: String,
2762    /// Maximum number of modules/nodes the architect may produce.
2763    pub max_modules: Option<u32>,
2764    /// Maximum total files the plan may create.
2765    pub max_files: Option<u32>,
2766    /// Maximum plan revisions before hard escalation.
2767    pub max_revisions: Option<u32>,
2768    /// Language or plugin constraint (e.g. `"rust"`, `"python"`).
2769    pub language_constraint: Option<String>,
2770    /// Epoch seconds when the charter was created.
2771    pub created_at: i64,
2772}
2773
2774impl FeatureCharter {
2775    /// Create a new charter with just a scope description.
2776    pub fn new(session_id: impl Into<String>, scope_description: impl Into<String>) -> Self {
2777        Self {
2778            charter_id: uuid_v4(),
2779            session_id: session_id.into(),
2780            scope_description: scope_description.into(),
2781            max_modules: None,
2782            max_files: None,
2783            max_revisions: None,
2784            language_constraint: None,
2785            created_at: epoch_secs(),
2786        }
2787    }
2788}
2789
2790/// A bounded repair unit that records what was changed during a correction.
2791///
2792/// Instead of raw `last_written_file` tracking, every correction pass creates
2793/// a `RepairFootprint` that records the affected files, applied bundle,
2794/// verification result before/after, and the node being repaired.
2795#[derive(Debug, Clone, Serialize, Deserialize)]
2796pub struct RepairFootprint {
2797    /// Unique footprint identifier.
2798    pub footprint_id: String,
2799    /// Session ID.
2800    pub session_id: String,
2801    /// Node ID being repaired.
2802    pub node_id: String,
2803    /// Which plan revision was active when the repair happened.
2804    pub revision_id: String,
2805    /// Correction attempt number within this node (1-based).
2806    pub attempt: u32,
2807    /// Files that were modified by the repair bundle.
2808    pub affected_files: Vec<String>,
2809    /// The artifact bundle applied during this repair.
2810    pub applied_bundle: ArtifactBundle,
2811    /// Brief summary of what was wrong (from verifier output).
2812    pub diagnosis: String,
2813    /// Whether the repair resolved the issue.
2814    pub resolved: bool,
2815    /// Epoch seconds.
2816    pub created_at: i64,
2817}
2818
2819impl RepairFootprint {
2820    /// Create a new repair footprint.
2821    pub fn new(
2822        session_id: impl Into<String>,
2823        node_id: impl Into<String>,
2824        revision_id: impl Into<String>,
2825        attempt: u32,
2826        bundle: &ArtifactBundle,
2827        diagnosis: impl Into<String>,
2828    ) -> Self {
2829        let affected_files = bundle
2830            .affected_paths()
2831            .into_iter()
2832            .map(String::from)
2833            .collect();
2834        Self {
2835            footprint_id: uuid_v4(),
2836            session_id: session_id.into(),
2837            node_id: node_id.into(),
2838            revision_id: revision_id.into(),
2839            attempt,
2840            affected_files,
2841            applied_bundle: bundle.clone(),
2842            diagnosis: diagnosis.into(),
2843            resolved: false,
2844            created_at: epoch_secs(),
2845        }
2846    }
2847
2848    /// Mark this footprint as having resolved the issue.
2849    pub fn mark_resolved(&mut self) {
2850        self.resolved = true;
2851    }
2852}
2853
2854/// Declared dependency expectations for a planned task.
2855///
2856/// Used during verification to confirm that the environment matches what
2857/// the architect assumed when producing the plan (e.g. required packages,
2858/// expected setup commands, or required toolchain version).
2859#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2860pub struct DependencyExpectation {
2861    /// Packages or crates the task expects to be available.
2862    pub required_packages: Vec<String>,
2863    /// Setup commands that must have succeeded before this task runs.
2864    pub setup_commands: Vec<String>,
2865    /// Minimum toolchain version string (e.g. `"1.75"` for Rust).
2866    pub min_toolchain_version: Option<String>,
2867}
2868
2869/// Budget envelope for plan execution.
2870///
2871/// Tracks cost, step, and revision budgets for a session.  The governance
2872/// layer checks these limits before allowing further execution.
2873#[derive(Debug, Clone, Serialize, Deserialize)]
2874pub struct BudgetEnvelope {
2875    /// Session ID.
2876    pub session_id: String,
2877    /// Maximum number of node execution steps allowed.
2878    pub max_steps: Option<u32>,
2879    /// Steps consumed so far.
2880    pub steps_used: u32,
2881    /// Maximum number of plan revisions allowed.
2882    pub max_revisions: Option<u32>,
2883    /// Revisions consumed so far.
2884    pub revisions_used: u32,
2885    /// Maximum total cost in USD.
2886    pub max_cost_usd: Option<f64>,
2887    /// Cost consumed so far.
2888    pub cost_used_usd: f64,
2889}
2890
2891impl BudgetEnvelope {
2892    /// Create a new budget envelope with no limits.
2893    pub fn new(session_id: impl Into<String>) -> Self {
2894        Self {
2895            session_id: session_id.into(),
2896            max_steps: None,
2897            steps_used: 0,
2898            max_revisions: None,
2899            revisions_used: 0,
2900            max_cost_usd: None,
2901            cost_used_usd: 0.0,
2902        }
2903    }
2904
2905    /// Whether the step budget is exhausted.
2906    pub fn steps_exhausted(&self) -> bool {
2907        self.max_steps.is_some_and(|max| self.steps_used >= max)
2908    }
2909
2910    /// Whether the revision budget is exhausted.
2911    pub fn revisions_exhausted(&self) -> bool {
2912        self.max_revisions
2913            .is_some_and(|max| self.revisions_used >= max)
2914    }
2915
2916    /// Whether the cost budget is exhausted.
2917    pub fn cost_exhausted(&self) -> bool {
2918        self.max_cost_usd
2919            .is_some_and(|max| self.cost_used_usd >= max)
2920    }
2921
2922    /// Whether any budget limit has been exceeded.
2923    pub fn any_exhausted(&self) -> bool {
2924        self.steps_exhausted() || self.revisions_exhausted() || self.cost_exhausted()
2925    }
2926
2927    /// Record a step.
2928    pub fn record_step(&mut self) {
2929        self.steps_used += 1;
2930    }
2931
2932    /// Record a plan revision.
2933    pub fn record_revision(&mut self) {
2934        self.revisions_used += 1;
2935    }
2936
2937    /// Record cost.
2938    pub fn record_cost(&mut self, usd: f64) {
2939        self.cost_used_usd += usd;
2940    }
2941}
2942
2943/// Helper: current epoch seconds.
2944fn epoch_secs() -> i64 {
2945    std::time::SystemTime::now()
2946        .duration_since(std::time::UNIX_EPOCH)
2947        .unwrap_or_default()
2948        .as_secs() as i64
2949}
2950
2951/// Helper: generate a UUID v4 string (simplified).
2952fn uuid_v4() -> String {
2953    // Use timestamp + random-ish counter for unique IDs without pulling uuid crate
2954    // The orchestrator and ledger layers use the `uuid` crate directly when available.
2955    use std::collections::hash_map::DefaultHasher;
2956    use std::hash::{Hash, Hasher};
2957    let now = std::time::SystemTime::now()
2958        .duration_since(std::time::UNIX_EPOCH)
2959        .unwrap_or_default();
2960    let mut hasher = DefaultHasher::new();
2961    now.as_nanos().hash(&mut hasher);
2962    format!("{:016x}", hasher.finish())
2963}
2964
2965// =============================================================================
2966// PSP-7: Runtime Barrier Types
2967// =============================================================================
2968
2969/// Result state from the typed parse pipeline (PSP-7 Layers A-E).
2970///
2971/// Every LLM response is classified into one of these states. The correction
2972/// loop and telemetry both key off this enum instead of ad-hoc Option checks.
2973#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2974#[serde(rename_all = "snake_case")]
2975pub enum ParseResultState {
2976    /// Layer C: strict JSON parse succeeded and semantic validation passed.
2977    StrictJsonOk,
2978    /// Layer D: tolerant file-marker recovery produced a valid bundle.
2979    TolerantRecoveryOk,
2980    /// No structured payload could be extracted from the response at all.
2981    NoStructuredPayload,
2982    /// JSON parsed but failed schema validation (missing required fields, wrong types).
2983    SchemaInvalid,
2984    /// Parsed and schema-valid but rejected by semantic validation (Layer E):
2985    /// unknown output files, disallowed commands, ownership violations, etc.
2986    SemanticallyRejected,
2987    /// Bundle is empty — parsed successfully but contained zero artifacts.
2988    EmptyBundle,
2989}
2990
2991impl ParseResultState {
2992    /// Whether this state represents a usable bundle.
2993    pub fn is_ok(&self) -> bool {
2994        matches!(self, Self::StrictJsonOk | Self::TolerantRecoveryOk)
2995    }
2996}
2997
2998impl std::fmt::Display for ParseResultState {
2999    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3000        match self {
3001            Self::StrictJsonOk => write!(f, "strict_json_ok"),
3002            Self::TolerantRecoveryOk => write!(f, "tolerant_recovery_ok"),
3003            Self::NoStructuredPayload => write!(f, "no_structured_payload"),
3004            Self::SchemaInvalid => write!(f, "schema_invalid"),
3005            Self::SemanticallyRejected => write!(f, "semantically_rejected"),
3006            Self::EmptyBundle => write!(f, "empty_bundle"),
3007        }
3008    }
3009}
3010
3011/// Retry classification for correction-loop failures (PSP-7 §3.3).
3012///
3013/// When a parse or semantic check fails, the correction loop classifies the
3014/// failure to decide between retrying, retargeting, or escalating.
3015#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3016#[serde(rename_all = "snake_case")]
3017pub enum RetryClassification {
3018    /// Response was malformed — retry with schema-clarification feedback.
3019    MalformedRetry,
3020    /// Artifacts targeted wrong files — retarget with ownership guidance.
3021    Retarget,
3022    /// LLM added unrequested support files — retry with legal-files guidance.
3023    SupportFileViolation,
3024    /// Failure is structural enough that replanning is needed.
3025    Replan,
3026    /// Budget is exhausted — cannot retry further.
3027    BudgetExhausted,
3028}
3029
3030impl std::fmt::Display for RetryClassification {
3031    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3032        match self {
3033            Self::MalformedRetry => write!(f, "malformed_retry"),
3034            Self::Retarget => write!(f, "retarget"),
3035            Self::SupportFileViolation => write!(f, "support_file_violation"),
3036            Self::Replan => write!(f, "replan"),
3037            Self::BudgetExhausted => write!(f, "budget_exhausted"),
3038        }
3039    }
3040}
3041
3042/// Telemetry record for a single correction attempt (PSP-7 §6).
3043///
3044/// Captures the full pipeline state for each correction round-trip so the
3045/// store can reconstruct exactly what happened during convergence.
3046#[derive(Debug, Clone, Serialize, Deserialize)]
3047pub struct CorrectionAttemptRecord {
3048    /// Which correction attempt within this node (1-based).
3049    pub attempt: u32,
3050    /// Parse result state from the typed pipeline.
3051    pub parse_state: ParseResultState,
3052    /// Retry classification (None if parse succeeded).
3053    pub retry_classification: Option<RetryClassification>,
3054    /// Raw response fingerprint (hash of the LLM response).
3055    pub response_fingerprint: String,
3056    /// Raw response byte length.
3057    pub response_length: usize,
3058    /// Energy snapshot after this attempt's verification.
3059    pub energy_after: Option<EnergyComponents>,
3060    /// Whether the correction was accepted and applied.
3061    pub accepted: bool,
3062    /// Human-readable rejection reason (if not accepted).
3063    pub rejection_reason: Option<String>,
3064    /// Epoch seconds when this attempt was recorded.
3065    pub created_at: i64,
3066}
3067
3068/// Intent tag for the prompt compiler (PSP-7 §5).
3069///
3070/// Each prompt emitted by the system carries an intent that determines which
3071/// template family and evidence inputs the compiler selects.
3072#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3073#[serde(rename_all = "snake_case")]
3074pub enum PromptIntent {
3075    /// Architect planning for an existing project.
3076    ArchitectExisting,
3077    /// Architect planning for a greenfield project.
3078    ArchitectGreenfield,
3079    /// Actuator coding (multi-output node).
3080    ActuatorMultiOutput,
3081    /// Actuator coding (single-output node).
3082    ActuatorSingleOutput,
3083    /// Verifier analysis.
3084    VerifierAnalysis,
3085    /// Correction retry after verification failure.
3086    CorrectionRetry,
3087    /// Bundle retarget after ownership/path rejection.
3088    BundleRetarget,
3089    /// Speculator basic lookahead.
3090    SpeculatorBasic,
3091    /// Speculator extended lookahead.
3092    SpeculatorLookahead,
3093    /// Solo mode generation.
3094    SoloGenerate,
3095    /// Solo mode correction.
3096    SoloCorrect,
3097    /// Project name suggestion.
3098    ProjectNameSuggest,
3099}
3100
3101/// Provenance metadata for a compiled prompt (PSP-7 §5).
3102///
3103/// Records which template, evidence sources, and plugin fragments contributed
3104/// to a final prompt so that observers can trace prompt lineage.
3105#[derive(Debug, Clone, Serialize, Deserialize)]
3106pub struct PromptProvenance {
3107    /// The intent that selected the template family.
3108    pub intent: PromptIntent,
3109    /// Which plugin contributed correction fragments (if any).
3110    pub plugin_fragment_source: Option<String>,
3111    /// Brief names of evidence sources folded into the prompt.
3112    pub evidence_sources: Vec<String>,
3113    /// Epoch seconds when the prompt was compiled.
3114    pub compiled_at: i64,
3115}
3116
3117/// A compiled prompt ready for submission to the LLM (PSP-7 §5).
3118///
3119/// Replaces raw string concatenation with a typed container that carries
3120/// the prompt text alongside its provenance metadata.
3121#[derive(Debug, Clone, Serialize, Deserialize)]
3122pub struct CompiledPrompt {
3123    /// The final prompt text to send to the LLM.
3124    pub text: String,
3125    /// Provenance metadata for observability.
3126    pub provenance: PromptProvenance,
3127}
3128
3129/// Evidence inputs for the prompt compiler (PSP-7 §5).
3130///
3131/// Each prompt family draws from a different subset of these fields.
3132/// The compiler ignores fields that are irrelevant for the selected intent.
3133#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3134pub struct PromptEvidence {
3135    /// The user's high-level goal or request.
3136    pub user_goal: Option<String>,
3137    /// Project structure summary (file tree, detected languages, etc.).
3138    pub project_summary: Option<String>,
3139    /// Node-scoped goal for actuator/verifier prompts.
3140    pub node_goal: Option<String>,
3141    /// Output files this node is expected to produce.
3142    pub output_files: Vec<String>,
3143    /// Context files the node should read.
3144    pub context_files: Vec<String>,
3145    /// Verifier diagnostics from the last verification pass.
3146    pub verifier_diagnostics: Option<String>,
3147    /// Previous correction attempt records for retry prompts.
3148    pub previous_attempts: Vec<CorrectionAttemptRecord>,
3149    /// Number of previous correction attempts (used when full records are unavailable).
3150    pub previous_attempt_count: usize,
3151    /// Plugin-contributed correction guidance.
3152    pub plugin_correction_fragment: Option<String>,
3153    /// Legal support files declared by the plugin.
3154    pub legal_support_files: Vec<String>,
3155    /// Existing file contents for context injection.
3156    pub existing_file_contents: Vec<(String, String)>,
3157    /// Dependency expectations for the current task.
3158    pub dependency_expectations: Option<DependencyExpectation>,
3159    /// Bundle that was rejected (for retarget prompts).
3160    pub rejected_bundle_summary: Option<String>,
3161    /// Solo mode file path.
3162    pub solo_file_path: Option<String>,
3163    /// Solo mode language hint.
3164    pub solo_language: Option<String>,
3165    /// Working directory path for context-aware prompts.
3166    pub working_dir: Option<String>,
3167    /// Active language plugins (e.g. `["rust", "python"]`).
3168    pub active_plugins: Vec<String>,
3169    /// Contract interface signature for actuator/verifier prompts.
3170    pub interface_signature: Option<String>,
3171    /// Contract invariants for actuator/verifier prompts.
3172    pub invariants: Option<String>,
3173    /// Contract forbidden patterns for actuator/verifier prompts.
3174    pub forbidden_patterns: Option<String>,
3175    /// Contract weighted tests for verifier prompts.
3176    pub weighted_tests: Option<String>,
3177    /// Workspace import hints for cross-module references.
3178    pub workspace_import_hints: Option<String>,
3179    /// Pre-formatted evidence section for architect prompts.
3180    pub evidence_section: Option<String>,
3181    /// Error feedback from previous planning attempts.
3182    pub error_feedback: Option<String>,
3183    /// Restriction map context for correction prompts (pre-formatted).
3184    pub restriction_map_context: Option<String>,
3185    /// Project file tree for correction prompts (pre-formatted lines).
3186    pub project_file_tree: Option<String>,
3187    /// Raw build/test output for correction prompts (truncated).
3188    pub build_test_output: Option<String>,
3189    /// Owner plugin name (e.g. "rust", "python") for language-specific guidance.
3190    pub owner_plugin: Option<String>,
3191    /// Syntactic energy score from the last verification pass.
3192    pub energy_v_syn: Option<f32>,
3193}
3194
3195/// Policy decision for a dependency command (PSP-7 §4).
3196#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3197pub enum CommandPolicyDecision {
3198    /// Command is allowed.
3199    Allow,
3200    /// Command is denied.
3201    Deny,
3202    /// Command requires user approval before execution.
3203    RequireApproval,
3204}
3205
3206/// Policy decision for a manifest mutation (PSP-7 §4).
3207#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3208pub enum ManifestMutationPolicy {
3209    /// Mutation is allowed.
3210    Allow,
3211    /// Mutation is denied.
3212    Deny,
3213}
3214
3215#[cfg(test)]
3216mod psp5_tests {
3217    use super::*;
3218
3219    #[test]
3220    fn test_execution_mode_default_is_project() {
3221        assert_eq!(ExecutionMode::default(), ExecutionMode::Project);
3222    }
3223
3224    #[test]
3225    fn test_node_class_default_is_implementation() {
3226        assert_eq!(NodeClass::default(), NodeClass::Implementation);
3227    }
3228
3229    #[test]
3230    fn test_artifact_bundle_roundtrip() {
3231        let bundle = ArtifactBundle {
3232            artifacts: vec![
3233                ArtifactOperation::Write {
3234                    path: "src/main.rs".to_string(),
3235                    content: "fn main() {}".to_string(),
3236                },
3237                ArtifactOperation::Diff {
3238                    path: "src/lib.rs".to_string(),
3239                    patch: "--- a\n+++ b\n@@ -1 +1 @@\n-old\n+new".to_string(),
3240                },
3241            ],
3242            commands: vec!["cargo build".to_string()],
3243        };
3244
3245        let json = serde_json::to_string(&bundle).unwrap();
3246        let deser: ArtifactBundle = serde_json::from_str(&json).unwrap();
3247
3248        assert_eq!(deser.len(), 2);
3249        assert_eq!(deser.writes_count(), 1);
3250        assert_eq!(deser.diffs_count(), 1);
3251        assert_eq!(deser.commands.len(), 1);
3252    }
3253
3254    #[test]
3255    fn test_artifact_bundle_validate_empty() {
3256        let bundle = ArtifactBundle::new();
3257        assert!(bundle.validate().is_err());
3258    }
3259
3260    #[test]
3261    fn test_artifact_bundle_validate_absolute_path() {
3262        let bundle = ArtifactBundle {
3263            artifacts: vec![ArtifactOperation::Write {
3264                path: "/etc/passwd".to_string(),
3265                content: "bad".to_string(),
3266            }],
3267            commands: vec![],
3268        };
3269        assert!(bundle.validate().is_err());
3270        assert!(bundle.validate().unwrap_err().contains("absolute path"));
3271    }
3272
3273    #[test]
3274    fn test_artifact_bundle_validate_path_traversal() {
3275        let bundle = ArtifactBundle {
3276            artifacts: vec![ArtifactOperation::Write {
3277                path: "../../etc/passwd".to_string(),
3278                content: "bad".to_string(),
3279            }],
3280            commands: vec![],
3281        };
3282        assert!(bundle.validate().is_err());
3283        assert!(bundle.validate().unwrap_err().contains("path traversal"));
3284    }
3285
3286    #[test]
3287    fn test_artifact_bundle_validate_ok() {
3288        let bundle = ArtifactBundle {
3289            artifacts: vec![ArtifactOperation::Write {
3290                path: "src/main.rs".to_string(),
3291                content: "fn main() {}".to_string(),
3292            }],
3293            commands: vec![],
3294        };
3295        assert!(bundle.validate().is_ok());
3296    }
3297
3298    #[test]
3299    fn test_artifact_operation_accessors() {
3300        let write = ArtifactOperation::Write {
3301            path: "foo.rs".to_string(),
3302            content: "bar".to_string(),
3303        };
3304        assert_eq!(write.path(), "foo.rs");
3305        assert!(write.is_write());
3306        assert!(!write.is_diff());
3307
3308        let diff = ArtifactOperation::Diff {
3309            path: "baz.rs".to_string(),
3310            patch: "patch".to_string(),
3311        };
3312        assert_eq!(diff.path(), "baz.rs");
3313        assert!(!diff.is_write());
3314        assert!(diff.is_diff());
3315    }
3316
3317    #[test]
3318    fn test_affected_paths_deduplication() {
3319        let bundle = ArtifactBundle {
3320            artifacts: vec![
3321                ArtifactOperation::Write {
3322                    path: "src/main.rs".to_string(),
3323                    content: "v1".to_string(),
3324                },
3325                ArtifactOperation::Diff {
3326                    path: "src/main.rs".to_string(),
3327                    patch: "patch".to_string(),
3328                },
3329            ],
3330            commands: vec![],
3331        };
3332        assert_eq!(bundle.affected_paths().len(), 1);
3333    }
3334
3335    #[test]
3336    fn test_verification_result_all_passed() {
3337        let mut result = VerificationResult::default();
3338        assert!(!result.all_passed()); // all false by default
3339
3340        result.syntax_ok = true;
3341        result.build_ok = true;
3342        result.tests_ok = true;
3343        assert!(result.all_passed());
3344    }
3345
3346    #[test]
3347    fn test_verification_result_degraded() {
3348        let result = VerificationResult::degraded("no cargo");
3349        assert!(result.degraded);
3350        assert!(!result.all_passed());
3351        assert_eq!(result.degraded_reason.unwrap(), "no cargo");
3352    }
3353
3354    // =========================================================================
3355    // PSP-5 Phase 2: Ownership Manifest Tests
3356    // =========================================================================
3357
3358    #[test]
3359    fn test_ownership_manifest_assign_and_lookup() {
3360        let mut manifest = OwnershipManifest::new();
3361        manifest.assign("src/main.rs", "node_1", "rust", NodeClass::Implementation);
3362        manifest.assign("src/lib.rs", "node_1", "rust", NodeClass::Implementation);
3363        manifest.assign("tests/test.rs", "node_2", "rust", NodeClass::Integration);
3364
3365        // owner_of
3366        let entry = manifest.owner_of("src/main.rs").unwrap();
3367        assert_eq!(entry.owner_node_id, "node_1");
3368        assert_eq!(entry.owner_plugin, "rust");
3369        assert_eq!(entry.node_class, NodeClass::Implementation);
3370
3371        assert!(manifest.owner_of("nonexistent.rs").is_none());
3372
3373        // files_owned_by
3374        let mut files = manifest.files_owned_by("node_1");
3375        files.sort();
3376        assert_eq!(files, vec!["src/lib.rs", "src/main.rs"]);
3377
3378        let files_2 = manifest.files_owned_by("node_2");
3379        assert_eq!(files_2, vec!["tests/test.rs"]);
3380
3381        assert_eq!(manifest.len(), 3);
3382        assert!(!manifest.is_empty());
3383    }
3384
3385    #[test]
3386    fn test_ownership_manifest_validate_bundle_ok() {
3387        let mut manifest = OwnershipManifest::new();
3388        manifest.assign("src/main.rs", "node_1", "rust", NodeClass::Implementation);
3389        manifest.assign("src/lib.rs", "node_1", "rust", NodeClass::Implementation);
3390
3391        let bundle = ArtifactBundle {
3392            artifacts: vec![
3393                ArtifactOperation::Write {
3394                    path: "src/main.rs".to_string(),
3395                    content: "fn main() {}".to_string(),
3396                },
3397                ArtifactOperation::Write {
3398                    path: "src/lib.rs".to_string(),
3399                    content: "pub fn lib() {}".to_string(),
3400                },
3401            ],
3402            commands: vec![],
3403        };
3404
3405        // node_1 owns both files → should pass
3406        assert!(manifest
3407            .validate_bundle(&bundle, "node_1", NodeClass::Implementation)
3408            .is_ok());
3409    }
3410
3411    #[test]
3412    fn test_ownership_manifest_validate_bundle_cross_owner_rejected() {
3413        let mut manifest = OwnershipManifest::new();
3414        manifest.assign("src/main.rs", "node_1", "rust", NodeClass::Implementation);
3415        manifest.assign("src/other.rs", "node_2", "rust", NodeClass::Implementation);
3416
3417        let bundle = ArtifactBundle {
3418            artifacts: vec![
3419                ArtifactOperation::Write {
3420                    path: "src/main.rs".to_string(),
3421                    content: "fn main() {}".to_string(),
3422                },
3423                ArtifactOperation::Write {
3424                    path: "src/other.rs".to_string(),
3425                    content: "fn other() {}".to_string(),
3426                },
3427            ],
3428            commands: vec![],
3429        };
3430
3431        // node_1 tries to modify node_2's file → rejected
3432        let result = manifest.validate_bundle(&bundle, "node_1", NodeClass::Implementation);
3433        assert!(result.is_err());
3434        assert!(result.unwrap_err().contains("Ownership violation"));
3435    }
3436
3437    #[test]
3438    fn test_ownership_manifest_validate_integration_cross_owner_ok() {
3439        let mut manifest = OwnershipManifest::new();
3440        manifest.assign("src/main.rs", "node_1", "rust", NodeClass::Implementation);
3441        manifest.assign("src/other.rs", "node_2", "rust", NodeClass::Implementation);
3442
3443        let bundle = ArtifactBundle {
3444            artifacts: vec![
3445                ArtifactOperation::Write {
3446                    path: "src/main.rs".to_string(),
3447                    content: "fn main() {}".to_string(),
3448                },
3449                ArtifactOperation::Write {
3450                    path: "src/other.rs".to_string(),
3451                    content: "fn other() {}".to_string(),
3452                },
3453            ],
3454            commands: vec![],
3455        };
3456
3457        // Integration node can cross ownership boundaries
3458        let result = manifest.validate_bundle(&bundle, "node_3", NodeClass::Integration);
3459        assert!(result.is_ok());
3460    }
3461
3462    #[test]
3463    fn test_ownership_manifest_fanout_limit() {
3464        let manifest = OwnershipManifest::with_fanout_limit(2);
3465
3466        let bundle = ArtifactBundle {
3467            artifacts: vec![
3468                ArtifactOperation::Write {
3469                    path: "a.rs".to_string(),
3470                    content: "a".to_string(),
3471                },
3472                ArtifactOperation::Write {
3473                    path: "b.rs".to_string(),
3474                    content: "b".to_string(),
3475                },
3476                ArtifactOperation::Write {
3477                    path: "c.rs".to_string(),
3478                    content: "c".to_string(),
3479                },
3480            ],
3481            commands: vec![],
3482        };
3483
3484        // 3 artifacts exceeds fanout limit of 2
3485        let result = manifest.validate_bundle(&bundle, "node_1", NodeClass::Implementation);
3486        assert!(result.is_err());
3487        assert!(result.unwrap_err().contains("fanout limit"));
3488
3489        // Exactly at the limit should pass
3490        let small_bundle = ArtifactBundle {
3491            artifacts: vec![
3492                ArtifactOperation::Write {
3493                    path: "a.rs".to_string(),
3494                    content: "a".to_string(),
3495                },
3496                ArtifactOperation::Write {
3497                    path: "b.rs".to_string(),
3498                    content: "b".to_string(),
3499                },
3500            ],
3501            commands: vec![],
3502        };
3503        assert!(manifest
3504            .validate_bundle(&small_bundle, "node_1", NodeClass::Implementation)
3505            .is_ok());
3506    }
3507
3508    #[test]
3509    fn test_ownership_manifest_assign_new_paths() {
3510        let mut manifest = OwnershipManifest::new();
3511        manifest.assign("src/main.rs", "node_1", "rust", NodeClass::Implementation);
3512
3513        let bundle = ArtifactBundle {
3514            artifacts: vec![
3515                ArtifactOperation::Write {
3516                    path: "src/main.rs".to_string(),
3517                    content: "existing".to_string(),
3518                },
3519                ArtifactOperation::Write {
3520                    path: "src/new_file.rs".to_string(),
3521                    content: "new".to_string(),
3522                },
3523            ],
3524            commands: vec![],
3525        };
3526
3527        manifest.assign_new_paths(&bundle, "node_1", "rust", NodeClass::Implementation);
3528
3529        // Existing entry unchanged
3530        assert_eq!(
3531            manifest.owner_of("src/main.rs").unwrap().owner_node_id,
3532            "node_1"
3533        );
3534        // New path auto-assigned
3535        let new_entry = manifest.owner_of("src/new_file.rs").unwrap();
3536        assert_eq!(new_entry.owner_node_id, "node_1");
3537        assert_eq!(new_entry.owner_plugin, "rust");
3538        assert_eq!(manifest.len(), 2);
3539    }
3540
3541    // =========================================================================
3542    // PSP-5: Plan Ownership Closure Validation Tests
3543    // =========================================================================
3544
3545    #[test]
3546    fn test_plan_validate_duplicate_output_files_rejected() {
3547        let plan = TaskPlan {
3548            tasks: vec![
3549                PlannedTask {
3550                    id: "task_1".into(),
3551                    goal: "Create math module".into(),
3552                    output_files: vec!["src/math.py".into(), "tests/test_math.py".into()],
3553                    ..PlannedTask::new("task_1", "Create math module")
3554                },
3555                PlannedTask {
3556                    id: "task_2".into(),
3557                    goal: "Create tests".into(),
3558                    output_files: vec!["tests/test_math.py".into()],
3559                    ..PlannedTask::new("task_2", "Create tests")
3560                },
3561            ],
3562        };
3563        let result = plan.validate();
3564        assert!(result.is_err());
3565        let err = result.unwrap_err();
3566        assert!(
3567            err.contains("tests/test_math.py"),
3568            "Error should mention the duplicate file: {}",
3569            err
3570        );
3571        assert!(
3572            err.contains("Ownership violation"),
3573            "Error should mention ownership: {}",
3574            err
3575        );
3576    }
3577
3578    #[test]
3579    fn test_plan_validate_unique_output_files_ok() {
3580        let plan = TaskPlan {
3581            tasks: vec![
3582                PlannedTask {
3583                    id: "task_1".into(),
3584                    goal: "Create math module".into(),
3585                    output_files: vec!["src/math.py".into()],
3586                    ..PlannedTask::new("task_1", "Create math module")
3587                },
3588                PlannedTask {
3589                    id: "test_1".into(),
3590                    goal: "Tests for math".into(),
3591                    output_files: vec!["tests/test_math.py".into()],
3592                    dependencies: vec!["task_1".into()],
3593                    ..PlannedTask::new("test_1", "Tests for math")
3594                },
3595            ],
3596        };
3597        assert!(plan.validate().is_ok());
3598    }
3599
3600    #[test]
3601    fn test_plan_validate_context_files_do_not_conflict_with_output_files() {
3602        // Reading another task's file via context_files is fine
3603        let plan = TaskPlan {
3604            tasks: vec![
3605                PlannedTask {
3606                    id: "task_1".into(),
3607                    goal: "Create math module".into(),
3608                    output_files: vec!["src/math.py".into()],
3609                    ..PlannedTask::new("task_1", "Create math module")
3610                },
3611                PlannedTask {
3612                    id: "test_1".into(),
3613                    goal: "Tests for math".into(),
3614                    context_files: vec!["src/math.py".into()], // reading, not owning
3615                    output_files: vec!["tests/test_math.py".into()],
3616                    dependencies: vec!["task_1".into()],
3617                    ..PlannedTask::new("test_1", "Tests for math")
3618                },
3619            ],
3620        };
3621        assert!(plan.validate().is_ok());
3622    }
3623
3624    // =========================================================================
3625    // PSP-5 Phase 3: Structural Digests, Context Packages, Provenance Tests
3626    // =========================================================================
3627
3628    #[test]
3629    fn test_structural_digest_from_content() {
3630        let digest = StructuralDigest::from_content(
3631            "node_1",
3632            "src/main.rs",
3633            ArtifactKind::Signature,
3634            b"fn main() {}",
3635        );
3636
3637        assert_eq!(digest.source_node_id, "node_1");
3638        assert_eq!(digest.source_path, "src/main.rs");
3639        assert_eq!(digest.artifact_kind, ArtifactKind::Signature);
3640        assert_eq!(digest.version, 1);
3641        assert!(!digest.digest_id.is_empty());
3642        // Hash must be non-zero
3643        assert_ne!(digest.hash, [0u8; 32]);
3644    }
3645
3646    #[test]
3647    fn test_structural_digest_matches() {
3648        let d1 = StructuralDigest::from_content(
3649            "node_1",
3650            "src/main.rs",
3651            ArtifactKind::Signature,
3652            b"fn main() {}",
3653        );
3654        let d2 = StructuralDigest::from_content(
3655            "node_1",
3656            "src/main.rs",
3657            ArtifactKind::Signature,
3658            b"fn main() {}",
3659        );
3660        let d3 = StructuralDigest::from_content(
3661            "node_1",
3662            "src/main.rs",
3663            ArtifactKind::Signature,
3664            b"fn main() { println!(); }",
3665        );
3666
3667        assert!(d1.matches(&d2));
3668        assert!(!d1.matches(&d3));
3669    }
3670
3671    #[test]
3672    fn test_context_budget_default() {
3673        let budget = ContextBudget::default();
3674        assert_eq!(budget.byte_limit, 100 * 1024); // 100KB
3675        assert_eq!(budget.file_count_limit, 20);
3676    }
3677
3678    #[test]
3679    fn test_restriction_map_for_node() {
3680        let map = RestrictionMap::for_node("node_1".to_string());
3681        assert_eq!(map.node_id, "node_1");
3682        assert!(map.owned_files.is_empty());
3683        assert!(map.sealed_interfaces.is_empty());
3684        assert_eq!(map.budget, ContextBudget::default());
3685    }
3686
3687    #[test]
3688    fn test_restriction_map_structural_bytes() {
3689        let mut map = RestrictionMap::for_node("node_1".to_string());
3690        let d = StructuralDigest::from_content(
3691            "n1",
3692            "src/a.rs",
3693            ArtifactKind::InterfaceSeal,
3694            b"content",
3695        );
3696        map.structural_digests.push(d);
3697        // structural_bytes = source_path.len() + 64 per digest + sealed_interfaces * 128
3698        assert!(map.structural_bytes() > 0);
3699    }
3700
3701    #[test]
3702    fn test_context_package_add_file_within_budget() {
3703        let mut pkg = ContextPackage::new("node_1".to_string());
3704        pkg.restriction_map.budget.byte_limit = 1024;
3705
3706        assert!(pkg.add_file("a.rs", "hello world".to_string()));
3707        assert_eq!(pkg.included_files.len(), 1);
3708        assert_eq!(pkg.total_bytes, 11);
3709        assert!(!pkg.budget_exceeded);
3710    }
3711
3712    #[test]
3713    fn test_context_package_add_file_exceeds_budget() {
3714        let mut pkg = ContextPackage::new("node_1".to_string());
3715        pkg.restriction_map.budget.byte_limit = 10;
3716
3717        let result = pkg.add_file("big.rs", "this is more than ten bytes".to_string());
3718        assert!(!result);
3719        assert!(pkg.budget_exceeded);
3720        // File should not have been added
3721        assert!(pkg.included_files.is_empty());
3722    }
3723
3724    #[test]
3725    fn test_context_package_provenance() {
3726        let mut pkg = ContextPackage::new("node_1".to_string());
3727        pkg.add_file("a.rs", "content".to_string());
3728
3729        let d = StructuralDigest::from_content("n1", "src/a.rs", ArtifactKind::Signature, b"data");
3730        pkg.add_structural_digest(d);
3731
3732        let prov = pkg.provenance();
3733        assert_eq!(prov.node_id, "node_1");
3734        assert_eq!(prov.context_package_id, pkg.package_id);
3735        assert_eq!(prov.included_file_count, 1);
3736        assert_eq!(prov.structural_digest_hashes.len(), 1);
3737        assert!(prov.total_bytes > 0);
3738    }
3739
3740    #[test]
3741    fn test_context_provenance_default() {
3742        let prov = ContextProvenance::default();
3743        assert!(prov.node_id.is_empty());
3744        assert!(prov.structural_digest_hashes.is_empty());
3745        assert_eq!(prov.included_file_count, 0);
3746    }
3747
3748    #[test]
3749    fn test_artifact_kind_display() {
3750        assert_eq!(format!("{}", ArtifactKind::Signature), "signature");
3751        assert_eq!(format!("{}", ArtifactKind::InterfaceSeal), "interface_seal");
3752    }
3753
3754    #[test]
3755    fn test_sensor_status_display() {
3756        assert_eq!(format!("{}", SensorStatus::Available), "available");
3757        assert_eq!(
3758            format!(
3759                "{}",
3760                SensorStatus::Fallback {
3761                    actual: "ruff".into(),
3762                    reason: "primary not found".into()
3763                }
3764            ),
3765            "fallback(ruff)"
3766        );
3767        assert_eq!(
3768            format!(
3769                "{}",
3770                SensorStatus::Unavailable {
3771                    reason: "not installed".into()
3772                }
3773            ),
3774            "unavailable(not installed)"
3775        );
3776    }
3777
3778    #[test]
3779    fn test_verification_result_no_degraded_stages() {
3780        let result = VerificationResult {
3781            syntax_ok: true,
3782            build_ok: true,
3783            tests_ok: true,
3784            lint_ok: true,
3785            stage_outcomes: vec![StageOutcome {
3786                stage: "syntax_check".into(),
3787                passed: true,
3788                sensor_status: SensorStatus::Available,
3789                output: None,
3790            }],
3791            ..Default::default()
3792        };
3793        assert!(result.all_passed());
3794        assert!(!result.has_degraded_stages());
3795        assert!(result.degraded_stage_reasons().is_empty());
3796    }
3797
3798    #[test]
3799    fn test_verification_result_with_fallback_blocks_stability() {
3800        let result = VerificationResult {
3801            syntax_ok: true,
3802            build_ok: true,
3803            tests_ok: true,
3804            lint_ok: true,
3805            stage_outcomes: vec![
3806                StageOutcome {
3807                    stage: "syntax_check".into(),
3808                    passed: true,
3809                    sensor_status: SensorStatus::Available,
3810                    output: None,
3811                },
3812                StageOutcome {
3813                    stage: "test".into(),
3814                    passed: true,
3815                    sensor_status: SensorStatus::Fallback {
3816                        actual: "python -m pytest".into(),
3817                        reason: "uv not found".into(),
3818                    },
3819                    output: None,
3820                },
3821            ],
3822            ..Default::default()
3823        };
3824        // All tools passed but a fallback was used — should flag degraded
3825        assert!(result.has_degraded_stages());
3826        let reasons = result.degraded_stage_reasons();
3827        assert_eq!(reasons.len(), 1);
3828        assert!(reasons[0].contains("test"));
3829        assert!(reasons[0].contains("fallback"));
3830    }
3831
3832    #[test]
3833    fn test_verification_result_unavailable_stage() {
3834        let result = VerificationResult {
3835            syntax_ok: false,
3836            stage_outcomes: vec![StageOutcome {
3837                stage: "lint".into(),
3838                passed: false,
3839                sensor_status: SensorStatus::Unavailable {
3840                    reason: "clippy not installed".into(),
3841                },
3842                output: None,
3843            }],
3844            ..Default::default()
3845        };
3846        assert!(result.has_degraded_stages());
3847        let reasons = result.degraded_stage_reasons();
3848        assert!(reasons[0].contains("clippy not installed"));
3849    }
3850
3851    #[test]
3852    fn test_verification_result_mixed_stages() {
3853        // A realistic result: syntax passed on primary, lint fell back, tests unavailable
3854        let result = VerificationResult {
3855            syntax_ok: true,
3856            tests_ok: false,
3857            lint_ok: false,
3858            stage_outcomes: vec![
3859                StageOutcome {
3860                    stage: "syntax_check".into(),
3861                    passed: true,
3862                    sensor_status: SensorStatus::Available,
3863                    output: Some("OK".into()),
3864                },
3865                StageOutcome {
3866                    stage: "lint".into(),
3867                    passed: true,
3868                    sensor_status: SensorStatus::Fallback {
3869                        actual: "cargo check".into(),
3870                        reason: "clippy not found".into(),
3871                    },
3872                    output: Some("warnings only".into()),
3873                },
3874                StageOutcome {
3875                    stage: "test".into(),
3876                    passed: false,
3877                    sensor_status: SensorStatus::Unavailable {
3878                        reason: "no test runner".into(),
3879                    },
3880                    output: None,
3881                },
3882            ],
3883            ..Default::default()
3884        };
3885        assert!(result.has_degraded_stages());
3886        let reasons = result.degraded_stage_reasons();
3887        // Both lint (fallback) and test (unavailable) should be degraded
3888        assert_eq!(reasons.len(), 2);
3889        assert!(reasons.iter().any(|r| r.contains("lint")));
3890        assert!(reasons.iter().any(|r| r.contains("test")));
3891    }
3892
3893    // =========================================================================
3894    // Phase 5: Escalation, graph rewrite, and sheaf validator types
3895    // =========================================================================
3896
3897    #[test]
3898    fn test_escalation_category_display() {
3899        assert_eq!(
3900            EscalationCategory::ImplementationError.to_string(),
3901            "implementation_error"
3902        );
3903        assert_eq!(
3904            EscalationCategory::ContractMismatch.to_string(),
3905            "contract_mismatch"
3906        );
3907        assert_eq!(
3908            EscalationCategory::DegradedSensors.to_string(),
3909            "degraded_sensors"
3910        );
3911    }
3912
3913    #[test]
3914    fn test_rewrite_action_grounded_retry() {
3915        let action = RewriteAction::GroundedRetry {
3916            evidence_summary: "build failed twice".into(),
3917        };
3918        match action {
3919            RewriteAction::GroundedRetry { evidence_summary } => {
3920                assert!(evidence_summary.contains("build failed"));
3921            }
3922            _ => panic!("Expected GroundedRetry"),
3923        }
3924    }
3925
3926    #[test]
3927    fn test_rewrite_action_node_split() {
3928        let action = RewriteAction::NodeSplit {
3929            proposed_children: vec!["child_a".into(), "child_b".into()],
3930        };
3931        match action {
3932            RewriteAction::NodeSplit { proposed_children } => {
3933                assert_eq!(proposed_children.len(), 2);
3934            }
3935            _ => panic!("Expected NodeSplit"),
3936        }
3937    }
3938
3939    #[test]
3940    fn test_sheaf_validator_class_display() {
3941        assert_eq!(
3942            SheafValidatorClass::DependencyGraphConsistency.to_string(),
3943            "dependency_graph"
3944        );
3945        assert_eq!(
3946            SheafValidatorClass::CrossLanguageBoundary.to_string(),
3947            "cross_language"
3948        );
3949    }
3950
3951    #[test]
3952    fn test_sheaf_validation_result_passed() {
3953        let result = SheafValidationResult::passed(
3954            SheafValidatorClass::DependencyGraphConsistency,
3955            vec!["node_1".into()],
3956        );
3957        assert!(result.passed);
3958        assert_eq!(result.v_sheaf_contribution, 0.0);
3959        assert!(result.evidence_summary.is_empty());
3960        assert!(result.requeue_targets.is_empty());
3961    }
3962
3963    #[test]
3964    fn test_sheaf_validation_result_failed() {
3965        let result = SheafValidationResult::failed(
3966            SheafValidatorClass::ExportImportConsistency,
3967            "ownership mismatch on 2 files",
3968            vec!["src/a.rs".into(), "src/b.rs".into()],
3969            vec!["node_2".into()],
3970            0.3,
3971        );
3972        assert!(!result.passed);
3973        assert_eq!(result.v_sheaf_contribution, 0.3);
3974        assert!(result.evidence_summary.contains("ownership mismatch"));
3975        assert_eq!(result.affected_files.len(), 2);
3976        assert_eq!(result.requeue_targets, vec!["node_2"]);
3977    }
3978
3979    #[test]
3980    fn test_escalation_report_roundtrip() {
3981        let report = EscalationReport {
3982            node_id: "test_node".into(),
3983            session_id: "sess_1".into(),
3984            category: EscalationCategory::TopologyMismatch,
3985            action: RewriteAction::InterfaceInsertion {
3986                boundary: "module_boundary".into(),
3987            },
3988            energy_snapshot: EnergyComponents::default(),
3989            stage_outcomes: Vec::new(),
3990            evidence: "violation at boundary".into(),
3991            affected_node_ids: vec!["dep_1".into()],
3992            timestamp: 12345,
3993        };
3994        let json = serde_json::to_string(&report).unwrap();
3995        let deser: EscalationReport = serde_json::from_str(&json).unwrap();
3996        assert_eq!(deser.node_id, "test_node");
3997        assert_eq!(deser.category, EscalationCategory::TopologyMismatch);
3998        assert_eq!(deser.affected_node_ids.len(), 1);
3999    }
4000
4001    #[test]
4002    fn test_stability_monitor_reset_for_replan() {
4003        let mut monitor = StabilityMonitor::new();
4004        monitor.record_energy(0.8);
4005        monitor.record_energy(0.5);
4006        monitor.record_failure(ErrorType::Compilation);
4007        assert_eq!(monitor.attempt_count, 2);
4008
4009        monitor.reset_for_replan();
4010        assert_eq!(monitor.attempt_count, 0);
4011        assert!(!monitor.stable);
4012        // History is preserved
4013        assert_eq!(monitor.energy_history.len(), 2);
4014    }
4015
4016    #[test]
4017    fn test_rewrite_record_serialization() {
4018        let record = RewriteRecord {
4019            node_id: "n1".into(),
4020            session_id: "s1".into(),
4021            action: RewriteAction::SubgraphReplan {
4022                affected_nodes: vec!["n2".into(), "n3".into()],
4023            },
4024            category: EscalationCategory::InsufficientModelCapability,
4025            requeued_nodes: vec!["n2".into(), "n3".into()],
4026            inserted_nodes: Vec::new(),
4027            timestamp: 99999,
4028        };
4029        let json = serde_json::to_string(&record).unwrap();
4030        let deser: RewriteRecord = serde_json::from_str(&json).unwrap();
4031        assert_eq!(deser.requeued_nodes.len(), 2);
4032        assert!(deser.inserted_nodes.is_empty());
4033    }
4034
4035    // =========================================================================
4036    // PSP-5 Phase 6: Provisional Branch and Seal Tests
4037    // =========================================================================
4038
4039    #[test]
4040    fn test_provisional_branch_state_display() {
4041        assert_eq!(ProvisionalBranchState::Active.to_string(), "active");
4042        assert_eq!(ProvisionalBranchState::Sealed.to_string(), "sealed");
4043        assert_eq!(ProvisionalBranchState::Merged.to_string(), "merged");
4044        assert_eq!(ProvisionalBranchState::Flushed.to_string(), "flushed");
4045    }
4046
4047    #[test]
4048    fn test_provisional_branch_lifecycle() {
4049        let branch = ProvisionalBranch::new("b1", "s1", "node_child", "node_parent");
4050        assert_eq!(branch.state, ProvisionalBranchState::Active);
4051        assert!(branch.is_live());
4052        assert!(!branch.is_flushed());
4053        assert!(branch.parent_seal_hash.is_none());
4054        assert!(branch.sandbox_dir.is_none());
4055        assert!(branch.created_at > 0);
4056    }
4057
4058    #[test]
4059    fn test_provisional_branch_flushed_not_live() {
4060        let mut branch = ProvisionalBranch::new("b1", "s1", "n1", "p1");
4061        branch.state = ProvisionalBranchState::Flushed;
4062        assert!(!branch.is_live());
4063        assert!(branch.is_flushed());
4064    }
4065
4066    #[test]
4067    fn test_provisional_branch_sealed_is_live() {
4068        let mut branch = ProvisionalBranch::new("b1", "s1", "n1", "p1");
4069        branch.state = ProvisionalBranchState::Sealed;
4070        assert!(branch.is_live());
4071        assert!(!branch.is_flushed());
4072    }
4073
4074    #[test]
4075    fn test_provisional_branch_serialization() {
4076        let branch = ProvisionalBranch::new("b1", "s1", "n1", "p1");
4077        let json = serde_json::to_string(&branch).unwrap();
4078        let deser: ProvisionalBranch = serde_json::from_str(&json).unwrap();
4079        assert_eq!(deser.branch_id, "b1");
4080        assert_eq!(deser.state, ProvisionalBranchState::Active);
4081    }
4082
4083    #[test]
4084    fn test_branch_lineage_serialization() {
4085        let lineage = BranchLineage {
4086            lineage_id: "lin_1".into(),
4087            parent_branch_id: "parent_b".into(),
4088            child_branch_id: "child_b".into(),
4089            depends_on_seal: true,
4090        };
4091        let json = serde_json::to_string(&lineage).unwrap();
4092        let deser: BranchLineage = serde_json::from_str(&json).unwrap();
4093        assert!(deser.depends_on_seal);
4094        assert_eq!(deser.parent_branch_id, "parent_b");
4095    }
4096
4097    #[test]
4098    fn test_interface_seal_from_digest() {
4099        let digest = StructuralDigest::from_content(
4100            "node_iface",
4101            "src/api.rs",
4102            ArtifactKind::InterfaceSeal,
4103            b"pub fn hello() -> String",
4104        );
4105        let seal = InterfaceSealRecord::from_digest("sess1", "node_iface", &digest);
4106        assert_eq!(seal.node_id, "node_iface");
4107        assert_eq!(seal.sealed_path, "src/api.rs");
4108        assert!(seal.matches_hash(&digest.hash));
4109        assert!(!seal.matches_hash(&[0u8; 32]));
4110    }
4111
4112    #[test]
4113    fn test_branch_flush_record() {
4114        let flush = BranchFlushRecord::new(
4115            "s1",
4116            "parent_node",
4117            vec!["b1".into(), "b2".into()],
4118            vec!["child1".into(), "child2".into()],
4119            "Parent failed verification",
4120        );
4121        assert!(flush.flush_id.starts_with("flush_"));
4122        assert_eq!(flush.flushed_branch_ids.len(), 2);
4123        assert_eq!(flush.requeue_node_ids.len(), 2);
4124        assert!(flush.created_at > 0);
4125    }
4126
4127    #[test]
4128    fn test_blocked_dependency() {
4129        let dep = BlockedDependency::new("child_node", "parent_node", vec!["src/api.rs".into()]);
4130        assert_eq!(dep.child_node_id, "child_node");
4131        assert_eq!(dep.parent_node_id, "parent_node");
4132        assert_eq!(dep.required_seal_paths.len(), 1);
4133        assert!(dep.blocked_at > 0);
4134    }
4135
4136    #[test]
4137    fn test_srbn_node_phase6_fields() {
4138        let node = SRBNNode::new("n1".into(), "goal".into(), ModelTier::Actuator);
4139        assert!(node.provisional_branch_id.is_none());
4140        assert!(node.interface_seal_hash.is_none());
4141    }
4142
4143    // =========================================================================
4144    // Plan Revision, Charter, Repair, and Budget Tests
4145    // =========================================================================
4146
4147    #[test]
4148    fn test_plan_revision_initial() {
4149        let plan = TaskPlan {
4150            tasks: vec![PlannedTask::new("t1", "Do something")],
4151        };
4152        let rev = PlanRevision::initial("session_1", plan);
4153        assert_eq!(rev.sequence, 1);
4154        assert_eq!(rev.reason, "initial");
4155        assert!(rev.supersedes.is_none());
4156        assert!(rev.is_active());
4157        assert_eq!(rev.status, PlanRevisionStatus::Active);
4158    }
4159
4160    #[test]
4161    fn test_plan_revision_successor() {
4162        let plan1 = TaskPlan {
4163            tasks: vec![PlannedTask::new("t1", "First")],
4164        };
4165        let rev1 = PlanRevision::initial("s1", plan1);
4166
4167        let plan2 = TaskPlan {
4168            tasks: vec![PlannedTask::new("t2", "Second")],
4169        };
4170        let rev2 = PlanRevision::successor(&rev1, plan2, "verification_failure");
4171
4172        assert_eq!(rev2.sequence, 2);
4173        assert_eq!(rev2.reason, "verification_failure");
4174        assert_eq!(rev2.supersedes, Some(rev1.revision_id.clone()));
4175        assert!(rev2.is_active());
4176    }
4177
4178    #[test]
4179    fn test_plan_revision_status_display() {
4180        assert_eq!(PlanRevisionStatus::Active.to_string(), "active");
4181        assert_eq!(PlanRevisionStatus::Superseded.to_string(), "superseded");
4182        assert_eq!(PlanRevisionStatus::Cancelled.to_string(), "cancelled");
4183    }
4184
4185    #[test]
4186    fn test_planning_policy_defaults_and_queries() {
4187        let policy = PlanningPolicy::default();
4188        assert_eq!(policy, PlanningPolicy::FeatureIncrement);
4189        assert!(policy.needs_architect());
4190        assert!(!policy.needs_speculator());
4191
4192        assert!(!PlanningPolicy::LocalEdit.needs_architect());
4193        assert!(!PlanningPolicy::LocalEdit.needs_speculator());
4194
4195        assert!(PlanningPolicy::LargeFeature.needs_architect());
4196        assert!(PlanningPolicy::LargeFeature.needs_speculator());
4197
4198        assert!(PlanningPolicy::GreenfieldBuild.needs_architect());
4199        assert!(PlanningPolicy::GreenfieldBuild.needs_speculator());
4200
4201        assert!(PlanningPolicy::ArchitecturalRevision.needs_architect());
4202        assert!(PlanningPolicy::ArchitecturalRevision.needs_speculator());
4203    }
4204
4205    #[test]
4206    fn test_repair_footprint_creation() {
4207        let bundle = ArtifactBundle {
4208            artifacts: vec![ArtifactOperation::Write {
4209                path: "src/fix.rs".into(),
4210                content: "fixed".into(),
4211            }],
4212            commands: vec![],
4213        };
4214        let fp = RepairFootprint::new("s1", "node1", "rev1", 1, &bundle, "Syntax error");
4215        assert_eq!(fp.node_id, "node1");
4216        assert_eq!(fp.attempt, 1);
4217        assert_eq!(fp.affected_files, vec!["src/fix.rs"]);
4218        assert!(!fp.resolved);
4219
4220        let mut fp = fp;
4221        fp.mark_resolved();
4222        assert!(fp.resolved);
4223    }
4224
4225    #[test]
4226    fn test_budget_envelope_tracking() {
4227        let mut budget = BudgetEnvelope::new("s1");
4228        budget.max_steps = Some(3);
4229        budget.max_revisions = Some(2);
4230        budget.max_cost_usd = Some(1.0);
4231
4232        assert!(!budget.any_exhausted());
4233
4234        budget.record_step();
4235        budget.record_step();
4236        assert!(!budget.steps_exhausted());
4237        budget.record_step();
4238        assert!(budget.steps_exhausted());
4239        assert!(budget.any_exhausted());
4240    }
4241
4242    #[test]
4243    fn test_budget_envelope_cost_tracking() {
4244        let mut budget = BudgetEnvelope::new("s1");
4245        budget.max_cost_usd = Some(0.50);
4246        budget.record_cost(0.25);
4247        assert!(!budget.cost_exhausted());
4248        budget.record_cost(0.30);
4249        assert!(budget.cost_exhausted());
4250    }
4251
4252    #[test]
4253    fn test_artifact_operation_delete_and_move() {
4254        let del = ArtifactOperation::Delete {
4255            path: "src/old.rs".into(),
4256        };
4257        assert!(del.is_delete());
4258        assert!(!del.is_write());
4259        assert_eq!(del.path(), "src/old.rs");
4260
4261        let mv = ArtifactOperation::Move {
4262            from: "src/old.rs".into(),
4263            to: "src/new.rs".into(),
4264        };
4265        assert!(mv.is_move());
4266        assert!(!mv.is_write());
4267        assert_eq!(mv.path(), "src/old.rs");
4268    }
4269
4270    #[test]
4271    fn test_artifact_bundle_with_delete_and_move() {
4272        let bundle = ArtifactBundle {
4273            artifacts: vec![
4274                ArtifactOperation::Write {
4275                    path: "src/new.rs".into(),
4276                    content: "code".into(),
4277                },
4278                ArtifactOperation::Delete {
4279                    path: "src/old.rs".into(),
4280                },
4281                ArtifactOperation::Move {
4282                    from: "src/a.rs".into(),
4283                    to: "src/b.rs".into(),
4284                },
4285            ],
4286            commands: vec![],
4287        };
4288        assert_eq!(bundle.writes_count(), 1);
4289        assert_eq!(bundle.deletes_count(), 1);
4290        assert_eq!(bundle.moves_count(), 1);
4291        assert!(bundle.validate().is_ok());
4292
4293        let paths = bundle.affected_paths();
4294        assert!(paths.contains(&"src/new.rs"));
4295        assert!(paths.contains(&"src/old.rs"));
4296        assert!(paths.contains(&"src/a.rs"));
4297        assert!(paths.contains(&"src/b.rs"));
4298    }
4299
4300    #[test]
4301    fn test_artifact_bundle_move_validation() {
4302        // Move with traversal in destination should fail
4303        let bundle = ArtifactBundle {
4304            artifacts: vec![ArtifactOperation::Move {
4305                from: "src/a.rs".into(),
4306                to: "../outside.rs".into(),
4307            }],
4308            commands: vec![],
4309        };
4310        assert!(bundle.validate().is_err());
4311    }
4312
4313    #[test]
4314    fn test_dependency_expectation_default() {
4315        let de = DependencyExpectation::default();
4316        assert!(de.required_packages.is_empty());
4317        assert!(de.setup_commands.is_empty());
4318        assert!(de.min_toolchain_version.is_none());
4319    }
4320
4321    #[test]
4322    fn test_planned_task_has_dependency_expectations() {
4323        let task = PlannedTask::new("t1", "Build module");
4324        assert!(task.dependency_expectations.required_packages.is_empty());
4325    }
4326
4327    #[test]
4328    fn test_srbn_node_carries_dependency_expectations() {
4329        let mut task = PlannedTask::new("t1", "Build module");
4330        task.dependency_expectations = DependencyExpectation {
4331            required_packages: vec!["serde".to_string(), "tokio".to_string()],
4332            setup_commands: vec!["cargo fetch".to_string()],
4333            min_toolchain_version: Some("1.75".to_string()),
4334        };
4335        let node = task.to_srbn_node(ModelTier::Actuator);
4336        assert_eq!(node.dependency_expectations.required_packages.len(), 2);
4337        assert_eq!(node.dependency_expectations.required_packages[0], "serde");
4338        assert_eq!(node.dependency_expectations.setup_commands, ["cargo fetch"]);
4339        assert_eq!(
4340            node.dependency_expectations
4341                .min_toolchain_version
4342                .as_deref(),
4343            Some("1.75")
4344        );
4345    }
4346
4347    #[test]
4348    fn test_dependency_expectations_deserialized_from_json() {
4349        let json = r#"{
4350            "id": "t1",
4351            "goal": "Build module",
4352            "dependency_expectations": {
4353                "required_packages": ["requests", "pydantic"],
4354                "setup_commands": [],
4355                "min_toolchain_version": "3.11"
4356            }
4357        }"#;
4358        let task: PlannedTask = serde_json::from_str(json).unwrap();
4359        assert_eq!(task.dependency_expectations.required_packages.len(), 2);
4360        assert_eq!(
4361            task.dependency_expectations
4362                .min_toolchain_version
4363                .as_deref(),
4364            Some("3.11")
4365        );
4366    }
4367
4368    #[test]
4369    fn test_dependency_expectations_default_when_omitted() {
4370        let json = r#"{"id": "t1", "goal": "Build module"}"#;
4371        let task: PlannedTask = serde_json::from_str(json).unwrap();
4372        assert!(task.dependency_expectations.required_packages.is_empty());
4373        assert!(task.dependency_expectations.setup_commands.is_empty());
4374        assert!(task.dependency_expectations.min_toolchain_version.is_none());
4375    }
4376
4377    #[test]
4378    fn test_node_state_from_display_str_case_insensitive() {
4379        assert_eq!(
4380            NodeState::from_display_str("Completed"),
4381            NodeState::Completed
4382        );
4383        assert_eq!(
4384            NodeState::from_display_str("COMPLETED"),
4385            NodeState::Completed
4386        );
4387        assert_eq!(
4388            NodeState::from_display_str("completed"),
4389            NodeState::Completed
4390        );
4391        assert_eq!(
4392            NodeState::from_display_str("TaskQueued"),
4393            NodeState::TaskQueued
4394        );
4395        assert_eq!(
4396            NodeState::from_display_str("TASKQUEUED"),
4397            NodeState::TaskQueued
4398        );
4399        assert_eq!(NodeState::from_display_str("coding"), NodeState::Coding);
4400        assert_eq!(NodeState::from_display_str("STABLE"), NodeState::Completed);
4401        assert_eq!(NodeState::from_display_str("RUNNING"), NodeState::Coding);
4402        // Unknown strings map to TaskQueued (default)
4403        assert_eq!(
4404            NodeState::from_display_str("garbage"),
4405            NodeState::TaskQueued
4406        );
4407    }
4408
4409    #[test]
4410    fn test_node_state_display_roundtrip() {
4411        let states = [
4412            NodeState::TaskQueued,
4413            NodeState::Planning,
4414            NodeState::Coding,
4415            NodeState::Verifying,
4416            NodeState::Retry,
4417            NodeState::SheafCheck,
4418            NodeState::Committing,
4419            NodeState::Escalated,
4420            NodeState::Completed,
4421            NodeState::Failed,
4422        ];
4423        for state in &states {
4424            let display = state.to_string();
4425            let parsed = NodeState::from_display_str(&display);
4426            assert_eq!(parsed, *state, "Roundtrip failed for {:?}", state);
4427        }
4428    }
4429
4430    #[test]
4431    fn test_node_state_is_success() {
4432        assert!(NodeState::Completed.is_success());
4433        assert!(!NodeState::Escalated.is_success());
4434        assert!(!NodeState::Failed.is_success());
4435        assert!(!NodeState::Coding.is_success());
4436    }
4437
4438    #[test]
4439    fn test_node_state_is_active() {
4440        assert!(NodeState::Coding.is_active());
4441        assert!(NodeState::Verifying.is_active());
4442        assert!(NodeState::Planning.is_active());
4443        assert!(NodeState::Retry.is_active());
4444        assert!(NodeState::SheafCheck.is_active());
4445        assert!(NodeState::Committing.is_active());
4446        assert!(!NodeState::Completed.is_active());
4447        assert!(!NodeState::Escalated.is_active());
4448        assert!(!NodeState::TaskQueued.is_active());
4449    }
4450
4451    #[test]
4452    fn test_session_outcome_equality() {
4453        assert_eq!(SessionOutcome::Success, SessionOutcome::Success);
4454        assert_ne!(SessionOutcome::Success, SessionOutcome::PartialSuccess);
4455        assert_ne!(SessionOutcome::Success, SessionOutcome::Failed);
4456        assert_ne!(SessionOutcome::PartialSuccess, SessionOutcome::Failed);
4457    }
4458
4459    // PSP-7 type tests
4460
4461    #[test]
4462    fn test_parse_result_state_is_ok() {
4463        assert!(ParseResultState::StrictJsonOk.is_ok());
4464        assert!(ParseResultState::TolerantRecoveryOk.is_ok());
4465        assert!(!ParseResultState::NoStructuredPayload.is_ok());
4466        assert!(!ParseResultState::SchemaInvalid.is_ok());
4467        assert!(!ParseResultState::SemanticallyRejected.is_ok());
4468        assert!(!ParseResultState::EmptyBundle.is_ok());
4469    }
4470
4471    #[test]
4472    fn test_parse_result_state_display() {
4473        assert_eq!(ParseResultState::StrictJsonOk.to_string(), "strict_json_ok");
4474        assert_eq!(
4475            ParseResultState::NoStructuredPayload.to_string(),
4476            "no_structured_payload"
4477        );
4478        assert_eq!(
4479            ParseResultState::SemanticallyRejected.to_string(),
4480            "semantically_rejected"
4481        );
4482    }
4483
4484    #[test]
4485    fn test_retry_classification_display() {
4486        assert_eq!(
4487            RetryClassification::MalformedRetry.to_string(),
4488            "malformed_retry"
4489        );
4490        assert_eq!(RetryClassification::Retarget.to_string(), "retarget");
4491        assert_eq!(RetryClassification::Replan.to_string(), "replan");
4492        assert_eq!(
4493            RetryClassification::BudgetExhausted.to_string(),
4494            "budget_exhausted"
4495        );
4496    }
4497
4498    #[test]
4499    fn test_prompt_intent_serde_roundtrip() {
4500        let intents = [
4501            PromptIntent::ArchitectExisting,
4502            PromptIntent::ActuatorMultiOutput,
4503            PromptIntent::CorrectionRetry,
4504            PromptIntent::SoloGenerate,
4505        ];
4506        for intent in &intents {
4507            let json = serde_json::to_string(intent).unwrap();
4508            let back: PromptIntent = serde_json::from_str(&json).unwrap();
4509            assert_eq!(*intent, back);
4510        }
4511    }
4512
4513    #[test]
4514    fn test_task_plan_cycle_detection() {
4515        let mut a = PlannedTask::new("a", "goal a");
4516        a.dependencies = vec!["b".to_string()];
4517        let mut b = PlannedTask::new("b", "goal b");
4518        b.dependencies = vec!["c".to_string()];
4519        let mut c = PlannedTask::new("c", "goal c");
4520        c.dependencies = vec!["a".to_string()];
4521        let plan = TaskPlan {
4522            tasks: vec![a, b, c],
4523        };
4524        let err = plan.validate().unwrap_err();
4525        assert!(err.contains("cycle"), "Expected cycle error, got: {err}");
4526    }
4527
4528    #[test]
4529    fn test_task_plan_implicit_dependency_enforcement() {
4530        // Task B produces "src/lib.rs", Task A reads it but doesn't depend on B
4531        let mut a = PlannedTask::new("a", "use lib");
4532        a.context_files = vec!["src/lib.rs".to_string()];
4533        a.output_files = vec!["src/main.rs".to_string()];
4534        let mut b = PlannedTask::new("b", "create lib");
4535        b.output_files = vec!["src/lib.rs".to_string()];
4536
4537        let mut plan = TaskPlan { tasks: vec![a, b] };
4538        let err = plan.validate().unwrap_err();
4539        assert!(
4540            err.contains("does not declare it as a dependency"),
4541            "Expected implicit dep error, got: {err}"
4542        );
4543        // Fix: add the dependency
4544        plan.tasks[0].dependencies.push("b".to_string());
4545        assert!(plan.validate().is_ok());
4546    }
4547
4548    #[test]
4549    fn test_task_plan_valid_acyclic() {
4550        let a = PlannedTask::new("a", "goal a");
4551        let mut b = PlannedTask::new("b", "goal b");
4552        b.dependencies = vec!["a".to_string()];
4553        let mut c = PlannedTask::new("c", "goal c");
4554        c.dependencies = vec!["a".to_string(), "b".to_string()];
4555        let plan = TaskPlan {
4556            tasks: vec![a, b, c],
4557        };
4558        assert!(plan.validate().is_ok());
4559    }
4560
4561    #[test]
4562    fn test_task_plan_test_file_dependency_inference() {
4563        // Source task produces src/lib.rs, test task produces tests/lib_test.rs
4564        // Test task should be required to depend on source task.
4565        let mut src = PlannedTask::new("src", "implement lib");
4566        src.output_files = vec!["src/lib.rs".to_string()];
4567        let mut tst = PlannedTask::new("tst", "test lib");
4568        tst.output_files = vec!["tests/lib_test.rs".to_string()];
4569
4570        let plan = TaskPlan {
4571            tasks: vec![src, tst],
4572        };
4573        let err = plan.validate().unwrap_err();
4574        assert!(
4575            err.contains("Test task 'tst'") && err.contains("does not depend on source task 'src'"),
4576            "Expected test-dep inference error, got: {err}"
4577        );
4578    }
4579
4580    #[test]
4581    fn test_task_plan_test_file_dependency_satisfied() {
4582        let mut src = PlannedTask::new("src", "implement lib");
4583        src.output_files = vec!["src/lib.rs".to_string()];
4584        let mut tst = PlannedTask::new("tst", "test lib");
4585        tst.output_files = vec!["tests/lib_test.rs".to_string()];
4586        tst.dependencies = vec!["src".to_string()];
4587
4588        let plan = TaskPlan {
4589            tasks: vec![src, tst],
4590        };
4591        assert!(plan.validate().is_ok());
4592    }
4593
4594    #[test]
4595    fn test_glob_matches_simple() {
4596        assert!(super::glob_matches_simple("tests/*.rs", "tests/foo.rs"));
4597        assert!(!super::glob_matches_simple("tests/*.rs", "src/foo.rs"));
4598        assert!(super::glob_matches_simple(
4599            "**/*.test.js",
4600            "src/utils.test.js"
4601        ));
4602        assert!(super::glob_matches_simple("test_*.py", "test_auth.py"));
4603        assert!(!super::glob_matches_simple("test_*.py", "auth.py"));
4604        assert!(super::glob_matches_simple(
4605            "tests/**/*.rs",
4606            "tests/unit/foo.rs"
4607        ));
4608    }
4609}