Skip to main content

deepstrike_core/
evolution.rs

1//! 0.2.70 Evolution Runtime contracts.
2//!
3//! This module is storage-neutral.  Artifact bytes and ledger persistence remain host-owned; the
4//! core owns canonical bytes, content-addressed identities, and fail-closed validation of the
5//! proposal → evaluation → promotion → activation graph.
6
7use std::collections::{HashMap, HashSet};
8
9use serde::{Deserialize, Serialize};
10use sha2::{Digest as ShaDigest, Sha256};
11use thiserror::Error;
12
13use crate::context::execution::ContextExecutionInput;
14
15pub const EVOLUTION_SCHEMA: &str = "evolution/v1";
16pub const EVOLUTION_REPORT_SCHEMA: &str = "evolution-report/v1";
17
18/// Validate one host-owned evolution bundle through the canonical JSON bridge.
19///
20/// The bridge is deliberately thin: hosts own artifact/evidence storage, while this function
21/// remains the only semantic implementation of E1–E8 for language bindings.
22pub fn validate_evolution_json(request: &str) -> Result<String, String> {
23    let bundle: EvolutionBundle = serde_json::from_str(request)
24        .map_err(|error| format!("invalid evolution bundle: {error}"))?;
25    serde_json::to_string(&validate_evolution(&bundle))
26        .map_err(|error| format!("could not encode evolution report: {error}"))
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
30#[serde(try_from = "String", into = "String")]
31pub struct ContentDigest(String);
32
33impl ContentDigest {
34    pub fn parse(value: impl Into<String>) -> Result<Self, EvolutionError> {
35        let value = value.into();
36        let valid = value.len() == 71
37            && value.starts_with("sha256:")
38            && value[7..].bytes().all(|byte| byte.is_ascii_hexdigit());
39        if valid {
40            Ok(Self(value.to_ascii_lowercase()))
41        } else {
42            Err(EvolutionError::InvalidDigest(value))
43        }
44    }
45
46    pub fn from_bytes(bytes: &[u8]) -> Self {
47        let mut hasher = Sha256::new();
48        hasher.update(bytes);
49        Self(format!("sha256:{:x}", hasher.finalize()))
50    }
51
52    pub fn as_str(&self) -> &str {
53        &self.0
54    }
55}
56
57impl TryFrom<String> for ContentDigest {
58    type Error = EvolutionError;
59
60    fn try_from(value: String) -> Result<Self, Self::Error> {
61        Self::parse(value)
62    }
63}
64
65impl From<ContentDigest> for String {
66    fn from(value: ContentDigest) -> Self {
67        value.0
68    }
69}
70
71/// The artifact identity a kernel operation is bound to at genesis.
72///
73/// Artifact bytes and promotion records stay host-owned. The kernel receives only their
74/// content-addressed identities, which makes the operation's execution set explicit and
75/// replayable without turning the kernel into an artifact store.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(deny_unknown_fields)]
78pub struct ArtifactSetBinding {
79    pub artifact_set_digest: ContentDigest,
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub promotion_decision_digest: Option<ContentDigest>,
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub lineage_root_digest: Option<ContentDigest>,
84}
85
86impl ArtifactSetBinding {
87    pub fn new(artifact_set_digest: ContentDigest) -> Self {
88        Self {
89            artifact_set_digest,
90            promotion_decision_digest: None,
91            lineage_root_digest: None,
92        }
93    }
94}
95
96impl std::fmt::Display for ContentDigest {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        f.write_str(&self.0)
99    }
100}
101
102#[derive(Debug, Error, Clone, PartialEq, Eq)]
103pub enum EvolutionError {
104    #[error("invalid content digest: {0}")]
105    InvalidDigest(String),
106    #[error("canonical evolution object could not be serialized: {0}")]
107    Serialization(String),
108    #[error("duplicate artifact reference: {0}")]
109    DuplicateArtifact(ContentDigest),
110    #[error("artifact set must contain at least one artifact")]
111    EmptyArtifactSet,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
115#[serde(rename_all = "snake_case")]
116pub enum ArtifactKind {
117    Runtime,
118    Skill,
119    Prompt,
120    Policy,
121    Toolset,
122    Bundle,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
126pub struct ArtifactRef {
127    pub kind: ArtifactKind,
128    pub digest: ContentDigest,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132pub struct ArtifactManifest {
133    pub kind: ArtifactKind,
134    pub payload_digest: ContentDigest,
135    pub parents: Vec<ContentDigest>,
136    pub toolchain_digest: ContentDigest,
137    pub scope: String,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141pub struct ArtifactVersion {
142    pub digest: ContentDigest,
143    pub manifest: ArtifactManifest,
144}
145
146impl ArtifactVersion {
147    pub fn from_manifest(manifest: ArtifactManifest) -> Result<Self, EvolutionError> {
148        let digest = canonical_digest(&manifest)?;
149        Ok(Self { digest, manifest })
150    }
151
152    pub fn reference(&self) -> ArtifactRef {
153        ArtifactRef {
154            kind: self.manifest.kind,
155            digest: self.digest.clone(),
156        }
157    }
158
159    pub fn verify_digest(&self) -> Result<(), EvolutionError> {
160        let expected = canonical_digest(&self.manifest)?;
161        if expected != self.digest {
162            return Err(EvolutionError::InvalidDigest(self.digest.to_string()));
163        }
164        Ok(())
165    }
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct ArtifactSet {
170    pub digest: ContentDigest,
171    pub artifacts: Vec<ArtifactRef>,
172}
173
174impl ArtifactSet {
175    pub fn new(mut artifacts: Vec<ArtifactRef>) -> Result<Self, EvolutionError> {
176        if artifacts.is_empty() {
177            return Err(EvolutionError::EmptyArtifactSet);
178        }
179        artifacts.sort_by(|left, right| {
180            left.kind
181                .cmp(&right.kind)
182                .then_with(|| left.digest.cmp(&right.digest))
183        });
184        for pair in artifacts.windows(2) {
185            if pair[0].digest == pair[1].digest {
186                return Err(EvolutionError::DuplicateArtifact(pair[0].digest.clone()));
187            }
188        }
189        let digest = canonical_digest(&artifacts)?;
190        Ok(Self { digest, artifacts })
191    }
192
193    pub fn verify_digest(&self) -> Result<(), EvolutionError> {
194        let expected = canonical_digest(&self.artifacts)?;
195        if expected != self.digest {
196            return Err(EvolutionError::InvalidDigest(self.digest.to_string()));
197        }
198        Ok(())
199    }
200}
201
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203pub struct EvolutionProposal {
204    pub digest: ContentDigest,
205    pub base_artifact_set: ContentDigest,
206    pub candidate_artifact_set: ContentDigest,
207    pub objective: String,
208    pub change_manifest: ContentDigest,
209    pub proposer: String,
210    pub constraints: Vec<String>,
211}
212
213impl EvolutionProposal {
214    pub fn new(
215        base_artifact_set: ContentDigest,
216        candidate_artifact_set: ContentDigest,
217        objective: impl Into<String>,
218        change_manifest: ContentDigest,
219        proposer: impl Into<String>,
220        constraints: Vec<String>,
221    ) -> Result<Self, EvolutionError> {
222        let unsigned = Self {
223            digest: ContentDigest::from_bytes(b"placeholder"),
224            base_artifact_set,
225            candidate_artifact_set,
226            objective: objective.into(),
227            change_manifest,
228            proposer: proposer.into(),
229            constraints,
230        };
231        let digest = canonical_digest(&ProposalBody::from(&unsigned))?;
232        Ok(Self { digest, ..unsigned })
233    }
234
235    pub fn verify_digest(&self) -> Result<(), EvolutionError> {
236        let expected = canonical_digest(&ProposalBody::from(self))?;
237        if expected != self.digest {
238            return Err(EvolutionError::InvalidDigest(self.digest.to_string()));
239        }
240        Ok(())
241    }
242}
243
244#[derive(Debug, Serialize)]
245struct ProposalBody<'a> {
246    base_artifact_set: &'a ContentDigest,
247    candidate_artifact_set: &'a ContentDigest,
248    objective: &'a str,
249    change_manifest: &'a ContentDigest,
250    proposer: &'a str,
251    constraints: &'a [String],
252}
253
254impl<'a> From<&'a EvolutionProposal> for ProposalBody<'a> {
255    fn from(value: &'a EvolutionProposal) -> Self {
256        Self {
257            base_artifact_set: &value.base_artifact_set,
258            candidate_artifact_set: &value.candidate_artifact_set,
259            objective: &value.objective,
260            change_manifest: &value.change_manifest,
261            proposer: &value.proposer,
262            constraints: &value.constraints,
263        }
264    }
265}
266
267/// The verifiable context projection used by one evaluated operation.
268///
269/// Context bytes remain host-owned evidence. The evaluation ledger binds only the canonical
270/// execution-input, state, plan, rendered projection, route, and measurement digests so a
271/// replay can prove which context was evaluated without making the kernel a context store.
272#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
273pub struct EvaluationContextBinding {
274    pub digest: ContentDigest,
275    pub operation_id: String,
276    pub execution_input: ContentDigest,
277    pub context_state: ContentDigest,
278    pub context_policy: ContentDigest,
279    pub context_plan: ContentDigest,
280    pub rendered_snapshot: ContentDigest,
281    pub prompt_measurement: ContentDigest,
282    pub provider_route: ContentDigest,
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub cache_prefix: Option<ContentDigest>,
285}
286
287impl EvaluationContextBinding {
288    /// Project a verified ContextExecutionInput into the evaluation evidence graph.
289    pub fn from_execution_input(input: &ContextExecutionInput) -> Self {
290        let unsigned = Self {
291            digest: ContentDigest::from_bytes(b"placeholder"),
292            operation_id: input.operation_id.clone(),
293            execution_input: input.input_digest.clone(),
294            context_state: input.state_digest.clone(),
295            context_policy: input.policy_digest.clone(),
296            context_plan: input.plan_digest.clone(),
297            rendered_snapshot: input.rendered_snapshot.clone(),
298            prompt_measurement: input.prompt_measurement.clone(),
299            provider_route: input.provider_route.clone(),
300            cache_prefix: input
301                .cache_prefix
302                .as_ref()
303                .map(|boundary| boundary.digest.clone()),
304        };
305        let digest = canonical_digest(&EvaluationContextBindingBody::from(&unsigned))
306            .expect("context input projection is canonical");
307        Self { digest, ..unsigned }
308    }
309
310    pub fn new(
311        operation_id: impl Into<String>,
312        execution_input: ContentDigest,
313        context_state: ContentDigest,
314        context_policy: ContentDigest,
315        context_plan: ContentDigest,
316        rendered_snapshot: ContentDigest,
317        prompt_measurement: ContentDigest,
318        provider_route: ContentDigest,
319        cache_prefix: Option<ContentDigest>,
320    ) -> Result<Self, EvolutionError> {
321        let unsigned = Self {
322            digest: ContentDigest::from_bytes(b"placeholder"),
323            operation_id: operation_id.into(),
324            execution_input,
325            context_state,
326            context_policy,
327            context_plan,
328            rendered_snapshot,
329            prompt_measurement,
330            provider_route,
331            cache_prefix,
332        };
333        let digest = canonical_digest(&EvaluationContextBindingBody::from(&unsigned))?;
334        Ok(Self { digest, ..unsigned })
335    }
336
337    pub fn verify_digest(&self) -> Result<(), EvolutionError> {
338        let expected = canonical_digest(&EvaluationContextBindingBody::from(self))?;
339        if expected != self.digest {
340            return Err(EvolutionError::InvalidDigest(self.digest.to_string()));
341        }
342        Ok(())
343    }
344}
345
346#[derive(Debug, Serialize)]
347struct EvaluationContextBindingBody<'a> {
348    operation_id: &'a str,
349    execution_input: &'a ContentDigest,
350    context_state: &'a ContentDigest,
351    context_policy: &'a ContentDigest,
352    context_plan: &'a ContentDigest,
353    rendered_snapshot: &'a ContentDigest,
354    prompt_measurement: &'a ContentDigest,
355    provider_route: &'a ContentDigest,
356    cache_prefix: Option<&'a ContentDigest>,
357}
358
359impl<'a> From<&'a EvaluationContextBinding> for EvaluationContextBindingBody<'a> {
360    fn from(value: &'a EvaluationContextBinding) -> Self {
361        Self {
362            operation_id: &value.operation_id,
363            execution_input: &value.execution_input,
364            context_state: &value.context_state,
365            context_policy: &value.context_policy,
366            context_plan: &value.context_plan,
367            rendered_snapshot: &value.rendered_snapshot,
368            prompt_measurement: &value.prompt_measurement,
369            provider_route: &value.provider_route,
370            cache_prefix: value.cache_prefix.as_ref(),
371        }
372    }
373}
374
375#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
376pub struct EvaluationRun {
377    pub digest: ContentDigest,
378    pub proposal: ContentDigest,
379    pub baseline_artifact_set: ContentDigest,
380    pub candidate_artifact_set: ContentDigest,
381    pub evaluator: ContentDigest,
382    pub dataset: ContentDigest,
383    pub operation_ids: Vec<String>,
384    pub contexts: Vec<EvaluationContextBinding>,
385    pub evidence_refs: Vec<ContentDigest>,
386}
387
388impl EvaluationRun {
389    pub fn verify_digest(&self) -> Result<(), EvolutionError> {
390        let expected = canonical_digest(&EvaluationRunBody::from(self))?;
391        if expected != self.digest {
392            return Err(EvolutionError::InvalidDigest(self.digest.to_string()));
393        }
394        Ok(())
395    }
396}
397
398#[derive(Debug, Serialize)]
399struct EvaluationRunBody<'a> {
400    proposal: &'a ContentDigest,
401    baseline_artifact_set: &'a ContentDigest,
402    candidate_artifact_set: &'a ContentDigest,
403    evaluator: &'a ContentDigest,
404    dataset: &'a ContentDigest,
405    operation_ids: &'a [String],
406    contexts: &'a [EvaluationContextBinding],
407    evidence_refs: &'a [ContentDigest],
408}
409
410impl<'a> From<&'a EvaluationRun> for EvaluationRunBody<'a> {
411    fn from(value: &'a EvaluationRun) -> Self {
412        Self {
413            proposal: &value.proposal,
414            baseline_artifact_set: &value.baseline_artifact_set,
415            candidate_artifact_set: &value.candidate_artifact_set,
416            evaluator: &value.evaluator,
417            dataset: &value.dataset,
418            operation_ids: &value.operation_ids,
419            contexts: &value.contexts,
420            evidence_refs: &value.evidence_refs,
421        }
422    }
423}
424
425#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
426pub struct EvaluationMetric {
427    pub name: String,
428    pub baseline: String,
429    pub candidate: String,
430    pub improved: bool,
431}
432
433#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
434pub struct EvaluationGate {
435    pub name: String,
436    pub required: bool,
437    pub passed: bool,
438}
439
440#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
441pub struct EvaluationFact {
442    pub digest: ContentDigest,
443    pub evaluation: ContentDigest,
444    pub metrics: Vec<EvaluationMetric>,
445    pub gates: Vec<EvaluationGate>,
446    pub replay_passed: bool,
447}
448
449impl EvaluationFact {
450    pub fn verify_digest(&self) -> Result<(), EvolutionError> {
451        let expected = canonical_digest(&EvaluationFactBody::from(self))?;
452        if expected != self.digest {
453            return Err(EvolutionError::InvalidDigest(self.digest.to_string()));
454        }
455        Ok(())
456    }
457}
458
459#[derive(Debug, Serialize)]
460struct EvaluationFactBody<'a> {
461    evaluation: &'a ContentDigest,
462    metrics: &'a [EvaluationMetric],
463    gates: &'a [EvaluationGate],
464    replay_passed: bool,
465}
466
467impl<'a> From<&'a EvaluationFact> for EvaluationFactBody<'a> {
468    fn from(value: &'a EvaluationFact) -> Self {
469        Self {
470            evaluation: &value.evaluation,
471            metrics: &value.metrics,
472            gates: &value.gates,
473            replay_passed: value.replay_passed,
474        }
475    }
476}
477
478#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
479#[serde(rename_all = "snake_case")]
480pub enum PromotionOutcome {
481    Promote,
482    Reject,
483    Hold,
484}
485
486#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
487pub struct PromotionDecision {
488    pub digest: ContentDigest,
489    pub proposal: ContentDigest,
490    pub evaluation_facts: Vec<ContentDigest>,
491    pub policy: ContentDigest,
492    pub outcome: PromotionOutcome,
493    pub selected_artifact_set: ContentDigest,
494}
495
496impl PromotionDecision {
497    pub fn verify_digest(&self) -> Result<(), EvolutionError> {
498        let expected = canonical_digest(&PromotionDecisionBody::from(self))?;
499        if expected != self.digest {
500            return Err(EvolutionError::InvalidDigest(self.digest.to_string()));
501        }
502        Ok(())
503    }
504}
505
506#[derive(Debug, Serialize)]
507struct PromotionDecisionBody<'a> {
508    proposal: &'a ContentDigest,
509    evaluation_facts: &'a [ContentDigest],
510    policy: &'a ContentDigest,
511    outcome: PromotionOutcome,
512    selected_artifact_set: &'a ContentDigest,
513}
514
515impl<'a> From<&'a PromotionDecision> for PromotionDecisionBody<'a> {
516    fn from(value: &'a PromotionDecision) -> Self {
517        Self {
518            proposal: &value.proposal,
519            evaluation_facts: &value.evaluation_facts,
520            policy: &value.policy,
521            outcome: value.outcome,
522            selected_artifact_set: &value.selected_artifact_set,
523        }
524    }
525}
526
527#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
528pub struct ActivationBinding {
529    pub digest: ContentDigest,
530    pub operation_id: String,
531    pub artifact_set: ContentDigest,
532    pub promotion_decision: ContentDigest,
533}
534
535impl ActivationBinding {
536    pub fn verify_digest(&self) -> Result<(), EvolutionError> {
537        let expected = canonical_digest(&(
538            &self.operation_id,
539            &self.artifact_set,
540            &self.promotion_decision,
541        ))?;
542        if expected != self.digest {
543            return Err(EvolutionError::InvalidDigest(self.digest.to_string()));
544        }
545        Ok(())
546    }
547}
548
549#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
550pub struct EvolutionBundle {
551    pub artifacts: Vec<ArtifactVersion>,
552    pub artifact_sets: Vec<ArtifactSet>,
553    pub proposals: Vec<EvolutionProposal>,
554    pub evaluations: Vec<EvaluationRun>,
555    pub facts: Vec<EvaluationFact>,
556    pub decisions: Vec<PromotionDecision>,
557    pub activations: Vec<ActivationBinding>,
558}
559
560#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
561#[serde(rename_all = "snake_case")]
562pub enum EvolutionVerdict {
563    Pass,
564    Fail,
565    Unavailable,
566}
567
568#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
569pub struct EvolutionViolation {
570    pub code: String,
571    pub detail: String,
572}
573
574#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
575pub struct EvolutionReport {
576    pub schema: String,
577    pub verdict: EvolutionVerdict,
578    pub violations: Vec<EvolutionViolation>,
579}
580
581pub fn validate_evolution(bundle: &EvolutionBundle) -> EvolutionReport {
582    let mut violations = Vec::new();
583    let artifacts: HashMap<_, _> = bundle
584        .artifacts
585        .iter()
586        .map(|artifact| (artifact.digest.clone(), artifact))
587        .collect();
588    let sets: HashMap<_, _> = bundle
589        .artifact_sets
590        .iter()
591        .map(|set| (set.digest.clone(), set))
592        .collect();
593    let proposals: HashMap<_, _> = bundle
594        .proposals
595        .iter()
596        .map(|proposal| (proposal.digest.clone(), proposal))
597        .collect();
598    let evaluations: HashMap<_, _> = bundle
599        .evaluations
600        .iter()
601        .map(|evaluation| (evaluation.digest.clone(), evaluation))
602        .collect();
603    let facts: HashMap<_, _> = bundle
604        .facts
605        .iter()
606        .map(|fact| (fact.digest.clone(), fact))
607        .collect();
608    let decisions: HashMap<_, _> = bundle
609        .decisions
610        .iter()
611        .map(|decision| (decision.digest.clone(), decision))
612        .collect();
613
614    for artifact in &bundle.artifacts {
615        if artifact.verify_digest().is_err() {
616            violation(
617                &mut violations,
618                "E1",
619                format!("artifact {} digest mismatch", artifact.digest),
620            );
621        }
622        for parent in &artifact.manifest.parents {
623            if !artifacts.contains_key(parent) {
624                violation(
625                    &mut violations,
626                    "E2",
627                    format!("artifact {} has missing parent {parent}", artifact.digest),
628                );
629            }
630        }
631    }
632    for artifact in &bundle.artifacts {
633        if has_artifact_cycle(
634            artifact.digest.clone(),
635            &artifacts,
636            &mut HashSet::new(),
637            &mut HashSet::new(),
638        ) {
639            violation(
640                &mut violations,
641                "E2",
642                format!("artifact lineage contains a cycle at {}", artifact.digest),
643            );
644        }
645    }
646    for set in &bundle.artifact_sets {
647        if set.verify_digest().is_err() {
648            violation(
649                &mut violations,
650                "E1",
651                format!("artifact set {} digest mismatch", set.digest),
652            );
653        }
654        for reference in &set.artifacts {
655            if !artifacts.contains_key(&reference.digest) {
656                violation(
657                    &mut violations,
658                    "E2",
659                    format!(
660                        "artifact set {} references missing {}",
661                        set.digest, reference.digest
662                    ),
663                );
664            }
665        }
666    }
667    for proposal in &bundle.proposals {
668        if proposal.verify_digest().is_err() {
669            violation(
670                &mut violations,
671                "E1",
672                format!("proposal {} digest mismatch", proposal.digest),
673            );
674        }
675        if !sets.contains_key(&proposal.base_artifact_set)
676            || !sets.contains_key(&proposal.candidate_artifact_set)
677        {
678            violation(
679                &mut violations,
680                "E3",
681                format!(
682                    "proposal {} references an unknown artifact set",
683                    proposal.digest
684                ),
685            );
686        }
687    }
688    for evaluation in &bundle.evaluations {
689        if evaluation.verify_digest().is_err() {
690            violation(
691                &mut violations,
692                "E1",
693                format!("evaluation {} digest mismatch", evaluation.digest),
694            );
695        }
696        match proposals.get(&evaluation.proposal) {
697            Some(proposal)
698                if proposal.base_artifact_set == evaluation.baseline_artifact_set
699                    && proposal.candidate_artifact_set == evaluation.candidate_artifact_set => {}
700            Some(_) => violation(
701                &mut violations,
702                "E4",
703                format!(
704                    "evaluation {} does not match its proposal",
705                    evaluation.digest
706                ),
707            ),
708            None => violation(
709                &mut violations,
710                "E4",
711                format!(
712                    "evaluation {} references an unknown proposal",
713                    evaluation.digest
714                ),
715            ),
716        }
717        if evaluation.operation_ids.is_empty()
718            || evaluation.contexts.is_empty()
719            || evaluation.evidence_refs.is_empty()
720        {
721            violation(
722                &mut violations,
723                "E5",
724                format!("evaluation {} has incomplete evidence", evaluation.digest),
725            );
726        }
727        let operation_ids: HashSet<_> = evaluation.operation_ids.iter().collect();
728        let context_operation_ids: HashSet<_> = evaluation
729            .contexts
730            .iter()
731            .map(|context| &context.operation_id)
732            .collect();
733        let mut context_inputs = HashSet::new();
734        for context in &evaluation.contexts {
735            if !context_inputs.insert((&context.operation_id, &context.execution_input)) {
736                violation(
737                    &mut violations,
738                    "E5",
739                    format!(
740                        "evaluation {} repeats context input {} for operation {}",
741                        evaluation.digest, context.execution_input, context.operation_id,
742                    ),
743                );
744            }
745            if context.verify_digest().is_err() {
746                violation(
747                    &mut violations,
748                    "E1",
749                    format!("evaluation context {} digest mismatch", context.digest),
750                );
751            }
752            if !operation_ids.contains(&context.operation_id) {
753                violation(
754                    &mut violations,
755                    "E5",
756                    format!(
757                        "evaluation context {} references an unknown operation {}",
758                        context.digest, context.operation_id
759                    ),
760                );
761            }
762            for reference in [
763                &context.execution_input,
764                &context.context_state,
765                &context.context_policy,
766                &context.context_plan,
767                &context.rendered_snapshot,
768                &context.prompt_measurement,
769                &context.provider_route,
770            ] {
771                if !evaluation.evidence_refs.contains(reference) {
772                    violation(
773                        &mut violations,
774                        "E5",
775                        format!(
776                            "evaluation context {} is missing evidence reference {}",
777                            context.digest, reference
778                        ),
779                    );
780                }
781            }
782            if let Some(cache_prefix) = &context.cache_prefix {
783                if !evaluation.evidence_refs.contains(cache_prefix) {
784                    violation(
785                        &mut violations,
786                        "E5",
787                        format!(
788                            "evaluation context {} is missing cache evidence reference {}",
789                            context.digest, cache_prefix
790                        ),
791                    );
792                }
793            }
794        }
795        if operation_ids != context_operation_ids {
796            violation(
797                &mut violations,
798                "E5",
799                format!(
800                    "evaluation {} does not bind every operation to a context",
801                    evaluation.digest
802                ),
803            );
804        }
805    }
806    for fact in &bundle.facts {
807        if fact.verify_digest().is_err() {
808            violation(
809                &mut violations,
810                "E1",
811                format!("fact {} digest mismatch", fact.digest),
812            );
813        }
814        if !evaluations.contains_key(&fact.evaluation) {
815            violation(
816                &mut violations,
817                "E5",
818                format!("fact {} references an unknown evaluation", fact.digest),
819            );
820        }
821        if fact
822            .metrics
823            .iter()
824            .any(|metric| metric.baseline.is_empty() || metric.candidate.is_empty())
825        {
826            violation(
827                &mut violations,
828                "E6",
829                format!("fact {} contains an incomplete metric", fact.digest),
830            );
831        }
832    }
833    for decision in &bundle.decisions {
834        if decision.verify_digest().is_err() {
835            violation(
836                &mut violations,
837                "E1",
838                format!("decision {} digest mismatch", decision.digest),
839            );
840        }
841        let Some(proposal) = proposals.get(&decision.proposal) else {
842            violation(
843                &mut violations,
844                "E7",
845                format!(
846                    "decision {} references an unknown proposal",
847                    decision.digest
848                ),
849            );
850            continue;
851        };
852        if decision.evaluation_facts.is_empty() {
853            violation(
854                &mut violations,
855                "E7",
856                format!("decision {} has no evaluation facts", decision.digest),
857            );
858        }
859        let referenced_facts: Vec<_> = decision
860            .evaluation_facts
861            .iter()
862            .filter_map(|digest| facts.get(digest))
863            .collect();
864        if referenced_facts.len() != decision.evaluation_facts.len() {
865            violation(
866                &mut violations,
867                "E7",
868                format!("decision {} references missing facts", decision.digest),
869            );
870        }
871        if decision.outcome == PromotionOutcome::Promote
872            && (referenced_facts.is_empty()
873                || referenced_facts.iter().any(|fact| {
874                    !fact.replay_passed
875                        || fact.gates.iter().any(|gate| gate.required && !gate.passed)
876                }))
877        {
878            violation(
879                &mut violations,
880                "E7",
881                format!(
882                    "decision {} promotes without passing required gates",
883                    decision.digest
884                ),
885            );
886        }
887        if decision.selected_artifact_set != proposal.candidate_artifact_set {
888            violation(
889                &mut violations,
890                "E7",
891                format!(
892                    "decision {} selects a non-candidate artifact set",
893                    decision.digest
894                ),
895            );
896        }
897    }
898    for activation in &bundle.activations {
899        if activation.verify_digest().is_err() {
900            violation(
901                &mut violations,
902                "E1",
903                format!("activation {} digest mismatch", activation.digest),
904            );
905        }
906        match decisions.get(&activation.promotion_decision) {
907            Some(decision)
908                if decision.outcome == PromotionOutcome::Promote
909                    && decision.selected_artifact_set == activation.artifact_set => {}
910            Some(_) => violation(
911                &mut violations,
912                "E8",
913                format!(
914                    "activation {} is not backed by a promotion decision",
915                    activation.digest
916                ),
917            ),
918            None => violation(
919                &mut violations,
920                "E8",
921                format!(
922                    "activation {} references an unknown decision",
923                    activation.digest
924                ),
925            ),
926        }
927    }
928
929    EvolutionReport {
930        schema: EVOLUTION_REPORT_SCHEMA.to_string(),
931        verdict: if violations.is_empty() {
932            EvolutionVerdict::Pass
933        } else {
934            EvolutionVerdict::Fail
935        },
936        violations,
937    }
938}
939
940fn canonical_digest<T: Serialize>(value: &T) -> Result<ContentDigest, EvolutionError> {
941    let bytes = serde_json::to_vec(value)
942        .map_err(|error| EvolutionError::Serialization(error.to_string()))?;
943    Ok(ContentDigest::from_bytes(&bytes))
944}
945
946fn violation(violations: &mut Vec<EvolutionViolation>, code: &str, detail: String) {
947    violations.push(EvolutionViolation {
948        code: code.to_string(),
949        detail,
950    });
951}
952
953fn has_artifact_cycle(
954    current: ContentDigest,
955    artifacts: &HashMap<ContentDigest, &ArtifactVersion>,
956    visiting: &mut HashSet<ContentDigest>,
957    visited: &mut HashSet<ContentDigest>,
958) -> bool {
959    if visited.contains(&current) {
960        return false;
961    }
962    if !visiting.insert(current.clone()) {
963        return true;
964    }
965    let cycle = artifacts.get(&current).is_some_and(|artifact| {
966        artifact
967            .manifest
968            .parents
969            .iter()
970            .any(|parent| has_artifact_cycle(parent.clone(), artifacts, visiting, visited))
971    });
972    visiting.remove(&current);
973    visited.insert(current);
974    cycle
975}
976
977#[cfg(test)]
978mod tests {
979    use super::*;
980
981    fn digest(label: &str) -> ContentDigest {
982        ContentDigest::from_bytes(label.as_bytes())
983    }
984
985    fn artifact(kind: ArtifactKind, label: &str, parents: Vec<ContentDigest>) -> ArtifactVersion {
986        ArtifactVersion::from_manifest(ArtifactManifest {
987            kind,
988            payload_digest: digest(&format!("payload:{label}")),
989            parents,
990            toolchain_digest: digest("toolchain:v4"),
991            scope: "runtime".to_string(),
992        })
993        .unwrap()
994    }
995
996    #[test]
997    fn artifact_and_set_digests_are_canonical() {
998        let runtime = artifact(ArtifactKind::Runtime, "r1", Vec::new());
999        let skill = artifact(ArtifactKind::Skill, "s1", Vec::new());
1000        let set = ArtifactSet::new(vec![skill.reference(), runtime.reference()]).unwrap();
1001
1002        assert_eq!(set.artifacts[0].kind, ArtifactKind::Runtime);
1003        assert!(runtime.verify_digest().is_ok());
1004        assert!(set.verify_digest().is_ok());
1005        assert_eq!(
1006            ContentDigest::parse(set.digest.to_string()).unwrap(),
1007            set.digest
1008        );
1009    }
1010
1011    #[test]
1012    fn content_digest_json_decode_rejects_non_sha256_values() {
1013        let invalid = serde_json::from_value::<ContentDigest>(serde_json::json!("legacy-id"));
1014        assert!(invalid.is_err());
1015    }
1016
1017    #[test]
1018    fn tampering_is_fail_closed() {
1019        let runtime = artifact(ArtifactKind::Runtime, "r1", Vec::new());
1020        let mut tampered = runtime.clone();
1021        tampered.manifest.scope = "changed".to_string();
1022        let report = validate_evolution(&EvolutionBundle {
1023            artifacts: vec![tampered],
1024            ..Default::default()
1025        });
1026
1027        assert_eq!(report.verdict, EvolutionVerdict::Fail);
1028        assert!(
1029            report
1030                .violations
1031                .iter()
1032                .any(|violation| violation.code == "E1")
1033        );
1034    }
1035
1036    #[test]
1037    fn proposal_evaluation_promotion_and_activation_form_one_valid_graph() {
1038        let base = artifact(ArtifactKind::Runtime, "base", Vec::new());
1039        let candidate = artifact(
1040            ArtifactKind::Runtime,
1041            "candidate",
1042            vec![base.digest.clone()],
1043        );
1044        let base_set = ArtifactSet::new(vec![base.reference()]).unwrap();
1045        let candidate_set = ArtifactSet::new(vec![candidate.reference()]).unwrap();
1046        let proposal = EvolutionProposal::new(
1047            base_set.digest.clone(),
1048            candidate_set.digest.clone(),
1049            "improve recovery",
1050            digest("change-manifest"),
1051            "agent:root",
1052            vec!["replay must pass".to_string()],
1053        )
1054        .unwrap();
1055        let evaluator = digest("evaluator");
1056        let dataset = digest("dataset");
1057        let evidence = digest("evidence");
1058        let operation_ids = vec!["eval-op".to_string()];
1059        let execution_input = digest("execution-input");
1060        let context_state = digest("context-state");
1061        let context_policy = digest("context-policy");
1062        let context_plan = digest("context-plan");
1063        let rendered_snapshot = digest("rendered-snapshot");
1064        let prompt_measurement = digest("prompt-measurement");
1065        let provider_route = digest("provider-route");
1066        let context = EvaluationContextBinding::new(
1067            "eval-op",
1068            execution_input,
1069            context_state,
1070            context_policy,
1071            context_plan,
1072            rendered_snapshot,
1073            prompt_measurement,
1074            provider_route,
1075            None,
1076        )
1077        .unwrap();
1078        let contexts = vec![context];
1079        let evidence_refs = vec![
1080            evidence.clone(),
1081            contexts[0].execution_input.clone(),
1082            contexts[0].context_state.clone(),
1083            contexts[0].context_policy.clone(),
1084            contexts[0].context_plan.clone(),
1085            contexts[0].rendered_snapshot.clone(),
1086            contexts[0].prompt_measurement.clone(),
1087            contexts[0].provider_route.clone(),
1088        ];
1089        let run_body = EvaluationRunBody {
1090            proposal: &proposal.digest,
1091            baseline_artifact_set: &base_set.digest,
1092            candidate_artifact_set: &candidate_set.digest,
1093            evaluator: &evaluator,
1094            dataset: &dataset,
1095            operation_ids: &operation_ids,
1096            contexts: &contexts,
1097            evidence_refs: &evidence_refs,
1098        };
1099        let evaluation = EvaluationRun {
1100            digest: canonical_digest(&run_body).unwrap(),
1101            proposal: proposal.digest.clone(),
1102            baseline_artifact_set: base_set.digest.clone(),
1103            candidate_artifact_set: candidate_set.digest.clone(),
1104            evaluator,
1105            dataset,
1106            operation_ids,
1107            contexts,
1108            evidence_refs,
1109        };
1110        let metrics = vec![EvaluationMetric {
1111            name: "quality".to_string(),
1112            baseline: "0.8".to_string(),
1113            candidate: "0.9".to_string(),
1114            improved: true,
1115        }];
1116        let gates = vec![EvaluationGate {
1117            name: "replay".to_string(),
1118            required: true,
1119            passed: true,
1120        }];
1121        let fact_body = EvaluationFactBody {
1122            evaluation: &evaluation.digest,
1123            metrics: &metrics,
1124            gates: &gates,
1125            replay_passed: true,
1126        };
1127        let fact = EvaluationFact {
1128            digest: canonical_digest(&fact_body).unwrap(),
1129            evaluation: evaluation.digest.clone(),
1130            metrics,
1131            gates,
1132            replay_passed: true,
1133        };
1134        let policy = digest("policy");
1135        let evaluation_facts = vec![fact.digest.clone()];
1136        let decision_body = PromotionDecisionBody {
1137            proposal: &proposal.digest,
1138            evaluation_facts: &evaluation_facts,
1139            policy: &policy,
1140            outcome: PromotionOutcome::Promote,
1141            selected_artifact_set: &candidate_set.digest,
1142        };
1143        let decision = PromotionDecision {
1144            digest: canonical_digest(&decision_body).unwrap(),
1145            proposal: proposal.digest.clone(),
1146            evaluation_facts,
1147            policy,
1148            outcome: PromotionOutcome::Promote,
1149            selected_artifact_set: candidate_set.digest.clone(),
1150        };
1151        let activation_body = (
1152            &"next-op".to_string(),
1153            &candidate_set.digest,
1154            &decision.digest,
1155        );
1156        let activation = ActivationBinding {
1157            digest: canonical_digest(&activation_body).unwrap(),
1158            operation_id: "next-op".to_string(),
1159            artifact_set: candidate_set.digest.clone(),
1160            promotion_decision: decision.digest.clone(),
1161        };
1162        let mut bundle = EvolutionBundle {
1163            artifacts: vec![base, candidate],
1164            artifact_sets: vec![base_set, candidate_set],
1165            proposals: vec![proposal],
1166            evaluations: vec![evaluation],
1167            facts: vec![fact],
1168            decisions: vec![decision],
1169            activations: vec![activation],
1170        };
1171        let report = validate_evolution(&bundle);
1172
1173        assert_eq!(report.verdict, EvolutionVerdict::Pass);
1174        assert!(report.violations.is_empty());
1175
1176        // An operation may contain several provider attempts, but repeating the same
1177        // execution input must not inflate its context evidence coverage.
1178        bundle.facts.clear();
1179        bundle.decisions.clear();
1180        bundle.activations.clear();
1181        let evaluation = &mut bundle.evaluations[0];
1182        let mut second = evaluation.contexts[0].clone();
1183        second.execution_input = digest("second-attempt");
1184        second.digest = canonical_digest(&EvaluationContextBindingBody::from(&second)).unwrap();
1185        evaluation
1186            .evidence_refs
1187            .push(second.execution_input.clone());
1188        evaluation.contexts.push(second);
1189        evaluation.digest = canonical_digest(&EvaluationRunBody::from(&*evaluation)).unwrap();
1190        assert_eq!(validate_evolution(&bundle).verdict, EvolutionVerdict::Pass);
1191        let evaluation = &mut bundle.evaluations[0];
1192        evaluation.contexts.push(evaluation.contexts[0].clone());
1193        evaluation.digest = canonical_digest(&EvaluationRunBody::from(&*evaluation)).unwrap();
1194        assert!(
1195            validate_evolution(&bundle)
1196                .violations
1197                .iter()
1198                .any(|violation| violation.code == "E5"
1199                    && violation.detail.contains("repeats context input"))
1200        );
1201    }
1202
1203    #[test]
1204    fn evaluation_rejects_tampered_context_or_missing_measurement_evidence() {
1205        let context_policy = digest("context-policy");
1206        let execution_input = digest("execution-input");
1207        let context_state = digest("context-state");
1208        let context_plan = digest("context-plan");
1209        let rendered_snapshot = digest("rendered-snapshot");
1210        let prompt_measurement = digest("prompt-measurement");
1211        let provider_route = digest("provider-route");
1212        let mut context = EvaluationContextBinding::new(
1213            "eval-op",
1214            execution_input,
1215            context_state,
1216            context_policy,
1217            context_plan,
1218            rendered_snapshot,
1219            prompt_measurement,
1220            provider_route,
1221            None,
1222        )
1223        .unwrap();
1224        context.rendered_snapshot = digest("tampered-render");
1225        let evaluation = EvaluationRun {
1226            digest: digest("evaluation"),
1227            proposal: digest("proposal"),
1228            baseline_artifact_set: digest("base-set"),
1229            candidate_artifact_set: digest("candidate-set"),
1230            evaluator: digest("evaluator"),
1231            dataset: digest("dataset"),
1232            operation_ids: vec!["eval-op".to_string()],
1233            contexts: vec![context],
1234            evidence_refs: vec![digest("context-policy"), digest("context-state")],
1235        };
1236        let report = validate_evolution(&EvolutionBundle {
1237            evaluations: vec![evaluation],
1238            ..Default::default()
1239        });
1240
1241        assert_eq!(report.verdict, EvolutionVerdict::Fail);
1242        assert!(
1243            report
1244                .violations
1245                .iter()
1246                .any(|violation| violation.code == "E1")
1247        );
1248        assert!(
1249            report
1250                .violations
1251                .iter()
1252                .any(|violation| violation.code == "E5")
1253        );
1254    }
1255
1256    #[test]
1257    fn promotion_rejects_missing_required_gate() {
1258        let fact = EvaluationFact {
1259            digest: digest("fact"),
1260            evaluation: digest("evaluation"),
1261            metrics: Vec::new(),
1262            gates: vec![EvaluationGate {
1263                name: "safety".to_string(),
1264                required: true,
1265                passed: false,
1266            }],
1267            replay_passed: true,
1268        };
1269        let decision = PromotionDecision {
1270            digest: digest("decision"),
1271            proposal: digest("proposal"),
1272            evaluation_facts: vec![fact.digest.clone()],
1273            policy: digest("policy"),
1274            outcome: PromotionOutcome::Promote,
1275            selected_artifact_set: digest("candidate"),
1276        };
1277        let report = validate_evolution(&EvolutionBundle {
1278            facts: vec![fact],
1279            decisions: vec![decision],
1280            ..Default::default()
1281        });
1282
1283        assert_eq!(report.verdict, EvolutionVerdict::Fail);
1284        assert!(
1285            report
1286                .violations
1287                .iter()
1288                .any(|violation| violation.code == "E7")
1289        );
1290    }
1291
1292    #[test]
1293    fn json_bridge_returns_the_canonical_report() {
1294        let report = validate_evolution_json(
1295            r#"{"artifacts":[],"artifact_sets":[],"proposals":[],"evaluations":[],"facts":[],"decisions":[],"activations":[]}"#,
1296        )
1297        .expect("valid evolution bundle");
1298        let value: serde_json::Value = serde_json::from_str(&report).expect("report json");
1299        assert_eq!(value["schema"], EVOLUTION_REPORT_SCHEMA);
1300        assert_eq!(value["verdict"], "pass");
1301
1302        let error = validate_evolution_json("[]").expect_err("non-object request must fail");
1303        assert!(error.contains("invalid evolution bundle"));
1304    }
1305}