1use 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#[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#[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#[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#[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 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}