1use chio_core_types::canonical::{canonical_json_bytes, canonical_json_bytes_from_str};
2use chio_core_types::capability::scope::MonetaryAmount;
3use chio_core_types::crypto::{sha256_hex, PublicKey};
4use serde::de::DeserializeOwned;
5use serde::Serialize;
6
7mod contract;
8mod delivery;
9mod evidence;
10mod predicate;
11mod verdict;
12
13pub use contract::*;
14pub use delivery::*;
15pub use evidence::*;
16pub use predicate::*;
17pub use verdict::*;
18
19pub const OUTCOME_ARTIFACT_SCHEMAS: &[(&str, &str)] = &[
20 (
21 "request.schema.json",
22 chio_core_types::capability::governance::VERIFIED_OUTCOME_REQUEST_SCHEMA,
23 ),
24 ("predicate.schema.json", OUTCOME_PREDICATE_SCHEMA),
25 ("pricing.schema.json", OUTCOME_PRICING_SCHEMA),
26 ("sla.schema.json", OUTCOME_SLA_SCHEMA),
27 ("eligibility.schema.json", OUTCOME_ELIGIBILITY_SCHEMA),
28 (
29 "delivery-checkpoint.schema.json",
30 OUTCOME_DELIVERY_CHECKPOINT_SCHEMA,
31 ),
32 (
33 "delivery-acknowledgement.schema.json",
34 OUTCOME_DELIVERY_ACKNOWLEDGEMENT_SCHEMA,
35 ),
36 (
37 "delivery-nonacceptance.schema.json",
38 OUTCOME_DELIVERY_NONACCEPTANCE_SCHEMA,
39 ),
40 (
41 "output-provenance.schema.json",
42 OUTCOME_OUTPUT_PROVENANCE_SCHEMA,
43 ),
44 (
45 "contractual-zero.schema.json",
46 OUTCOME_CONTRACTUAL_ZERO_SCHEMA,
47 ),
48 ("verdict.schema.json", OUTCOME_VERDICT_SCHEMA),
49];
50
51const MAX_TEXT_CHARS: usize = 2_048;
52const I_JSON_MAX_SAFE_INTEGER: u64 = (1_u64 << 53) - 1;
53
54#[derive(Debug, thiserror::Error, PartialEq, Eq)]
55pub enum OutcomeError {
56 #[error("invalid outcome field `{0}`")]
57 InvalidField(&'static str),
58 #[error("outcome artifact binding does not match")]
59 BindingMismatch,
60 #[error("outcome artifact authority verification failed")]
61 AuthorityVerification,
62 #[error("outcome artifact is not current")]
63 NotCurrent,
64 #[error("illegal outcome lifecycle transition")]
65 IllegalTransition,
66 #[error("outcome arithmetic overflow")]
67 ArithmeticOverflow,
68 #[error("outcome JSON is invalid: {0}")]
69 InvalidJson(String),
70 #[error("outcome artifact is not canonical: {0}")]
71 Canonicalization(String),
72}
73
74#[derive(Debug, Clone)]
75pub struct OutcomeSignerTrustV1 {
76 principal_id: String,
77 key: PublicKey,
78 key_epoch: u64,
79 max_lifetime_ms: u64,
80}
81
82impl OutcomeSignerTrustV1 {
83 pub fn new(
84 principal_id: String,
85 key: PublicKey,
86 key_epoch: u64,
87 max_lifetime_ms: u64,
88 ) -> Result<Self, OutcomeError> {
89 validate_text("trusted_principal_id", &principal_id)?;
90 validate_time("trusted_key_epoch", key_epoch)?;
91 validate_time("max_lifetime_ms", max_lifetime_ms)?;
92 Ok(Self {
93 principal_id,
94 key,
95 key_epoch,
96 max_lifetime_ms,
97 })
98 }
99
100 #[must_use]
101 pub fn principal_id(&self) -> &str {
102 &self.principal_id
103 }
104
105 #[must_use]
106 pub const fn key(&self) -> &PublicKey {
107 &self.key
108 }
109
110 #[must_use]
111 pub const fn key_epoch(&self) -> u64 {
112 self.key_epoch
113 }
114
115 #[must_use]
116 pub const fn max_lifetime_ms(&self) -> u64 {
117 self.max_lifetime_ms
118 }
119}
120
121pub fn canonical_outcome_bytes(value: &impl Serialize) -> Result<Vec<u8>, OutcomeError> {
122 let encoded = canonical_json_bytes(value)
123 .map_err(|error| OutcomeError::Canonicalization(error.to_string()))?;
124 let input = std::str::from_utf8(&encoded)
125 .map_err(|error| OutcomeError::Canonicalization(error.to_string()))?;
126 canonical_json_bytes_from_str(input)
127 .map_err(|error| OutcomeError::Canonicalization(error.to_string()))
128}
129
130pub fn load_canonical_outcome_json<T>(bytes: &[u8]) -> Result<T, OutcomeError>
131where
132 T: DeserializeOwned,
133{
134 let input =
135 std::str::from_utf8(bytes).map_err(|error| OutcomeError::InvalidJson(error.to_string()))?;
136 let canonical = canonical_json_bytes_from_str(input)
137 .map_err(|error| OutcomeError::Canonicalization(error.to_string()))?;
138 if canonical.as_slice() != bytes {
139 return Err(OutcomeError::Canonicalization(
140 "input bytes differ from RFC 8785 form".to_owned(),
141 ));
142 }
143 let value = serde_json::from_slice(bytes)
144 .map_err(|error| OutcomeError::InvalidJson(error.to_string()))?;
145 Ok(value)
146}
147
148pub(super) fn domain_digest(domain: &[u8], value: &impl Serialize) -> Result<String, OutcomeError> {
149 let bytes = canonical_outcome_bytes(value)?;
150 let mut preimage = Vec::with_capacity(domain.len() + bytes.len());
151 preimage.extend_from_slice(domain);
152 preimage.extend_from_slice(&bytes);
153 Ok(sha256_hex(&preimage))
154}
155
156pub(super) fn domain_digest_without_field(
157 domain: &[u8],
158 value: &impl Serialize,
159 field: &'static str,
160) -> Result<String, OutcomeError> {
161 let mut value = serde_json::to_value(value)
162 .map_err(|error| OutcomeError::Canonicalization(error.to_string()))?;
163 let object = value
164 .as_object_mut()
165 .ok_or(OutcomeError::InvalidField("artifact_body"))?;
166 if object.remove(field).is_none() {
167 return Err(OutcomeError::InvalidField(field));
168 }
169 domain_digest(domain, &value)
170}
171
172pub(super) fn envelope_digest(value: &impl Serialize) -> Result<String, OutcomeError> {
173 canonical_outcome_bytes(value).map(|bytes| sha256_hex(&bytes))
174}
175
176pub(super) fn validate_text(field: &'static str, value: &str) -> Result<(), OutcomeError> {
177 if value.is_empty()
178 || value.trim() != value
179 || value.chars().count() > MAX_TEXT_CHARS
180 || value.chars().any(char::is_control)
181 {
182 Err(OutcomeError::InvalidField(field))
183 } else {
184 Ok(())
185 }
186}
187
188pub(super) fn validate_digest(field: &'static str, value: &str) -> Result<(), OutcomeError> {
189 if value.len() == 64
190 && value
191 .bytes()
192 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
193 {
194 Ok(())
195 } else {
196 Err(OutcomeError::InvalidField(field))
197 }
198}
199
200pub(super) fn validate_time(field: &'static str, value: u64) -> Result<(), OutcomeError> {
201 if value == 0 || value > I_JSON_MAX_SAFE_INTEGER {
202 Err(OutcomeError::InvalidField(field))
203 } else {
204 Ok(())
205 }
206}
207
208pub(super) fn validate_window(issued_at: u64, expires_at: u64) -> Result<(), OutcomeError> {
209 validate_time("issued_at_unix_ms", issued_at)?;
210 validate_time("expires_at_unix_ms", expires_at)?;
211 if expires_at <= issued_at {
212 return Err(OutcomeError::InvalidField("validity_window"));
213 }
214 Ok(())
215}
216
217pub(super) fn validate_current_window(
218 issued_at: u64,
219 expires_at: u64,
220 max_lifetime_ms: u64,
221 trusted_now_unix_ms: u64,
222) -> Result<(), OutcomeError> {
223 validate_window(issued_at, expires_at)?;
224 let lifetime = expires_at
225 .checked_sub(issued_at)
226 .ok_or(OutcomeError::NotCurrent)?;
227 if lifetime > max_lifetime_ms
228 || trusted_now_unix_ms < issued_at
229 || trusted_now_unix_ms >= expires_at
230 {
231 return Err(OutcomeError::NotCurrent);
232 }
233 Ok(())
234}
235
236pub(super) fn validate_money(
237 amount: &MonetaryAmount,
238 allow_zero: bool,
239) -> Result<(), OutcomeError> {
240 if (!allow_zero && amount.units == 0)
241 || amount.units > I_JSON_MAX_SAFE_INTEGER
242 || amount.currency.len() != 3
243 || !amount
244 .currency
245 .bytes()
246 .all(|byte| byte.is_ascii_uppercase())
247 {
248 return Err(OutcomeError::InvalidField("amount"));
249 }
250 Ok(())
251}