Skip to main content

oris_evokernel/
core.rs

1//! EvoKernel orchestration: mutation capture, validation, capsule construction, and replay-first reuse.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::fs;
5use std::path::Path;
6use std::process::Command;
7use std::sync::{Arc, Mutex};
8
9use async_trait::async_trait;
10use chrono::{DateTime, Duration, Utc};
11use oris_agent_contract::{ExecutionFeedback, MutationProposal as AgentMutationProposal};
12use oris_economics::{EconomicsSignal, EvuLedger, StakePolicy};
13use oris_evolution::{
14    compute_artifact_hash, next_id, stable_hash_json, AssetState, BlastRadius, CandidateSource,
15    Capsule, CapsuleId, EnvFingerprint, EvolutionError, EvolutionEvent, EvolutionStore, Gene,
16    GeneCandidate, MutationId, PreparedMutation, Selector, SelectorInput, StoreBackedSelector,
17    StoredEvolutionEvent, ValidationSnapshot,
18};
19use oris_evolution_network::{EvolutionEnvelope, NetworkAsset};
20use oris_governor::{DefaultGovernor, Governor, GovernorDecision, GovernorInput};
21use oris_kernel::{Kernel, KernelState, RunId};
22use oris_sandbox::{
23    compute_blast_radius, execute_allowed_command, Sandbox, SandboxPolicy, SandboxReceipt,
24};
25use oris_spec::CompiledMutationPlan;
26use serde::{Deserialize, Serialize};
27use thiserror::Error;
28
29pub use oris_evolution::{
30    default_store_root, ArtifactEncoding, AssetState as EvoAssetState,
31    BlastRadius as EvoBlastRadius, CandidateSource as EvoCandidateSource,
32    EnvFingerprint as EvoEnvFingerprint, EvolutionStore as EvoEvolutionStore, JsonlEvolutionStore,
33    MutationArtifact, MutationIntent, MutationTarget, Outcome, RiskLevel,
34    SelectorInput as EvoSelectorInput,
35};
36pub use oris_evolution_network::{
37    FetchQuery, FetchResponse, MessageType, PublishRequest, RevokeNotice,
38};
39pub use oris_governor::{CoolingWindow, GovernorConfig, RevocationReason};
40pub use oris_sandbox::{LocalProcessSandbox, SandboxPolicy as EvoSandboxPolicy};
41pub use oris_spec::{SpecCompileError, SpecCompiler, SpecDocument};
42
43#[derive(Clone, Debug, Serialize, Deserialize)]
44pub struct ValidationPlan {
45    pub profile: String,
46    pub stages: Vec<ValidationStage>,
47}
48
49impl ValidationPlan {
50    pub fn oris_default() -> Self {
51        Self {
52            profile: "oris-default".into(),
53            stages: vec![
54                ValidationStage::Command {
55                    program: "cargo".into(),
56                    args: vec!["fmt".into(), "--all".into(), "--check".into()],
57                    timeout_ms: 60_000,
58                },
59                ValidationStage::Command {
60                    program: "cargo".into(),
61                    args: vec!["check".into(), "--workspace".into()],
62                    timeout_ms: 180_000,
63                },
64                ValidationStage::Command {
65                    program: "cargo".into(),
66                    args: vec![
67                        "test".into(),
68                        "-p".into(),
69                        "oris-kernel".into(),
70                        "-p".into(),
71                        "oris-evolution".into(),
72                        "-p".into(),
73                        "oris-sandbox".into(),
74                        "-p".into(),
75                        "oris-evokernel".into(),
76                        "--lib".into(),
77                    ],
78                    timeout_ms: 300_000,
79                },
80                ValidationStage::Command {
81                    program: "cargo".into(),
82                    args: vec![
83                        "test".into(),
84                        "-p".into(),
85                        "oris-runtime".into(),
86                        "--lib".into(),
87                    ],
88                    timeout_ms: 300_000,
89                },
90            ],
91        }
92    }
93}
94
95#[derive(Clone, Debug, Serialize, Deserialize)]
96pub enum ValidationStage {
97    Command {
98        program: String,
99        args: Vec<String>,
100        timeout_ms: u64,
101    },
102}
103
104#[derive(Clone, Debug, Serialize, Deserialize)]
105pub struct ValidationStageReport {
106    pub stage: String,
107    pub success: bool,
108    pub exit_code: Option<i32>,
109    pub duration_ms: u64,
110    pub stdout: String,
111    pub stderr: String,
112}
113
114#[derive(Clone, Debug, Serialize, Deserialize)]
115pub struct ValidationReport {
116    pub success: bool,
117    pub duration_ms: u64,
118    pub stages: Vec<ValidationStageReport>,
119    pub logs: String,
120}
121
122impl ValidationReport {
123    pub fn to_snapshot(&self, profile: &str) -> ValidationSnapshot {
124        ValidationSnapshot {
125            success: self.success,
126            profile: profile.to_string(),
127            duration_ms: self.duration_ms,
128            summary: if self.success {
129                "validation passed".into()
130            } else {
131                "validation failed".into()
132            },
133        }
134    }
135}
136
137#[derive(Debug, Error)]
138pub enum ValidationError {
139    #[error("validation execution failed: {0}")]
140    Execution(String),
141}
142
143#[async_trait]
144pub trait Validator: Send + Sync {
145    async fn run(
146        &self,
147        receipt: &SandboxReceipt,
148        plan: &ValidationPlan,
149    ) -> Result<ValidationReport, ValidationError>;
150}
151
152pub struct CommandValidator {
153    policy: SandboxPolicy,
154}
155
156impl CommandValidator {
157    pub fn new(policy: SandboxPolicy) -> Self {
158        Self { policy }
159    }
160}
161
162#[async_trait]
163impl Validator for CommandValidator {
164    async fn run(
165        &self,
166        receipt: &SandboxReceipt,
167        plan: &ValidationPlan,
168    ) -> Result<ValidationReport, ValidationError> {
169        let started = std::time::Instant::now();
170        let mut stages = Vec::new();
171        let mut success = true;
172        let mut logs = String::new();
173
174        for stage in &plan.stages {
175            match stage {
176                ValidationStage::Command {
177                    program,
178                    args,
179                    timeout_ms,
180                } => {
181                    let result = execute_allowed_command(
182                        &self.policy,
183                        &receipt.workdir,
184                        program,
185                        args,
186                        *timeout_ms,
187                    )
188                    .await;
189                    let report = match result {
190                        Ok(output) => ValidationStageReport {
191                            stage: format!("{program} {}", args.join(" ")),
192                            success: output.success,
193                            exit_code: output.exit_code,
194                            duration_ms: output.duration_ms,
195                            stdout: output.stdout,
196                            stderr: output.stderr,
197                        },
198                        Err(err) => ValidationStageReport {
199                            stage: format!("{program} {}", args.join(" ")),
200                            success: false,
201                            exit_code: None,
202                            duration_ms: 0,
203                            stdout: String::new(),
204                            stderr: err.to_string(),
205                        },
206                    };
207                    if !report.success {
208                        success = false;
209                    }
210                    if !report.stdout.is_empty() {
211                        logs.push_str(&report.stdout);
212                        logs.push('\n');
213                    }
214                    if !report.stderr.is_empty() {
215                        logs.push_str(&report.stderr);
216                        logs.push('\n');
217                    }
218                    stages.push(report);
219                    if !success {
220                        break;
221                    }
222                }
223            }
224        }
225
226        Ok(ValidationReport {
227            success,
228            duration_ms: started.elapsed().as_millis() as u64,
229            stages,
230            logs,
231        })
232    }
233}
234
235#[derive(Clone, Debug)]
236pub struct ReplayDecision {
237    pub used_capsule: bool,
238    pub capsule_id: Option<CapsuleId>,
239    pub fallback_to_planner: bool,
240    pub reason: String,
241}
242
243#[derive(Debug, Error)]
244pub enum ReplayError {
245    #[error("store error: {0}")]
246    Store(String),
247    #[error("sandbox error: {0}")]
248    Sandbox(String),
249    #[error("validation error: {0}")]
250    Validation(String),
251}
252
253#[async_trait]
254pub trait ReplayExecutor: Send + Sync {
255    async fn try_replay(
256        &self,
257        input: &SelectorInput,
258        policy: &SandboxPolicy,
259        validation: &ValidationPlan,
260    ) -> Result<ReplayDecision, ReplayError>;
261}
262
263pub struct StoreReplayExecutor {
264    pub sandbox: Arc<dyn Sandbox>,
265    pub validator: Arc<dyn Validator>,
266    pub store: Arc<dyn EvolutionStore>,
267    pub selector: Arc<dyn Selector>,
268    pub governor: Arc<dyn Governor>,
269    pub economics: Option<Arc<Mutex<EvuLedger>>>,
270    pub remote_publishers: Option<Arc<Mutex<BTreeMap<String, String>>>>,
271    pub stake_policy: StakePolicy,
272}
273
274#[async_trait]
275impl ReplayExecutor for StoreReplayExecutor {
276    async fn try_replay(
277        &self,
278        input: &SelectorInput,
279        policy: &SandboxPolicy,
280        validation: &ValidationPlan,
281    ) -> Result<ReplayDecision, ReplayError> {
282        let mut selector_input = input.clone();
283        if self.economics.is_some() && self.remote_publishers.is_some() {
284            selector_input.limit = selector_input.limit.max(4);
285        }
286        let mut candidates = self.selector.select(&selector_input);
287        self.rerank_with_reputation_bias(&mut candidates);
288        let mut exact_match = false;
289        if candidates.is_empty() {
290            let mut exact_candidates = exact_match_candidates(self.store.as_ref(), input);
291            self.rerank_with_reputation_bias(&mut exact_candidates);
292            if !exact_candidates.is_empty() {
293                candidates = exact_candidates;
294                exact_match = true;
295            }
296        }
297        if candidates.is_empty() {
298            let mut remote_candidates =
299                quarantined_remote_exact_match_candidates(self.store.as_ref(), input);
300            self.rerank_with_reputation_bias(&mut remote_candidates);
301            if !remote_candidates.is_empty() {
302                candidates = remote_candidates;
303                exact_match = true;
304            }
305        }
306        candidates.truncate(input.limit.max(1));
307        let Some(best) = candidates.into_iter().next() else {
308            return Ok(ReplayDecision {
309                used_capsule: false,
310                capsule_id: None,
311                fallback_to_planner: true,
312                reason: "no matching gene".into(),
313            });
314        };
315        let remote_publisher = self.publisher_for_gene(&best.gene.id);
316
317        if !exact_match && best.score < 0.82 {
318            return Ok(ReplayDecision {
319                used_capsule: false,
320                capsule_id: None,
321                fallback_to_planner: true,
322                reason: format!("best gene score {:.3} below replay threshold", best.score),
323            });
324        }
325
326        let Some(capsule) = best.capsules.first().cloned() else {
327            return Ok(ReplayDecision {
328                used_capsule: false,
329                capsule_id: None,
330                fallback_to_planner: true,
331                reason: "candidate gene has no capsule".into(),
332            });
333        };
334
335        let Some(mutation) = find_declared_mutation(self.store.as_ref(), &capsule.mutation_id)
336            .map_err(|err| ReplayError::Store(err.to_string()))?
337        else {
338            return Ok(ReplayDecision {
339                used_capsule: false,
340                capsule_id: None,
341                fallback_to_planner: true,
342                reason: "mutation payload missing from store".into(),
343            });
344        };
345
346        let receipt = match self.sandbox.apply(&mutation, policy).await {
347            Ok(receipt) => receipt,
348            Err(err) => {
349                self.record_reuse_settlement(remote_publisher.as_deref(), false);
350                return Ok(ReplayDecision {
351                    used_capsule: false,
352                    capsule_id: Some(capsule.id.clone()),
353                    fallback_to_planner: true,
354                    reason: format!("replay patch apply failed: {err}"),
355                });
356            }
357        };
358
359        let report = self
360            .validator
361            .run(&receipt, validation)
362            .await
363            .map_err(|err| ReplayError::Validation(err.to_string()))?;
364        if !report.success {
365            self.record_replay_validation_failure(&best, &capsule, validation, &report)?;
366            self.record_reuse_settlement(remote_publisher.as_deref(), false);
367            return Ok(ReplayDecision {
368                used_capsule: false,
369                capsule_id: Some(capsule.id.clone()),
370                fallback_to_planner: true,
371                reason: "replay validation failed".into(),
372            });
373        }
374
375        if matches!(capsule.state, AssetState::Quarantined) {
376            self.store
377                .append_event(EvolutionEvent::ValidationPassed {
378                    mutation_id: capsule.mutation_id.clone(),
379                    report: report.to_snapshot(&validation.profile),
380                    gene_id: Some(best.gene.id.clone()),
381                })
382                .map_err(|err| ReplayError::Store(err.to_string()))?;
383            self.store
384                .append_event(EvolutionEvent::CapsuleReleased {
385                    capsule_id: capsule.id.clone(),
386                    state: AssetState::Promoted,
387                })
388                .map_err(|err| ReplayError::Store(err.to_string()))?;
389        }
390
391        self.store
392            .append_event(EvolutionEvent::CapsuleReused {
393                capsule_id: capsule.id.clone(),
394                gene_id: capsule.gene_id.clone(),
395                run_id: capsule.run_id.clone(),
396            })
397            .map_err(|err| ReplayError::Store(err.to_string()))?;
398        self.record_reuse_settlement(remote_publisher.as_deref(), true);
399
400        Ok(ReplayDecision {
401            used_capsule: true,
402            capsule_id: Some(capsule.id),
403            fallback_to_planner: false,
404            reason: if exact_match {
405                "replayed via exact-match cold-start lookup".into()
406            } else {
407                "replayed via selector".into()
408            },
409        })
410    }
411}
412
413impl StoreReplayExecutor {
414    fn rerank_with_reputation_bias(&self, candidates: &mut [GeneCandidate]) {
415        let Some(ledger) = self.economics.as_ref() else {
416            return;
417        };
418        let Some(remote_publishers) = self.remote_publishers.as_ref() else {
419            return;
420        };
421        let reputation_bias = ledger
422            .lock()
423            .ok()
424            .map(|locked| locked.selector_reputation_bias())
425            .unwrap_or_default();
426        if reputation_bias.is_empty() {
427            return;
428        }
429        let publisher_map = remote_publishers
430            .lock()
431            .ok()
432            .map(|locked| locked.clone())
433            .unwrap_or_default();
434        candidates.sort_by(|left, right| {
435            effective_candidate_score(right, &publisher_map, &reputation_bias)
436                .partial_cmp(&effective_candidate_score(
437                    left,
438                    &publisher_map,
439                    &reputation_bias,
440                ))
441                .unwrap_or(std::cmp::Ordering::Equal)
442                .then_with(|| left.gene.id.cmp(&right.gene.id))
443        });
444    }
445
446    fn publisher_for_gene(&self, gene_id: &str) -> Option<String> {
447        self.remote_publishers
448            .as_ref()?
449            .lock()
450            .ok()?
451            .get(gene_id)
452            .cloned()
453    }
454
455    fn record_reuse_settlement(&self, publisher_id: Option<&str>, success: bool) {
456        let Some(publisher_id) = publisher_id else {
457            return;
458        };
459        let Some(ledger) = self.economics.as_ref() else {
460            return;
461        };
462        if let Ok(mut locked) = ledger.lock() {
463            locked.settle_remote_reuse(publisher_id, success, &self.stake_policy);
464        }
465    }
466
467    fn record_replay_validation_failure(
468        &self,
469        best: &GeneCandidate,
470        capsule: &Capsule,
471        validation: &ValidationPlan,
472        report: &ValidationReport,
473    ) -> Result<(), ReplayError> {
474        self.store
475            .append_event(EvolutionEvent::ValidationFailed {
476                mutation_id: capsule.mutation_id.clone(),
477                report: report.to_snapshot(&validation.profile),
478                gene_id: Some(best.gene.id.clone()),
479            })
480            .map_err(|err| ReplayError::Store(err.to_string()))?;
481
482        let replay_failures = self.replay_failure_count(&best.gene.id)?;
483        let governor_decision = self.governor.evaluate(GovernorInput {
484            candidate_source: if self.publisher_for_gene(&best.gene.id).is_some() {
485                CandidateSource::Remote
486            } else {
487                CandidateSource::Local
488            },
489            success_count: 0,
490            blast_radius: BlastRadius {
491                files_changed: capsule.outcome.changed_files.len(),
492                lines_changed: capsule.outcome.lines_changed,
493            },
494            replay_failures,
495        });
496
497        if matches!(governor_decision.target_state, AssetState::Revoked) {
498            self.store
499                .append_event(EvolutionEvent::PromotionEvaluated {
500                    gene_id: best.gene.id.clone(),
501                    state: AssetState::Revoked,
502                    reason: governor_decision.reason.clone(),
503                })
504                .map_err(|err| ReplayError::Store(err.to_string()))?;
505            self.store
506                .append_event(EvolutionEvent::GeneRevoked {
507                    gene_id: best.gene.id.clone(),
508                    reason: governor_decision.reason,
509                })
510                .map_err(|err| ReplayError::Store(err.to_string()))?;
511            for related in &best.capsules {
512                self.store
513                    .append_event(EvolutionEvent::CapsuleQuarantined {
514                        capsule_id: related.id.clone(),
515                    })
516                    .map_err(|err| ReplayError::Store(err.to_string()))?;
517            }
518        }
519
520        Ok(())
521    }
522
523    fn replay_failure_count(&self, gene_id: &str) -> Result<u64, ReplayError> {
524        Ok(self
525            .store
526            .scan(1)
527            .map_err(|err| ReplayError::Store(err.to_string()))?
528            .into_iter()
529            .filter(|stored| {
530                matches!(
531                    &stored.event,
532                    EvolutionEvent::ValidationFailed {
533                        gene_id: Some(current_gene_id),
534                        ..
535                    } if current_gene_id == gene_id
536                )
537            })
538            .count() as u64)
539    }
540}
541
542#[derive(Debug, Error)]
543pub enum EvoKernelError {
544    #[error("sandbox error: {0}")]
545    Sandbox(String),
546    #[error("validation error: {0}")]
547    Validation(String),
548    #[error("validation failed")]
549    ValidationFailed(ValidationReport),
550    #[error("store error: {0}")]
551    Store(String),
552}
553
554#[derive(Clone, Debug)]
555pub struct CaptureOutcome {
556    pub capsule: Capsule,
557    pub gene: Gene,
558    pub governor_decision: GovernorDecision,
559}
560
561#[derive(Clone, Debug, Serialize, Deserialize)]
562pub struct ImportOutcome {
563    pub imported_asset_ids: Vec<String>,
564    pub accepted: bool,
565}
566
567#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
568pub struct EvolutionMetricsSnapshot {
569    pub replay_attempts_total: u64,
570    pub replay_success_total: u64,
571    pub replay_success_rate: f64,
572    pub mutation_declared_total: u64,
573    pub promoted_mutations_total: u64,
574    pub promotion_ratio: f64,
575    pub gene_revocations_total: u64,
576    pub mutation_velocity_last_hour: u64,
577    pub revoke_frequency_last_hour: u64,
578    pub promoted_genes: u64,
579    pub promoted_capsules: u64,
580    pub last_event_seq: u64,
581}
582
583#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
584pub struct EvolutionHealthSnapshot {
585    pub status: String,
586    pub last_event_seq: u64,
587    pub promoted_genes: u64,
588    pub promoted_capsules: u64,
589}
590
591#[derive(Clone)]
592pub struct EvolutionNetworkNode {
593    pub store: Arc<dyn EvolutionStore>,
594}
595
596impl EvolutionNetworkNode {
597    pub fn new(store: Arc<dyn EvolutionStore>) -> Self {
598        Self { store }
599    }
600
601    pub fn with_default_store() -> Self {
602        Self {
603            store: Arc::new(JsonlEvolutionStore::new(default_store_root())),
604        }
605    }
606
607    pub fn accept_publish_request(
608        &self,
609        request: &PublishRequest,
610    ) -> Result<ImportOutcome, EvoKernelError> {
611        import_remote_envelope_into_store(
612            self.store.as_ref(),
613            &EvolutionEnvelope::publish(request.sender_id.clone(), request.assets.clone()),
614        )
615    }
616
617    pub fn publish_local_assets(
618        &self,
619        sender_id: impl Into<String>,
620    ) -> Result<EvolutionEnvelope, EvoKernelError> {
621        export_promoted_assets_from_store(self.store.as_ref(), sender_id)
622    }
623
624    pub fn fetch_assets(
625        &self,
626        responder_id: impl Into<String>,
627        query: &FetchQuery,
628    ) -> Result<FetchResponse, EvoKernelError> {
629        fetch_assets_from_store(self.store.as_ref(), responder_id, query)
630    }
631
632    pub fn revoke_assets(&self, notice: &RevokeNotice) -> Result<RevokeNotice, EvoKernelError> {
633        revoke_assets_in_store(self.store.as_ref(), notice)
634    }
635
636    pub fn metrics_snapshot(&self) -> Result<EvolutionMetricsSnapshot, EvoKernelError> {
637        evolution_metrics_snapshot(self.store.as_ref())
638    }
639
640    pub fn render_metrics_prometheus(&self) -> Result<String, EvoKernelError> {
641        self.metrics_snapshot().map(|snapshot| {
642            let health = evolution_health_snapshot(&snapshot);
643            render_evolution_metrics_prometheus(&snapshot, &health)
644        })
645    }
646
647    pub fn health_snapshot(&self) -> Result<EvolutionHealthSnapshot, EvoKernelError> {
648        self.metrics_snapshot()
649            .map(|snapshot| evolution_health_snapshot(&snapshot))
650    }
651}
652
653pub struct EvoKernel<S: KernelState> {
654    pub kernel: Arc<Kernel<S>>,
655    pub sandbox: Arc<dyn Sandbox>,
656    pub validator: Arc<dyn Validator>,
657    pub store: Arc<dyn EvolutionStore>,
658    pub selector: Arc<dyn Selector>,
659    pub governor: Arc<dyn Governor>,
660    pub economics: Arc<Mutex<EvuLedger>>,
661    pub remote_publishers: Arc<Mutex<BTreeMap<String, String>>>,
662    pub stake_policy: StakePolicy,
663    pub sandbox_policy: SandboxPolicy,
664    pub validation_plan: ValidationPlan,
665}
666
667impl<S: KernelState> EvoKernel<S> {
668    pub fn new(
669        kernel: Arc<Kernel<S>>,
670        sandbox: Arc<dyn Sandbox>,
671        validator: Arc<dyn Validator>,
672        store: Arc<dyn EvolutionStore>,
673    ) -> Self {
674        let selector: Arc<dyn Selector> = Arc::new(StoreBackedSelector::new(store.clone()));
675        Self {
676            kernel,
677            sandbox,
678            validator,
679            store,
680            selector,
681            governor: Arc::new(DefaultGovernor::default()),
682            economics: Arc::new(Mutex::new(EvuLedger::default())),
683            remote_publishers: Arc::new(Mutex::new(BTreeMap::new())),
684            stake_policy: StakePolicy::default(),
685            sandbox_policy: SandboxPolicy::oris_default(),
686            validation_plan: ValidationPlan::oris_default(),
687        }
688    }
689
690    pub fn with_selector(mut self, selector: Arc<dyn Selector>) -> Self {
691        self.selector = selector;
692        self
693    }
694
695    pub fn with_sandbox_policy(mut self, policy: SandboxPolicy) -> Self {
696        self.sandbox_policy = policy;
697        self
698    }
699
700    pub fn with_governor(mut self, governor: Arc<dyn Governor>) -> Self {
701        self.governor = governor;
702        self
703    }
704
705    pub fn with_economics(mut self, economics: Arc<Mutex<EvuLedger>>) -> Self {
706        self.economics = economics;
707        self
708    }
709
710    pub fn with_stake_policy(mut self, policy: StakePolicy) -> Self {
711        self.stake_policy = policy;
712        self
713    }
714
715    pub fn with_validation_plan(mut self, plan: ValidationPlan) -> Self {
716        self.validation_plan = plan;
717        self
718    }
719
720    pub async fn capture_successful_mutation(
721        &self,
722        run_id: &RunId,
723        mutation: PreparedMutation,
724    ) -> Result<Capsule, EvoKernelError> {
725        Ok(self
726            .capture_mutation_with_governor(run_id, mutation)
727            .await?
728            .capsule)
729    }
730
731    pub async fn capture_mutation_with_governor(
732        &self,
733        run_id: &RunId,
734        mutation: PreparedMutation,
735    ) -> Result<CaptureOutcome, EvoKernelError> {
736        self.store
737            .append_event(EvolutionEvent::MutationDeclared {
738                mutation: mutation.clone(),
739            })
740            .map_err(store_err)?;
741
742        let receipt = match self.sandbox.apply(&mutation, &self.sandbox_policy).await {
743            Ok(receipt) => receipt,
744            Err(err) => {
745                self.store
746                    .append_event(EvolutionEvent::MutationRejected {
747                        mutation_id: mutation.intent.id.clone(),
748                        reason: err.to_string(),
749                    })
750                    .map_err(store_err)?;
751                return Err(EvoKernelError::Sandbox(err.to_string()));
752            }
753        };
754
755        self.store
756            .append_event(EvolutionEvent::MutationApplied {
757                mutation_id: mutation.intent.id.clone(),
758                patch_hash: receipt.patch_hash.clone(),
759                changed_files: receipt
760                    .changed_files
761                    .iter()
762                    .map(|path| path.to_string_lossy().to_string())
763                    .collect(),
764            })
765            .map_err(store_err)?;
766
767        let report = self
768            .validator
769            .run(&receipt, &self.validation_plan)
770            .await
771            .map_err(|err| EvoKernelError::Validation(err.to_string()))?;
772        if !report.success {
773            self.store
774                .append_event(EvolutionEvent::ValidationFailed {
775                    mutation_id: mutation.intent.id.clone(),
776                    report: report.to_snapshot(&self.validation_plan.profile),
777                    gene_id: None,
778                })
779                .map_err(store_err)?;
780            return Err(EvoKernelError::ValidationFailed(report));
781        }
782
783        let projection = self.store.rebuild_projection().map_err(store_err)?;
784        let blast_radius = compute_blast_radius(&mutation.artifact.payload);
785        let success_count = projection
786            .genes
787            .iter()
788            .find(|gene| {
789                gene.id == derive_gene(&mutation, &receipt, &self.validation_plan.profile).id
790            })
791            .map(|existing| {
792                projection
793                    .capsules
794                    .iter()
795                    .filter(|capsule| capsule.gene_id == existing.id)
796                    .count() as u64
797            })
798            .unwrap_or(0)
799            + 1;
800        let governor_decision = self.governor.evaluate(GovernorInput {
801            candidate_source: CandidateSource::Local,
802            success_count,
803            blast_radius: blast_radius.clone(),
804            replay_failures: 0,
805        });
806
807        let mut gene = derive_gene(&mutation, &receipt, &self.validation_plan.profile);
808        gene.state = governor_decision.target_state.clone();
809        self.store
810            .append_event(EvolutionEvent::ValidationPassed {
811                mutation_id: mutation.intent.id.clone(),
812                report: report.to_snapshot(&self.validation_plan.profile),
813                gene_id: Some(gene.id.clone()),
814            })
815            .map_err(store_err)?;
816        self.store
817            .append_event(EvolutionEvent::GeneProjected { gene: gene.clone() })
818            .map_err(store_err)?;
819        self.store
820            .append_event(EvolutionEvent::PromotionEvaluated {
821                gene_id: gene.id.clone(),
822                state: governor_decision.target_state.clone(),
823                reason: governor_decision.reason.clone(),
824            })
825            .map_err(store_err)?;
826        if matches!(governor_decision.target_state, AssetState::Promoted) {
827            self.store
828                .append_event(EvolutionEvent::GenePromoted {
829                    gene_id: gene.id.clone(),
830                })
831                .map_err(store_err)?;
832        }
833        if let Some(spec_id) = &mutation.intent.spec_id {
834            self.store
835                .append_event(EvolutionEvent::SpecLinked {
836                    mutation_id: mutation.intent.id.clone(),
837                    spec_id: spec_id.clone(),
838                })
839                .map_err(store_err)?;
840        }
841
842        let mut capsule = build_capsule(
843            run_id,
844            &mutation,
845            &receipt,
846            &report,
847            &self.validation_plan.profile,
848            &gene,
849            &blast_radius,
850        )
851        .map_err(|err| EvoKernelError::Validation(err.to_string()))?;
852        capsule.state = governor_decision.target_state.clone();
853        self.store
854            .append_event(EvolutionEvent::CapsuleCommitted {
855                capsule: capsule.clone(),
856            })
857            .map_err(store_err)?;
858        if matches!(governor_decision.target_state, AssetState::Quarantined) {
859            self.store
860                .append_event(EvolutionEvent::CapsuleQuarantined {
861                    capsule_id: capsule.id.clone(),
862                })
863                .map_err(store_err)?;
864        }
865
866        Ok(CaptureOutcome {
867            capsule,
868            gene,
869            governor_decision,
870        })
871    }
872
873    pub async fn capture_from_proposal(
874        &self,
875        run_id: &RunId,
876        proposal: &AgentMutationProposal,
877        diff_payload: String,
878        base_revision: Option<String>,
879    ) -> Result<CaptureOutcome, EvoKernelError> {
880        let intent = MutationIntent {
881            id: next_id("proposal"),
882            intent: proposal.intent.clone(),
883            target: MutationTarget::Paths {
884                allow: proposal.files.clone(),
885            },
886            expected_effect: proposal.expected_effect.clone(),
887            risk: RiskLevel::Low,
888            signals: proposal.files.clone(),
889            spec_id: None,
890        };
891        self.capture_mutation_with_governor(
892            run_id,
893            prepare_mutation(intent, diff_payload, base_revision),
894        )
895        .await
896    }
897
898    pub fn feedback_for_agent(outcome: &CaptureOutcome) -> ExecutionFeedback {
899        ExecutionFeedback {
900            accepted: !matches!(outcome.governor_decision.target_state, AssetState::Revoked),
901            asset_state: Some(format!("{:?}", outcome.governor_decision.target_state)),
902            summary: outcome.governor_decision.reason.clone(),
903        }
904    }
905
906    pub fn export_promoted_assets(
907        &self,
908        sender_id: impl Into<String>,
909    ) -> Result<EvolutionEnvelope, EvoKernelError> {
910        let sender_id = sender_id.into();
911        let envelope = export_promoted_assets_from_store(self.store.as_ref(), sender_id.clone())?;
912        if !envelope.assets.is_empty() {
913            let mut ledger = self
914                .economics
915                .lock()
916                .map_err(|_| EvoKernelError::Validation("economics ledger lock poisoned".into()))?;
917            if ledger
918                .reserve_publish_stake(&sender_id, &self.stake_policy)
919                .is_none()
920            {
921                return Err(EvoKernelError::Validation(
922                    "insufficient EVU for remote publish".into(),
923                ));
924            }
925        }
926        Ok(envelope)
927    }
928
929    pub fn import_remote_envelope(
930        &self,
931        envelope: &EvolutionEnvelope,
932    ) -> Result<ImportOutcome, EvoKernelError> {
933        let outcome = import_remote_envelope_into_store(self.store.as_ref(), envelope)?;
934        self.record_remote_publishers(envelope);
935        Ok(outcome)
936    }
937
938    pub fn fetch_assets(
939        &self,
940        responder_id: impl Into<String>,
941        query: &FetchQuery,
942    ) -> Result<FetchResponse, EvoKernelError> {
943        fetch_assets_from_store(self.store.as_ref(), responder_id, query)
944    }
945
946    pub fn revoke_assets(&self, notice: &RevokeNotice) -> Result<RevokeNotice, EvoKernelError> {
947        revoke_assets_in_store(self.store.as_ref(), notice)
948    }
949
950    pub async fn replay_or_fallback(
951        &self,
952        input: SelectorInput,
953    ) -> Result<ReplayDecision, EvoKernelError> {
954        let executor = StoreReplayExecutor {
955            sandbox: self.sandbox.clone(),
956            validator: self.validator.clone(),
957            store: self.store.clone(),
958            selector: self.selector.clone(),
959            governor: self.governor.clone(),
960            economics: Some(self.economics.clone()),
961            remote_publishers: Some(self.remote_publishers.clone()),
962            stake_policy: self.stake_policy.clone(),
963        };
964        executor
965            .try_replay(&input, &self.sandbox_policy, &self.validation_plan)
966            .await
967            .map_err(|err| EvoKernelError::Validation(err.to_string()))
968    }
969
970    pub fn economics_signal(&self, node_id: &str) -> Option<EconomicsSignal> {
971        self.economics.lock().ok()?.governor_signal(node_id)
972    }
973
974    pub fn selector_reputation_bias(&self) -> BTreeMap<String, f32> {
975        self.economics
976            .lock()
977            .ok()
978            .map(|locked| locked.selector_reputation_bias())
979            .unwrap_or_default()
980    }
981
982    pub fn metrics_snapshot(&self) -> Result<EvolutionMetricsSnapshot, EvoKernelError> {
983        evolution_metrics_snapshot(self.store.as_ref())
984    }
985
986    pub fn render_metrics_prometheus(&self) -> Result<String, EvoKernelError> {
987        self.metrics_snapshot().map(|snapshot| {
988            let health = evolution_health_snapshot(&snapshot);
989            render_evolution_metrics_prometheus(&snapshot, &health)
990        })
991    }
992
993    pub fn health_snapshot(&self) -> Result<EvolutionHealthSnapshot, EvoKernelError> {
994        self.metrics_snapshot()
995            .map(|snapshot| evolution_health_snapshot(&snapshot))
996    }
997
998    fn record_remote_publishers(&self, envelope: &EvolutionEnvelope) {
999        let sender_id = envelope.sender_id.trim();
1000        if sender_id.is_empty() {
1001            return;
1002        }
1003        let Ok(mut publishers) = self.remote_publishers.lock() else {
1004            return;
1005        };
1006        for asset in &envelope.assets {
1007            match asset {
1008                NetworkAsset::Gene { gene } => {
1009                    publishers.insert(gene.id.clone(), sender_id.to_string());
1010                }
1011                NetworkAsset::Capsule { capsule } => {
1012                    publishers.insert(capsule.gene_id.clone(), sender_id.to_string());
1013                }
1014                NetworkAsset::EvolutionEvent { .. } => {}
1015            }
1016        }
1017    }
1018}
1019
1020pub fn prepare_mutation(
1021    intent: MutationIntent,
1022    diff_payload: String,
1023    base_revision: Option<String>,
1024) -> PreparedMutation {
1025    PreparedMutation {
1026        intent,
1027        artifact: MutationArtifact {
1028            encoding: ArtifactEncoding::UnifiedDiff,
1029            content_hash: compute_artifact_hash(&diff_payload),
1030            payload: diff_payload,
1031            base_revision,
1032        },
1033    }
1034}
1035
1036pub fn prepare_mutation_from_spec(
1037    plan: CompiledMutationPlan,
1038    diff_payload: String,
1039    base_revision: Option<String>,
1040) -> PreparedMutation {
1041    prepare_mutation(plan.mutation_intent, diff_payload, base_revision)
1042}
1043
1044pub fn default_evolution_store() -> Arc<dyn EvolutionStore> {
1045    Arc::new(oris_evolution::JsonlEvolutionStore::new(
1046        default_store_root(),
1047    ))
1048}
1049
1050fn derive_gene(
1051    mutation: &PreparedMutation,
1052    receipt: &SandboxReceipt,
1053    validation_profile: &str,
1054) -> Gene {
1055    let mut strategy = BTreeSet::new();
1056    for file in &receipt.changed_files {
1057        if let Some(component) = file.components().next() {
1058            strategy.insert(component.as_os_str().to_string_lossy().to_string());
1059        }
1060    }
1061    for token in mutation
1062        .artifact
1063        .payload
1064        .split(|ch: char| !ch.is_ascii_alphanumeric())
1065    {
1066        if token.len() == 5
1067            && token.starts_with('E')
1068            && token[1..].chars().all(|ch| ch.is_ascii_digit())
1069        {
1070            strategy.insert(token.to_string());
1071        }
1072    }
1073    for token in mutation.intent.intent.split_whitespace().take(8) {
1074        strategy.insert(token.to_ascii_lowercase());
1075    }
1076    let strategy = strategy.into_iter().collect::<Vec<_>>();
1077    let id = stable_hash_json(&(&mutation.intent.signals, &strategy, validation_profile))
1078        .unwrap_or_else(|_| next_id("gene"));
1079    Gene {
1080        id,
1081        signals: mutation.intent.signals.clone(),
1082        strategy,
1083        validation: vec![validation_profile.to_string()],
1084        state: AssetState::Promoted,
1085    }
1086}
1087
1088fn build_capsule(
1089    run_id: &RunId,
1090    mutation: &PreparedMutation,
1091    receipt: &SandboxReceipt,
1092    report: &ValidationReport,
1093    validation_profile: &str,
1094    gene: &Gene,
1095    blast_radius: &BlastRadius,
1096) -> Result<Capsule, EvolutionError> {
1097    let env = current_env_fingerprint(&receipt.workdir);
1098    let validator_hash = stable_hash_json(report)?;
1099    let diff_hash = mutation.artifact.content_hash.clone();
1100    let id = stable_hash_json(&(run_id, &gene.id, &diff_hash, &mutation.intent.id))?;
1101    Ok(Capsule {
1102        id,
1103        gene_id: gene.id.clone(),
1104        mutation_id: mutation.intent.id.clone(),
1105        run_id: run_id.clone(),
1106        diff_hash,
1107        confidence: 0.7,
1108        env,
1109        outcome: oris_evolution::Outcome {
1110            success: true,
1111            validation_profile: validation_profile.to_string(),
1112            validation_duration_ms: report.duration_ms,
1113            changed_files: receipt
1114                .changed_files
1115                .iter()
1116                .map(|path| path.to_string_lossy().to_string())
1117                .collect(),
1118            validator_hash,
1119            lines_changed: blast_radius.lines_changed,
1120            replay_verified: false,
1121        },
1122        state: AssetState::Promoted,
1123    })
1124}
1125
1126fn current_env_fingerprint(workdir: &Path) -> EnvFingerprint {
1127    let rustc_version = Command::new("rustc")
1128        .arg("--version")
1129        .output()
1130        .ok()
1131        .filter(|output| output.status.success())
1132        .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
1133        .unwrap_or_else(|| "rustc unknown".into());
1134    let cargo_lock_hash = fs::read(workdir.join("Cargo.lock"))
1135        .ok()
1136        .map(|bytes| {
1137            let value = String::from_utf8_lossy(&bytes);
1138            compute_artifact_hash(&value)
1139        })
1140        .unwrap_or_else(|| "missing-cargo-lock".into());
1141    let target_triple = format!(
1142        "{}-unknown-{}",
1143        std::env::consts::ARCH,
1144        std::env::consts::OS
1145    );
1146    EnvFingerprint {
1147        rustc_version,
1148        cargo_lock_hash,
1149        target_triple,
1150        os: std::env::consts::OS.to_string(),
1151    }
1152}
1153
1154fn find_declared_mutation(
1155    store: &dyn EvolutionStore,
1156    mutation_id: &MutationId,
1157) -> Result<Option<PreparedMutation>, EvolutionError> {
1158    for stored in store.scan(1)? {
1159        if let EvolutionEvent::MutationDeclared { mutation } = stored.event {
1160            if &mutation.intent.id == mutation_id {
1161                return Ok(Some(mutation));
1162            }
1163        }
1164    }
1165    Ok(None)
1166}
1167
1168fn exact_match_candidates(store: &dyn EvolutionStore, input: &SelectorInput) -> Vec<GeneCandidate> {
1169    let Ok(projection) = store.rebuild_projection() else {
1170        return Vec::new();
1171    };
1172    let capsules = projection.capsules.clone();
1173    let spec_ids_by_gene = projection.spec_ids_by_gene.clone();
1174    let requested_spec_id = input
1175        .spec_id
1176        .as_deref()
1177        .map(str::trim)
1178        .filter(|value| !value.is_empty());
1179    let signal_set = input
1180        .signals
1181        .iter()
1182        .map(|signal| signal.to_ascii_lowercase())
1183        .collect::<BTreeSet<_>>();
1184    let mut candidates = projection
1185        .genes
1186        .into_iter()
1187        .filter_map(|gene| {
1188            if gene.state != AssetState::Promoted {
1189                return None;
1190            }
1191            if let Some(spec_id) = requested_spec_id {
1192                let matches_spec = spec_ids_by_gene
1193                    .get(&gene.id)
1194                    .map(|values| {
1195                        values
1196                            .iter()
1197                            .any(|value| value.eq_ignore_ascii_case(spec_id))
1198                    })
1199                    .unwrap_or(false);
1200                if !matches_spec {
1201                    return None;
1202                }
1203            }
1204            let gene_signals = gene
1205                .signals
1206                .iter()
1207                .map(|signal| signal.to_ascii_lowercase())
1208                .collect::<BTreeSet<_>>();
1209            if gene_signals == signal_set {
1210                let mut matched_capsules = capsules
1211                    .iter()
1212                    .filter(|capsule| {
1213                        capsule.gene_id == gene.id && capsule.state == AssetState::Promoted
1214                    })
1215                    .cloned()
1216                    .collect::<Vec<_>>();
1217                matched_capsules.sort_by(|left, right| {
1218                    replay_environment_match_factor(&input.env, &right.env)
1219                        .partial_cmp(&replay_environment_match_factor(&input.env, &left.env))
1220                        .unwrap_or(std::cmp::Ordering::Equal)
1221                        .then_with(|| {
1222                            right
1223                                .confidence
1224                                .partial_cmp(&left.confidence)
1225                                .unwrap_or(std::cmp::Ordering::Equal)
1226                        })
1227                        .then_with(|| left.id.cmp(&right.id))
1228                });
1229                if matched_capsules.is_empty() {
1230                    None
1231                } else {
1232                    let score = matched_capsules
1233                        .first()
1234                        .map(|capsule| replay_environment_match_factor(&input.env, &capsule.env))
1235                        .unwrap_or(0.0);
1236                    Some(GeneCandidate {
1237                        gene,
1238                        score,
1239                        capsules: matched_capsules,
1240                    })
1241                }
1242            } else {
1243                None
1244            }
1245        })
1246        .collect::<Vec<_>>();
1247    candidates.sort_by(|left, right| {
1248        right
1249            .score
1250            .partial_cmp(&left.score)
1251            .unwrap_or(std::cmp::Ordering::Equal)
1252            .then_with(|| left.gene.id.cmp(&right.gene.id))
1253    });
1254    candidates
1255}
1256
1257fn quarantined_remote_exact_match_candidates(
1258    store: &dyn EvolutionStore,
1259    input: &SelectorInput,
1260) -> Vec<GeneCandidate> {
1261    let remote_asset_ids = store
1262        .scan(1)
1263        .ok()
1264        .map(|events| {
1265            events
1266                .into_iter()
1267                .filter_map(|stored| match stored.event {
1268                    EvolutionEvent::RemoteAssetImported {
1269                        source: CandidateSource::Remote,
1270                        asset_ids,
1271                    } => Some(asset_ids),
1272                    _ => None,
1273                })
1274                .flatten()
1275                .collect::<BTreeSet<_>>()
1276        })
1277        .unwrap_or_default();
1278    if remote_asset_ids.is_empty() {
1279        return Vec::new();
1280    }
1281
1282    let Ok(projection) = store.rebuild_projection() else {
1283        return Vec::new();
1284    };
1285    let capsules = projection.capsules.clone();
1286    let spec_ids_by_gene = projection.spec_ids_by_gene.clone();
1287    let requested_spec_id = input
1288        .spec_id
1289        .as_deref()
1290        .map(str::trim)
1291        .filter(|value| !value.is_empty());
1292    let signal_set = input
1293        .signals
1294        .iter()
1295        .map(|signal| signal.to_ascii_lowercase())
1296        .collect::<BTreeSet<_>>();
1297    let mut candidates = projection
1298        .genes
1299        .into_iter()
1300        .filter_map(|gene| {
1301            if gene.state != AssetState::Promoted {
1302                return None;
1303            }
1304            if let Some(spec_id) = requested_spec_id {
1305                let matches_spec = spec_ids_by_gene
1306                    .get(&gene.id)
1307                    .map(|values| {
1308                        values
1309                            .iter()
1310                            .any(|value| value.eq_ignore_ascii_case(spec_id))
1311                    })
1312                    .unwrap_or(false);
1313                if !matches_spec {
1314                    return None;
1315                }
1316            }
1317            let gene_signals = gene
1318                .signals
1319                .iter()
1320                .map(|signal| signal.to_ascii_lowercase())
1321                .collect::<BTreeSet<_>>();
1322            if gene_signals == signal_set {
1323                let mut matched_capsules = capsules
1324                    .iter()
1325                    .filter(|capsule| {
1326                        capsule.gene_id == gene.id
1327                            && capsule.state == AssetState::Quarantined
1328                            && remote_asset_ids.contains(&capsule.id)
1329                    })
1330                    .cloned()
1331                    .collect::<Vec<_>>();
1332                matched_capsules.sort_by(|left, right| {
1333                    replay_environment_match_factor(&input.env, &right.env)
1334                        .partial_cmp(&replay_environment_match_factor(&input.env, &left.env))
1335                        .unwrap_or(std::cmp::Ordering::Equal)
1336                        .then_with(|| {
1337                            right
1338                                .confidence
1339                                .partial_cmp(&left.confidence)
1340                                .unwrap_or(std::cmp::Ordering::Equal)
1341                        })
1342                        .then_with(|| left.id.cmp(&right.id))
1343                });
1344                if matched_capsules.is_empty() {
1345                    None
1346                } else {
1347                    let score = matched_capsules
1348                        .first()
1349                        .map(|capsule| replay_environment_match_factor(&input.env, &capsule.env))
1350                        .unwrap_or(0.0);
1351                    Some(GeneCandidate {
1352                        gene,
1353                        score,
1354                        capsules: matched_capsules,
1355                    })
1356                }
1357            } else {
1358                None
1359            }
1360        })
1361        .collect::<Vec<_>>();
1362    candidates.sort_by(|left, right| {
1363        right
1364            .score
1365            .partial_cmp(&left.score)
1366            .unwrap_or(std::cmp::Ordering::Equal)
1367            .then_with(|| left.gene.id.cmp(&right.gene.id))
1368    });
1369    candidates
1370}
1371
1372fn replay_environment_match_factor(input: &EnvFingerprint, candidate: &EnvFingerprint) -> f32 {
1373    let fields = [
1374        input
1375            .rustc_version
1376            .eq_ignore_ascii_case(&candidate.rustc_version),
1377        input
1378            .cargo_lock_hash
1379            .eq_ignore_ascii_case(&candidate.cargo_lock_hash),
1380        input
1381            .target_triple
1382            .eq_ignore_ascii_case(&candidate.target_triple),
1383        input.os.eq_ignore_ascii_case(&candidate.os),
1384    ];
1385    let matched_fields = fields.into_iter().filter(|matched| *matched).count() as f32;
1386    0.5 + ((matched_fields / 4.0) * 0.5)
1387}
1388
1389fn effective_candidate_score(
1390    candidate: &GeneCandidate,
1391    publishers_by_gene: &BTreeMap<String, String>,
1392    reputation_bias: &BTreeMap<String, f32>,
1393) -> f32 {
1394    let bias = publishers_by_gene
1395        .get(&candidate.gene.id)
1396        .and_then(|publisher| reputation_bias.get(publisher))
1397        .copied()
1398        .unwrap_or(0.0)
1399        .clamp(0.0, 1.0);
1400    candidate.score * (1.0 + (bias * 0.1))
1401}
1402
1403fn export_promoted_assets_from_store(
1404    store: &dyn EvolutionStore,
1405    sender_id: impl Into<String>,
1406) -> Result<EvolutionEnvelope, EvoKernelError> {
1407    let projection = store.rebuild_projection().map_err(store_err)?;
1408    let mut assets = Vec::new();
1409    for gene in projection
1410        .genes
1411        .into_iter()
1412        .filter(|gene| gene.state == AssetState::Promoted)
1413    {
1414        assets.push(NetworkAsset::Gene { gene });
1415    }
1416    for capsule in projection
1417        .capsules
1418        .into_iter()
1419        .filter(|capsule| capsule.state == AssetState::Promoted)
1420    {
1421        assets.push(NetworkAsset::Capsule { capsule });
1422    }
1423    Ok(EvolutionEnvelope::publish(sender_id, assets))
1424}
1425
1426fn import_remote_envelope_into_store(
1427    store: &dyn EvolutionStore,
1428    envelope: &EvolutionEnvelope,
1429) -> Result<ImportOutcome, EvoKernelError> {
1430    if !envelope.verify_content_hash() {
1431        return Err(EvoKernelError::Validation(
1432            "invalid evolution envelope hash".into(),
1433        ));
1434    }
1435
1436    let mut imported_asset_ids = Vec::new();
1437    for asset in &envelope.assets {
1438        match asset {
1439            NetworkAsset::Gene { gene } => {
1440                imported_asset_ids.push(gene.id.clone());
1441                store
1442                    .append_event(EvolutionEvent::RemoteAssetImported {
1443                        source: CandidateSource::Remote,
1444                        asset_ids: vec![gene.id.clone()],
1445                    })
1446                    .map_err(store_err)?;
1447                store
1448                    .append_event(EvolutionEvent::GeneProjected { gene: gene.clone() })
1449                    .map_err(store_err)?;
1450            }
1451            NetworkAsset::Capsule { capsule } => {
1452                imported_asset_ids.push(capsule.id.clone());
1453                store
1454                    .append_event(EvolutionEvent::RemoteAssetImported {
1455                        source: CandidateSource::Remote,
1456                        asset_ids: vec![capsule.id.clone()],
1457                    })
1458                    .map_err(store_err)?;
1459                let mut quarantined = capsule.clone();
1460                quarantined.state = AssetState::Quarantined;
1461                store
1462                    .append_event(EvolutionEvent::CapsuleCommitted {
1463                        capsule: quarantined.clone(),
1464                    })
1465                    .map_err(store_err)?;
1466                store
1467                    .append_event(EvolutionEvent::CapsuleQuarantined {
1468                        capsule_id: quarantined.id,
1469                    })
1470                    .map_err(store_err)?;
1471            }
1472            NetworkAsset::EvolutionEvent { event } => {
1473                if should_import_remote_event(event) {
1474                    store.append_event(event.clone()).map_err(store_err)?;
1475                }
1476            }
1477        }
1478    }
1479
1480    Ok(ImportOutcome {
1481        imported_asset_ids,
1482        accepted: true,
1483    })
1484}
1485
1486fn should_import_remote_event(event: &EvolutionEvent) -> bool {
1487    matches!(
1488        event,
1489        EvolutionEvent::MutationDeclared { .. } | EvolutionEvent::SpecLinked { .. }
1490    )
1491}
1492
1493fn fetch_assets_from_store(
1494    store: &dyn EvolutionStore,
1495    responder_id: impl Into<String>,
1496    query: &FetchQuery,
1497) -> Result<FetchResponse, EvoKernelError> {
1498    let projection = store.rebuild_projection().map_err(store_err)?;
1499    let normalized_signals: Vec<String> = query
1500        .signals
1501        .iter()
1502        .map(|signal| signal.trim().to_ascii_lowercase())
1503        .filter(|signal| !signal.is_empty())
1504        .collect();
1505    let matches_any_signal = |candidate: &str| {
1506        if normalized_signals.is_empty() {
1507            return true;
1508        }
1509        let candidate = candidate.to_ascii_lowercase();
1510        normalized_signals
1511            .iter()
1512            .any(|signal| candidate.contains(signal) || signal.contains(&candidate))
1513    };
1514
1515    let matched_genes: Vec<Gene> = projection
1516        .genes
1517        .into_iter()
1518        .filter(|gene| gene.state == AssetState::Promoted)
1519        .filter(|gene| gene.signals.iter().any(|signal| matches_any_signal(signal)))
1520        .collect();
1521    let matched_gene_ids: BTreeSet<String> =
1522        matched_genes.iter().map(|gene| gene.id.clone()).collect();
1523    let matched_capsules: Vec<Capsule> = projection
1524        .capsules
1525        .into_iter()
1526        .filter(|capsule| capsule.state == AssetState::Promoted)
1527        .filter(|capsule| matched_gene_ids.contains(&capsule.gene_id))
1528        .collect();
1529
1530    let mut assets = Vec::new();
1531    for gene in matched_genes {
1532        assets.push(NetworkAsset::Gene { gene });
1533    }
1534    for capsule in matched_capsules {
1535        assets.push(NetworkAsset::Capsule { capsule });
1536    }
1537
1538    Ok(FetchResponse {
1539        sender_id: responder_id.into(),
1540        assets,
1541    })
1542}
1543
1544fn revoke_assets_in_store(
1545    store: &dyn EvolutionStore,
1546    notice: &RevokeNotice,
1547) -> Result<RevokeNotice, EvoKernelError> {
1548    let projection = store.rebuild_projection().map_err(store_err)?;
1549    let requested: BTreeSet<String> = notice
1550        .asset_ids
1551        .iter()
1552        .map(|asset_id| asset_id.trim().to_string())
1553        .filter(|asset_id| !asset_id.is_empty())
1554        .collect();
1555    let mut revoked_gene_ids = BTreeSet::new();
1556    let mut quarantined_capsule_ids = BTreeSet::new();
1557
1558    for gene in &projection.genes {
1559        if requested.contains(&gene.id) {
1560            revoked_gene_ids.insert(gene.id.clone());
1561        }
1562    }
1563    for capsule in &projection.capsules {
1564        if requested.contains(&capsule.id) {
1565            quarantined_capsule_ids.insert(capsule.id.clone());
1566            revoked_gene_ids.insert(capsule.gene_id.clone());
1567        }
1568    }
1569    for capsule in &projection.capsules {
1570        if revoked_gene_ids.contains(&capsule.gene_id) {
1571            quarantined_capsule_ids.insert(capsule.id.clone());
1572        }
1573    }
1574
1575    for gene_id in &revoked_gene_ids {
1576        store
1577            .append_event(EvolutionEvent::GeneRevoked {
1578                gene_id: gene_id.clone(),
1579                reason: notice.reason.clone(),
1580            })
1581            .map_err(store_err)?;
1582    }
1583    for capsule_id in &quarantined_capsule_ids {
1584        store
1585            .append_event(EvolutionEvent::CapsuleQuarantined {
1586                capsule_id: capsule_id.clone(),
1587            })
1588            .map_err(store_err)?;
1589    }
1590
1591    let mut affected_ids: Vec<String> = revoked_gene_ids.into_iter().collect();
1592    affected_ids.extend(quarantined_capsule_ids);
1593    affected_ids.sort();
1594    affected_ids.dedup();
1595
1596    Ok(RevokeNotice {
1597        sender_id: notice.sender_id.clone(),
1598        asset_ids: affected_ids,
1599        reason: notice.reason.clone(),
1600    })
1601}
1602
1603fn evolution_metrics_snapshot(
1604    store: &dyn EvolutionStore,
1605) -> Result<EvolutionMetricsSnapshot, EvoKernelError> {
1606    let events = store.scan(1).map_err(store_err)?;
1607    let projection = store.rebuild_projection().map_err(store_err)?;
1608    let replay_success_total = events
1609        .iter()
1610        .filter(|stored| matches!(stored.event, EvolutionEvent::CapsuleReused { .. }))
1611        .count() as u64;
1612    let replay_failures_total = events
1613        .iter()
1614        .filter(|stored| is_replay_validation_failure(&stored.event))
1615        .count() as u64;
1616    let replay_attempts_total = replay_success_total + replay_failures_total;
1617    let mutation_declared_total = events
1618        .iter()
1619        .filter(|stored| matches!(stored.event, EvolutionEvent::MutationDeclared { .. }))
1620        .count() as u64;
1621    let promoted_mutations_total = events
1622        .iter()
1623        .filter(|stored| matches!(stored.event, EvolutionEvent::GenePromoted { .. }))
1624        .count() as u64;
1625    let gene_revocations_total = events
1626        .iter()
1627        .filter(|stored| matches!(stored.event, EvolutionEvent::GeneRevoked { .. }))
1628        .count() as u64;
1629    let cutoff = Utc::now() - Duration::hours(1);
1630    let mutation_velocity_last_hour = count_recent_events(&events, cutoff, |event| {
1631        matches!(event, EvolutionEvent::MutationDeclared { .. })
1632    });
1633    let revoke_frequency_last_hour = count_recent_events(&events, cutoff, |event| {
1634        matches!(event, EvolutionEvent::GeneRevoked { .. })
1635    });
1636    let promoted_genes = projection
1637        .genes
1638        .iter()
1639        .filter(|gene| gene.state == AssetState::Promoted)
1640        .count() as u64;
1641    let promoted_capsules = projection
1642        .capsules
1643        .iter()
1644        .filter(|capsule| capsule.state == AssetState::Promoted)
1645        .count() as u64;
1646
1647    Ok(EvolutionMetricsSnapshot {
1648        replay_attempts_total,
1649        replay_success_total,
1650        replay_success_rate: safe_ratio(replay_success_total, replay_attempts_total),
1651        mutation_declared_total,
1652        promoted_mutations_total,
1653        promotion_ratio: safe_ratio(promoted_mutations_total, mutation_declared_total),
1654        gene_revocations_total,
1655        mutation_velocity_last_hour,
1656        revoke_frequency_last_hour,
1657        promoted_genes,
1658        promoted_capsules,
1659        last_event_seq: events.last().map(|stored| stored.seq).unwrap_or(0),
1660    })
1661}
1662
1663fn evolution_health_snapshot(snapshot: &EvolutionMetricsSnapshot) -> EvolutionHealthSnapshot {
1664    EvolutionHealthSnapshot {
1665        status: "ok".into(),
1666        last_event_seq: snapshot.last_event_seq,
1667        promoted_genes: snapshot.promoted_genes,
1668        promoted_capsules: snapshot.promoted_capsules,
1669    }
1670}
1671
1672fn render_evolution_metrics_prometheus(
1673    snapshot: &EvolutionMetricsSnapshot,
1674    health: &EvolutionHealthSnapshot,
1675) -> String {
1676    let mut out = String::new();
1677    out.push_str(
1678        "# HELP oris_evolution_replay_attempts_total Total replay attempts that reached validation.\n",
1679    );
1680    out.push_str("# TYPE oris_evolution_replay_attempts_total counter\n");
1681    out.push_str(&format!(
1682        "oris_evolution_replay_attempts_total {}\n",
1683        snapshot.replay_attempts_total
1684    ));
1685    out.push_str("# HELP oris_evolution_replay_success_total Total replay attempts that reused a capsule successfully.\n");
1686    out.push_str("# TYPE oris_evolution_replay_success_total counter\n");
1687    out.push_str(&format!(
1688        "oris_evolution_replay_success_total {}\n",
1689        snapshot.replay_success_total
1690    ));
1691    out.push_str("# HELP oris_evolution_replay_success_rate Successful replay attempts divided by replay attempts that reached validation.\n");
1692    out.push_str("# TYPE oris_evolution_replay_success_rate gauge\n");
1693    out.push_str(&format!(
1694        "oris_evolution_replay_success_rate {:.6}\n",
1695        snapshot.replay_success_rate
1696    ));
1697    out.push_str(
1698        "# HELP oris_evolution_mutation_declared_total Total declared mutations recorded in the evolution log.\n",
1699    );
1700    out.push_str("# TYPE oris_evolution_mutation_declared_total counter\n");
1701    out.push_str(&format!(
1702        "oris_evolution_mutation_declared_total {}\n",
1703        snapshot.mutation_declared_total
1704    ));
1705    out.push_str("# HELP oris_evolution_promoted_mutations_total Total mutations promoted by the governor.\n");
1706    out.push_str("# TYPE oris_evolution_promoted_mutations_total counter\n");
1707    out.push_str(&format!(
1708        "oris_evolution_promoted_mutations_total {}\n",
1709        snapshot.promoted_mutations_total
1710    ));
1711    out.push_str(
1712        "# HELP oris_evolution_promotion_ratio Promoted mutations divided by declared mutations.\n",
1713    );
1714    out.push_str("# TYPE oris_evolution_promotion_ratio gauge\n");
1715    out.push_str(&format!(
1716        "oris_evolution_promotion_ratio {:.6}\n",
1717        snapshot.promotion_ratio
1718    ));
1719    out.push_str("# HELP oris_evolution_gene_revocations_total Total gene revocations recorded in the evolution log.\n");
1720    out.push_str("# TYPE oris_evolution_gene_revocations_total counter\n");
1721    out.push_str(&format!(
1722        "oris_evolution_gene_revocations_total {}\n",
1723        snapshot.gene_revocations_total
1724    ));
1725    out.push_str("# HELP oris_evolution_mutation_velocity_last_hour Declared mutations observed in the last hour.\n");
1726    out.push_str("# TYPE oris_evolution_mutation_velocity_last_hour gauge\n");
1727    out.push_str(&format!(
1728        "oris_evolution_mutation_velocity_last_hour {}\n",
1729        snapshot.mutation_velocity_last_hour
1730    ));
1731    out.push_str("# HELP oris_evolution_revoke_frequency_last_hour Gene revocations observed in the last hour.\n");
1732    out.push_str("# TYPE oris_evolution_revoke_frequency_last_hour gauge\n");
1733    out.push_str(&format!(
1734        "oris_evolution_revoke_frequency_last_hour {}\n",
1735        snapshot.revoke_frequency_last_hour
1736    ));
1737    out.push_str("# HELP oris_evolution_promoted_genes Current promoted genes in the evolution projection.\n");
1738    out.push_str("# TYPE oris_evolution_promoted_genes gauge\n");
1739    out.push_str(&format!(
1740        "oris_evolution_promoted_genes {}\n",
1741        snapshot.promoted_genes
1742    ));
1743    out.push_str("# HELP oris_evolution_promoted_capsules Current promoted capsules in the evolution projection.\n");
1744    out.push_str("# TYPE oris_evolution_promoted_capsules gauge\n");
1745    out.push_str(&format!(
1746        "oris_evolution_promoted_capsules {}\n",
1747        snapshot.promoted_capsules
1748    ));
1749    out.push_str("# HELP oris_evolution_store_last_event_seq Last visible append-only evolution event sequence.\n");
1750    out.push_str("# TYPE oris_evolution_store_last_event_seq gauge\n");
1751    out.push_str(&format!(
1752        "oris_evolution_store_last_event_seq {}\n",
1753        snapshot.last_event_seq
1754    ));
1755    out.push_str(
1756        "# HELP oris_evolution_health Evolution observability store health (1 = healthy).\n",
1757    );
1758    out.push_str("# TYPE oris_evolution_health gauge\n");
1759    out.push_str(&format!(
1760        "oris_evolution_health {}\n",
1761        u8::from(health.status == "ok")
1762    ));
1763    out
1764}
1765
1766fn count_recent_events(
1767    events: &[StoredEvolutionEvent],
1768    cutoff: DateTime<Utc>,
1769    predicate: impl Fn(&EvolutionEvent) -> bool,
1770) -> u64 {
1771    events
1772        .iter()
1773        .filter(|stored| {
1774            predicate(&stored.event)
1775                && parse_event_timestamp(&stored.timestamp)
1776                    .map(|timestamp| timestamp >= cutoff)
1777                    .unwrap_or(false)
1778        })
1779        .count() as u64
1780}
1781
1782fn parse_event_timestamp(raw: &str) -> Option<DateTime<Utc>> {
1783    DateTime::parse_from_rfc3339(raw)
1784        .ok()
1785        .map(|parsed| parsed.with_timezone(&Utc))
1786}
1787
1788fn is_replay_validation_failure(event: &EvolutionEvent) -> bool {
1789    matches!(
1790        event,
1791        EvolutionEvent::ValidationFailed {
1792            gene_id: Some(_),
1793            ..
1794        }
1795    )
1796}
1797
1798fn safe_ratio(numerator: u64, denominator: u64) -> f64 {
1799    if denominator == 0 {
1800        0.0
1801    } else {
1802        numerator as f64 / denominator as f64
1803    }
1804}
1805
1806fn store_err(err: EvolutionError) -> EvoKernelError {
1807    EvoKernelError::Store(err.to_string())
1808}
1809
1810#[cfg(test)]
1811mod tests {
1812    use super::*;
1813    use oris_kernel::{
1814        AllowAllPolicy, InMemoryEventStore, KernelMode, KernelState, NoopActionExecutor,
1815        NoopStepFn, StateUpdatedOnlyReducer,
1816    };
1817    use serde::{Deserialize, Serialize};
1818
1819    #[derive(Clone, Debug, Default, Serialize, Deserialize)]
1820    struct TestState;
1821
1822    impl KernelState for TestState {
1823        fn version(&self) -> u32 {
1824            1
1825        }
1826    }
1827
1828    fn temp_workspace(name: &str) -> std::path::PathBuf {
1829        let root =
1830            std::env::temp_dir().join(format!("oris-evokernel-{name}-{}", std::process::id()));
1831        if root.exists() {
1832            fs::remove_dir_all(&root).unwrap();
1833        }
1834        fs::create_dir_all(root.join("src")).unwrap();
1835        fs::write(
1836            root.join("Cargo.toml"),
1837            "[package]\nname = \"sample\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
1838        )
1839        .unwrap();
1840        fs::write(root.join("Cargo.lock"), "# lock\n").unwrap();
1841        fs::write(root.join("src/lib.rs"), "pub fn demo() -> usize { 1 }\n").unwrap();
1842        root
1843    }
1844
1845    fn test_kernel() -> Arc<Kernel<TestState>> {
1846        Arc::new(Kernel::<TestState> {
1847            events: Box::new(InMemoryEventStore::new()),
1848            snaps: None,
1849            reducer: Box::new(StateUpdatedOnlyReducer),
1850            exec: Box::new(NoopActionExecutor),
1851            step: Box::new(NoopStepFn),
1852            policy: Box::new(AllowAllPolicy),
1853            effect_sink: None,
1854            mode: KernelMode::Normal,
1855        })
1856    }
1857
1858    fn lightweight_plan() -> ValidationPlan {
1859        ValidationPlan {
1860            profile: "test".into(),
1861            stages: vec![ValidationStage::Command {
1862                program: "git".into(),
1863                args: vec!["--version".into()],
1864                timeout_ms: 5_000,
1865            }],
1866        }
1867    }
1868
1869    fn sample_mutation() -> PreparedMutation {
1870        prepare_mutation(
1871            MutationIntent {
1872                id: "mutation-1".into(),
1873                intent: "add README".into(),
1874                target: MutationTarget::Paths {
1875                    allow: vec!["README.md".into()],
1876                },
1877                expected_effect: "repo still builds".into(),
1878                risk: RiskLevel::Low,
1879                signals: vec!["missing readme".into()],
1880                spec_id: None,
1881            },
1882            "\
1883diff --git a/README.md b/README.md
1884new file mode 100644
1885index 0000000..1111111
1886--- /dev/null
1887+++ b/README.md
1888@@ -0,0 +1 @@
1889+# sample
1890"
1891            .into(),
1892            Some("HEAD".into()),
1893        )
1894    }
1895
1896    fn base_sandbox_policy() -> SandboxPolicy {
1897        SandboxPolicy {
1898            allowed_programs: vec!["git".into()],
1899            max_duration_ms: 60_000,
1900            max_output_bytes: 1024 * 1024,
1901            denied_env_prefixes: Vec::new(),
1902        }
1903    }
1904
1905    fn command_validator() -> Arc<dyn Validator> {
1906        Arc::new(CommandValidator::new(base_sandbox_policy()))
1907    }
1908
1909    fn replay_input(signal: &str) -> SelectorInput {
1910        SelectorInput {
1911            signals: vec![signal.into()],
1912            env: EnvFingerprint {
1913                rustc_version: "rustc".into(),
1914                cargo_lock_hash: "lock".into(),
1915                target_triple: "x86_64-unknown-linux-gnu".into(),
1916                os: std::env::consts::OS.into(),
1917            },
1918            spec_id: None,
1919            limit: 1,
1920        }
1921    }
1922
1923    fn build_test_evo_with_store(
1924        name: &str,
1925        run_id: &str,
1926        validator: Arc<dyn Validator>,
1927        store: Arc<dyn EvolutionStore>,
1928    ) -> EvoKernel<TestState> {
1929        let workspace = temp_workspace(name);
1930        let sandbox: Arc<dyn Sandbox> = Arc::new(oris_sandbox::LocalProcessSandbox::new(
1931            run_id,
1932            &workspace,
1933            std::env::temp_dir(),
1934        ));
1935        EvoKernel::new(test_kernel(), sandbox, validator, store)
1936            .with_governor(Arc::new(DefaultGovernor::new(
1937                oris_governor::GovernorConfig {
1938                    promote_after_successes: 1,
1939                    ..Default::default()
1940                },
1941            )))
1942            .with_validation_plan(lightweight_plan())
1943            .with_sandbox_policy(base_sandbox_policy())
1944    }
1945
1946    fn build_test_evo(
1947        name: &str,
1948        run_id: &str,
1949        validator: Arc<dyn Validator>,
1950    ) -> (EvoKernel<TestState>, Arc<dyn EvolutionStore>) {
1951        let store_root = std::env::temp_dir().join(format!(
1952            "oris-evokernel-{name}-store-{}",
1953            std::process::id()
1954        ));
1955        if store_root.exists() {
1956            fs::remove_dir_all(&store_root).unwrap();
1957        }
1958        let store: Arc<dyn EvolutionStore> =
1959            Arc::new(oris_evolution::JsonlEvolutionStore::new(&store_root));
1960        let evo = build_test_evo_with_store(name, run_id, validator, store.clone());
1961        (evo, store)
1962    }
1963
1964    fn remote_publish_envelope(
1965        sender_id: &str,
1966        run_id: &str,
1967        gene_id: &str,
1968        capsule_id: &str,
1969        mutation_id: &str,
1970        signal: &str,
1971        file_name: &str,
1972        line: &str,
1973    ) -> EvolutionEnvelope {
1974        remote_publish_envelope_with_env(
1975            sender_id,
1976            run_id,
1977            gene_id,
1978            capsule_id,
1979            mutation_id,
1980            signal,
1981            file_name,
1982            line,
1983            replay_input(signal).env,
1984        )
1985    }
1986
1987    fn remote_publish_envelope_with_env(
1988        sender_id: &str,
1989        run_id: &str,
1990        gene_id: &str,
1991        capsule_id: &str,
1992        mutation_id: &str,
1993        signal: &str,
1994        file_name: &str,
1995        line: &str,
1996        env: EnvFingerprint,
1997    ) -> EvolutionEnvelope {
1998        let mutation = prepare_mutation(
1999            MutationIntent {
2000                id: mutation_id.into(),
2001                intent: format!("add {file_name}"),
2002                target: MutationTarget::Paths {
2003                    allow: vec![file_name.into()],
2004                },
2005                expected_effect: "replay should still validate".into(),
2006                risk: RiskLevel::Low,
2007                signals: vec![signal.into()],
2008                spec_id: None,
2009            },
2010            format!(
2011                "\
2012diff --git a/{file_name} b/{file_name}
2013new file mode 100644
2014index 0000000..1111111
2015--- /dev/null
2016+++ b/{file_name}
2017@@ -0,0 +1 @@
2018+{line}
2019"
2020            ),
2021            Some("HEAD".into()),
2022        );
2023        let gene = Gene {
2024            id: gene_id.into(),
2025            signals: vec![signal.into()],
2026            strategy: vec![file_name.into()],
2027            validation: vec!["test".into()],
2028            state: AssetState::Promoted,
2029        };
2030        let capsule = Capsule {
2031            id: capsule_id.into(),
2032            gene_id: gene_id.into(),
2033            mutation_id: mutation_id.into(),
2034            run_id: run_id.into(),
2035            diff_hash: mutation.artifact.content_hash.clone(),
2036            confidence: 0.9,
2037            env,
2038            outcome: Outcome {
2039                success: true,
2040                validation_profile: "test".into(),
2041                validation_duration_ms: 1,
2042                changed_files: vec![file_name.into()],
2043                validator_hash: "validator-hash".into(),
2044                lines_changed: 1,
2045                replay_verified: false,
2046            },
2047            state: AssetState::Promoted,
2048        };
2049        EvolutionEnvelope::publish(
2050            sender_id,
2051            vec![
2052                NetworkAsset::EvolutionEvent {
2053                    event: EvolutionEvent::MutationDeclared { mutation },
2054                },
2055                NetworkAsset::Gene { gene: gene.clone() },
2056                NetworkAsset::Capsule {
2057                    capsule: capsule.clone(),
2058                },
2059                NetworkAsset::EvolutionEvent {
2060                    event: EvolutionEvent::CapsuleReleased {
2061                        capsule_id: capsule.id.clone(),
2062                        state: AssetState::Promoted,
2063                    },
2064                },
2065            ],
2066        )
2067    }
2068
2069    struct FixedValidator {
2070        success: bool,
2071    }
2072
2073    #[async_trait]
2074    impl Validator for FixedValidator {
2075        async fn run(
2076            &self,
2077            _receipt: &SandboxReceipt,
2078            plan: &ValidationPlan,
2079        ) -> Result<ValidationReport, ValidationError> {
2080            Ok(ValidationReport {
2081                success: self.success,
2082                duration_ms: 1,
2083                stages: Vec::new(),
2084                logs: if self.success {
2085                    format!("{} ok", plan.profile)
2086                } else {
2087                    format!("{} failed", plan.profile)
2088                },
2089            })
2090        }
2091    }
2092
2093    #[tokio::test]
2094    async fn command_validator_aggregates_stage_reports() {
2095        let workspace = temp_workspace("validator");
2096        let receipt = SandboxReceipt {
2097            mutation_id: "m".into(),
2098            workdir: workspace,
2099            applied: true,
2100            changed_files: Vec::new(),
2101            patch_hash: "hash".into(),
2102            stdout_log: std::env::temp_dir().join("stdout.log"),
2103            stderr_log: std::env::temp_dir().join("stderr.log"),
2104        };
2105        let validator = CommandValidator::new(SandboxPolicy {
2106            allowed_programs: vec!["git".into()],
2107            max_duration_ms: 1_000,
2108            max_output_bytes: 1024,
2109            denied_env_prefixes: Vec::new(),
2110        });
2111        let report = validator
2112            .run(
2113                &receipt,
2114                &ValidationPlan {
2115                    profile: "test".into(),
2116                    stages: vec![ValidationStage::Command {
2117                        program: "git".into(),
2118                        args: vec!["--version".into()],
2119                        timeout_ms: 1_000,
2120                    }],
2121                },
2122            )
2123            .await
2124            .unwrap();
2125        assert_eq!(report.stages.len(), 1);
2126    }
2127
2128    #[tokio::test]
2129    async fn capture_successful_mutation_appends_capsule() {
2130        let (evo, store) = build_test_evo("capture", "run-1", command_validator());
2131        let capsule = evo
2132            .capture_successful_mutation(&"run-1".into(), sample_mutation())
2133            .await
2134            .unwrap();
2135        let events = store.scan(1).unwrap();
2136        assert!(events
2137            .iter()
2138            .any(|stored| matches!(stored.event, EvolutionEvent::CapsuleCommitted { .. })));
2139        assert!(!capsule.id.is_empty());
2140    }
2141
2142    #[tokio::test]
2143    async fn replay_hit_records_capsule_reused() {
2144        let (evo, store) = build_test_evo("replay", "run-2", command_validator());
2145        let capsule = evo
2146            .capture_successful_mutation(&"run-2".into(), sample_mutation())
2147            .await
2148            .unwrap();
2149        let decision = evo
2150            .replay_or_fallback(replay_input("missing readme"))
2151            .await
2152            .unwrap();
2153        assert!(decision.used_capsule);
2154        assert_eq!(decision.capsule_id, Some(capsule.id));
2155        assert!(store
2156            .scan(1)
2157            .unwrap()
2158            .iter()
2159            .any(|stored| matches!(stored.event, EvolutionEvent::CapsuleReused { .. })));
2160    }
2161
2162    #[tokio::test]
2163    async fn metrics_snapshot_tracks_replay_promotion_and_revocation_signals() {
2164        let (evo, _) = build_test_evo("metrics", "run-metrics", command_validator());
2165        let capsule = evo
2166            .capture_successful_mutation(&"run-metrics".into(), sample_mutation())
2167            .await
2168            .unwrap();
2169        let decision = evo
2170            .replay_or_fallback(replay_input("missing readme"))
2171            .await
2172            .unwrap();
2173        assert!(decision.used_capsule);
2174
2175        evo.revoke_assets(&RevokeNotice {
2176            sender_id: "node-metrics".into(),
2177            asset_ids: vec![capsule.id.clone()],
2178            reason: "manual test revoke".into(),
2179        })
2180        .unwrap();
2181
2182        let snapshot = evo.metrics_snapshot().unwrap();
2183        assert_eq!(snapshot.replay_attempts_total, 1);
2184        assert_eq!(snapshot.replay_success_total, 1);
2185        assert_eq!(snapshot.replay_success_rate, 1.0);
2186        assert_eq!(snapshot.mutation_declared_total, 1);
2187        assert_eq!(snapshot.promoted_mutations_total, 1);
2188        assert_eq!(snapshot.promotion_ratio, 1.0);
2189        assert_eq!(snapshot.gene_revocations_total, 1);
2190        assert_eq!(snapshot.mutation_velocity_last_hour, 1);
2191        assert_eq!(snapshot.revoke_frequency_last_hour, 1);
2192        assert_eq!(snapshot.promoted_genes, 0);
2193        assert_eq!(snapshot.promoted_capsules, 0);
2194
2195        let rendered = evo.render_metrics_prometheus().unwrap();
2196        assert!(rendered.contains("oris_evolution_replay_success_rate 1.000000"));
2197        assert!(rendered.contains("oris_evolution_promotion_ratio 1.000000"));
2198        assert!(rendered.contains("oris_evolution_revoke_frequency_last_hour 1"));
2199        assert!(rendered.contains("oris_evolution_mutation_velocity_last_hour 1"));
2200        assert!(rendered.contains("oris_evolution_health 1"));
2201    }
2202
2203    #[tokio::test]
2204    async fn remote_replay_prefers_closest_environment_match() {
2205        let (evo, _) = build_test_evo("remote-env", "run-remote-env", command_validator());
2206        let input = replay_input("env-signal");
2207
2208        let envelope_a = remote_publish_envelope_with_env(
2209            "node-a",
2210            "run-remote-a",
2211            "gene-a",
2212            "capsule-a",
2213            "mutation-a",
2214            "env-signal",
2215            "A.md",
2216            "# from a",
2217            input.env.clone(),
2218        );
2219        let envelope_b = remote_publish_envelope_with_env(
2220            "node-b",
2221            "run-remote-b",
2222            "gene-b",
2223            "capsule-b",
2224            "mutation-b",
2225            "env-signal",
2226            "B.md",
2227            "# from b",
2228            EnvFingerprint {
2229                rustc_version: "old-rustc".into(),
2230                cargo_lock_hash: "other-lock".into(),
2231                target_triple: "aarch64-apple-darwin".into(),
2232                os: "linux".into(),
2233            },
2234        );
2235
2236        evo.import_remote_envelope(&envelope_a).unwrap();
2237        evo.import_remote_envelope(&envelope_b).unwrap();
2238
2239        let decision = evo.replay_or_fallback(input).await.unwrap();
2240
2241        assert!(decision.used_capsule);
2242        assert_eq!(decision.capsule_id, Some("capsule-a".into()));
2243        assert!(!decision.fallback_to_planner);
2244    }
2245
2246    #[tokio::test]
2247    async fn remote_capsule_stays_quarantined_until_first_successful_replay() {
2248        let (evo, store) = build_test_evo(
2249            "remote-quarantine",
2250            "run-remote-quarantine",
2251            command_validator(),
2252        );
2253        let envelope = remote_publish_envelope(
2254            "node-remote",
2255            "run-remote-quarantine",
2256            "gene-remote",
2257            "capsule-remote",
2258            "mutation-remote",
2259            "remote-signal",
2260            "REMOTE.md",
2261            "# from remote",
2262        );
2263
2264        evo.import_remote_envelope(&envelope).unwrap();
2265
2266        let before_replay = store.rebuild_projection().unwrap();
2267        let imported_capsule = before_replay
2268            .capsules
2269            .iter()
2270            .find(|capsule| capsule.id == "capsule-remote")
2271            .unwrap();
2272        assert_eq!(imported_capsule.state, AssetState::Quarantined);
2273
2274        let decision = evo
2275            .replay_or_fallback(replay_input("remote-signal"))
2276            .await
2277            .unwrap();
2278
2279        assert!(decision.used_capsule);
2280        assert_eq!(decision.capsule_id, Some("capsule-remote".into()));
2281
2282        let after_replay = store.rebuild_projection().unwrap();
2283        let released_capsule = after_replay
2284            .capsules
2285            .iter()
2286            .find(|capsule| capsule.id == "capsule-remote")
2287            .unwrap();
2288        assert_eq!(released_capsule.state, AssetState::Promoted);
2289    }
2290
2291    #[tokio::test]
2292    async fn insufficient_evu_blocks_publish_but_not_local_replay() {
2293        let (evo, _) = build_test_evo("stake-gate", "run-stake", command_validator());
2294        let capsule = evo
2295            .capture_successful_mutation(&"run-stake".into(), sample_mutation())
2296            .await
2297            .unwrap();
2298        let publish = evo.export_promoted_assets("node-local");
2299        assert!(matches!(publish, Err(EvoKernelError::Validation(_))));
2300
2301        let decision = evo
2302            .replay_or_fallback(replay_input("missing readme"))
2303            .await
2304            .unwrap();
2305        assert!(decision.used_capsule);
2306        assert_eq!(decision.capsule_id, Some(capsule.id));
2307    }
2308
2309    #[tokio::test]
2310    async fn second_replay_validation_failure_revokes_gene_immediately() {
2311        let (capturer, store) = build_test_evo("revoke-replay", "run-capture", command_validator());
2312        let capsule = capturer
2313            .capture_successful_mutation(&"run-capture".into(), sample_mutation())
2314            .await
2315            .unwrap();
2316
2317        let failing_validator: Arc<dyn Validator> = Arc::new(FixedValidator { success: false });
2318        let failing_replay = build_test_evo_with_store(
2319            "revoke-replay",
2320            "run-replay-fail",
2321            failing_validator,
2322            store.clone(),
2323        );
2324
2325        let first = failing_replay
2326            .replay_or_fallback(replay_input("missing readme"))
2327            .await
2328            .unwrap();
2329        let second = failing_replay
2330            .replay_or_fallback(replay_input("missing readme"))
2331            .await
2332            .unwrap();
2333
2334        assert!(!first.used_capsule);
2335        assert!(first.fallback_to_planner);
2336        assert!(!second.used_capsule);
2337        assert!(second.fallback_to_planner);
2338
2339        let projection = store.rebuild_projection().unwrap();
2340        let gene = projection
2341            .genes
2342            .iter()
2343            .find(|gene| gene.id == capsule.gene_id)
2344            .unwrap();
2345        assert_eq!(gene.state, AssetState::Revoked);
2346        let committed_capsule = projection
2347            .capsules
2348            .iter()
2349            .find(|current| current.id == capsule.id)
2350            .unwrap();
2351        assert_eq!(committed_capsule.state, AssetState::Quarantined);
2352
2353        let events = store.scan(1).unwrap();
2354        assert_eq!(
2355            events
2356                .iter()
2357                .filter(|stored| {
2358                    matches!(
2359                        &stored.event,
2360                        EvolutionEvent::ValidationFailed {
2361                            gene_id: Some(gene_id),
2362                            ..
2363                        } if gene_id == &capsule.gene_id
2364                    )
2365                })
2366                .count(),
2367            2
2368        );
2369        assert!(events.iter().any(|stored| {
2370            matches!(
2371                &stored.event,
2372                EvolutionEvent::GeneRevoked { gene_id, .. } if gene_id == &capsule.gene_id
2373            )
2374        }));
2375
2376        let recovered = build_test_evo_with_store(
2377            "revoke-replay",
2378            "run-replay-check",
2379            command_validator(),
2380            store.clone(),
2381        );
2382        let after_revoke = recovered
2383            .replay_or_fallback(replay_input("missing readme"))
2384            .await
2385            .unwrap();
2386        assert!(!after_revoke.used_capsule);
2387        assert!(after_revoke.fallback_to_planner);
2388        assert_eq!(after_revoke.reason, "no matching gene");
2389    }
2390
2391    #[tokio::test]
2392    async fn remote_reuse_success_rewards_publisher_and_biases_selection() {
2393        let ledger = Arc::new(Mutex::new(EvuLedger {
2394            accounts: vec![],
2395            reputations: vec![
2396                oris_economics::ReputationRecord {
2397                    node_id: "node-a".into(),
2398                    publish_success_rate: 0.4,
2399                    validator_accuracy: 0.4,
2400                    reuse_impact: 0,
2401                },
2402                oris_economics::ReputationRecord {
2403                    node_id: "node-b".into(),
2404                    publish_success_rate: 0.95,
2405                    validator_accuracy: 0.95,
2406                    reuse_impact: 8,
2407                },
2408            ],
2409        }));
2410        let (evo, _) = build_test_evo("remote-success", "run-remote", command_validator());
2411        let evo = evo.with_economics(ledger.clone());
2412
2413        let envelope_a = remote_publish_envelope(
2414            "node-a",
2415            "run-remote-a",
2416            "gene-a",
2417            "capsule-a",
2418            "mutation-a",
2419            "shared-signal",
2420            "A.md",
2421            "# from a",
2422        );
2423        let envelope_b = remote_publish_envelope(
2424            "node-b",
2425            "run-remote-b",
2426            "gene-b",
2427            "capsule-b",
2428            "mutation-b",
2429            "shared-signal",
2430            "B.md",
2431            "# from b",
2432        );
2433
2434        evo.import_remote_envelope(&envelope_a).unwrap();
2435        evo.import_remote_envelope(&envelope_b).unwrap();
2436
2437        let decision = evo
2438            .replay_or_fallback(replay_input("shared-signal"))
2439            .await
2440            .unwrap();
2441
2442        assert!(decision.used_capsule);
2443        assert_eq!(decision.capsule_id, Some("capsule-b".into()));
2444        let locked = ledger.lock().unwrap();
2445        let rewarded = locked
2446            .accounts
2447            .iter()
2448            .find(|item| item.node_id == "node-b")
2449            .unwrap();
2450        assert_eq!(rewarded.balance, evo.stake_policy.reuse_reward);
2451        assert!(
2452            locked.selector_reputation_bias()["node-b"]
2453                > locked.selector_reputation_bias()["node-a"]
2454        );
2455    }
2456
2457    #[tokio::test]
2458    async fn remote_reuse_failure_penalizes_remote_reputation() {
2459        let ledger = Arc::new(Mutex::new(EvuLedger::default()));
2460        let failing_validator: Arc<dyn Validator> = Arc::new(FixedValidator { success: false });
2461        let (evo, _) = build_test_evo("remote-failure", "run-failure", failing_validator);
2462        let evo = evo.with_economics(ledger.clone());
2463
2464        let envelope = remote_publish_envelope(
2465            "node-remote",
2466            "run-remote-failed",
2467            "gene-remote",
2468            "capsule-remote",
2469            "mutation-remote",
2470            "failure-signal",
2471            "FAILED.md",
2472            "# from remote",
2473        );
2474        evo.import_remote_envelope(&envelope).unwrap();
2475
2476        let decision = evo
2477            .replay_or_fallback(replay_input("failure-signal"))
2478            .await
2479            .unwrap();
2480
2481        assert!(!decision.used_capsule);
2482        assert!(decision.fallback_to_planner);
2483
2484        let signal = evo.economics_signal("node-remote").unwrap();
2485        assert_eq!(signal.available_evu, 0);
2486        assert!(signal.publish_success_rate < 0.5);
2487        assert!(signal.validator_accuracy < 0.5);
2488    }
2489}