Skip to main content

lean_ctx_protocol/
outcome.rs

1//! Accepted-outcome contract.
2
3use crate::common::{
4    ExtensionsV1, OutcomeId, PlanId, ReceiptId, TaskId, ValidationError,
5    deserialize_optional_milliunit, deserialize_schema_version, validate_milliunit,
6    validate_schema_version, validate_unique_strings,
7};
8use crate::evidence::EvidenceRefV1;
9use serde::{Deserialize, Serialize};
10
11/// Tri-state acceptance prevents an absent observation from being treated as a rejection.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum AcceptanceState {
15    Accepted,
16    Rejected,
17    Unknown,
18}
19
20/// State of an individual completion or quality signal.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum SignalState {
24    Passed,
25    Failed,
26    Unknown,
27    NotRun,
28}
29
30/// Verification signals attached to an accepted-outcome observation.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(deny_unknown_fields)]
33pub struct OutcomeSignalsV1 {
34    pub build: Option<SignalState>,
35    pub tests: Option<SignalState>,
36    pub lint: Option<SignalState>,
37    pub typecheck: Option<SignalState>,
38    pub completion: Option<SignalState>,
39    pub pr: Option<SignalState>,
40    pub correction: Option<SignalState>,
41    pub rollback: Option<SignalState>,
42    pub retry: Option<SignalState>,
43}
44
45/// Canonical outcome observation used for acceptance and efficiency accounting.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct AcceptedOutcomeV1 {
48    #[serde(deserialize_with = "deserialize_schema_version")]
49    pub schema_version: u32,
50    pub outcome_id: OutcomeId,
51    pub task_id: TaskId,
52    pub accepted: AcceptanceState,
53    #[serde(
54        default,
55        skip_serializing_if = "Option::is_none",
56        deserialize_with = "deserialize_optional_milliunit"
57    )]
58    pub quality_score_milli: Option<u16>,
59    pub signals: OutcomeSignalsV1,
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub contract_ref: Option<String>,
62    pub evidence_refs: Vec<EvidenceRefV1>,
63    pub observed_at: String,
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub plan_id: Option<PlanId>,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub receipt_id: Option<ReceiptId>,
68    #[serde(default, skip_serializing_if = "Vec::is_empty")]
69    pub decision_refs: Vec<String>,
70    #[serde(default, flatten)]
71    pub extensions: ExtensionsV1,
72}
73
74const OUTCOME_RESERVED_FIELDS: &[&str] = &[
75    "schema_version",
76    "outcome_id",
77    "task_id",
78    "accepted",
79    "quality_score_milli",
80    "signals",
81    "contract_ref",
82    "evidence_refs",
83    "observed_at",
84    "plan_id",
85    "receipt_id",
86    "decision_refs",
87];
88
89impl AcceptedOutcomeV1 {
90    /// Validate invariants that also apply to values constructed in Rust.
91    pub fn validate(&self) -> Result<(), ValidationError> {
92        self.extensions.validate_reserved(OUTCOME_RESERVED_FIELDS)?;
93        validate_schema_version(self.schema_version)?;
94        if let Some(value) = self.quality_score_milli {
95            validate_milliunit(value, "quality_score_milli")?;
96        }
97        validate_unique_strings(&self.decision_refs, "decision_refs")?;
98        if self.evidence_refs.len() > crate::MAX_PROTOCOL_ITEMS {
99            return Err(ValidationError::new("evidence_refs exceeds item limit"));
100        }
101        if let Some(contract_ref) = &self.contract_ref {
102            crate::validate_bounded_string(contract_ref, "contract_ref")?;
103        }
104        for evidence in &self.evidence_refs {
105            evidence.validate()?;
106        }
107        if self.accepted != AcceptanceState::Unknown && self.evidence_refs.is_empty() {
108            return Err(ValidationError::new(
109                "accepted or rejected outcome requires evidence",
110            ));
111        }
112        Ok(())
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    fn id<T>(value: &str) -> T
121    where
122        T: TryFrom<String>,
123        <T as TryFrom<String>>::Error: std::fmt::Debug,
124    {
125        T::try_from(value.to_owned()).expect("identifier should be valid")
126    }
127
128    #[test]
129    fn serialization_round_trip() {
130        let outcome = AcceptedOutcomeV1 {
131            schema_version: 1,
132            outcome_id: id("outcome-1"),
133            task_id: id("task-1"),
134            accepted: AcceptanceState::Accepted,
135            quality_score_milli: Some(950),
136            signals: OutcomeSignalsV1 {
137                build: Some(SignalState::Passed),
138                tests: Some(SignalState::Passed),
139                lint: Some(SignalState::Passed),
140                typecheck: Some(SignalState::Passed),
141                completion: Some(SignalState::Passed),
142                pr: Some(SignalState::NotRun),
143                correction: Some(SignalState::NotRun),
144                rollback: Some(SignalState::NotRun),
145                retry: Some(SignalState::Failed),
146            },
147            contract_ref: Some("contract:outcome".to_owned()),
148            evidence_refs: vec![EvidenceRefV1 {
149                schema_version: Some(1),
150                kind: crate::EvidenceKind::QualityMeasurement,
151                uri: "urn:evidence:outcome-1".to_owned(),
152                digest: "a".repeat(64),
153                signature_status: crate::SignatureStatus::NotSigned,
154                media_type: Some("application/json".to_owned()),
155                extensions: Default::default(),
156            }],
157            observed_at: "2026-08-09T12:00:00Z".to_owned(),
158            plan_id: Some(id("plan-1")),
159            receipt_id: Some(id("receipt-1")),
160            decision_refs: vec!["decision:acceptance".to_owned()],
161            extensions: Default::default(),
162        };
163        let json = serde_json::to_string(&outcome).expect("outcome should serialize");
164        let decoded: AcceptedOutcomeV1 =
165            serde_json::from_str(&json).expect("outcome should deserialize");
166        assert_eq!(outcome, decoded);
167        outcome
168            .validate()
169            .expect("outcome should satisfy invariants");
170    }
171}