1use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11use crate::proof::ProofState;
12
13#[derive(
15 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
16)]
17#[serde(rename_all = "snake_case")]
18pub enum RuntimeMode {
19 Unknown,
21 Dev,
23 RemoteUnsigned,
28 LocalUnsigned,
30 SignedLocalLedger,
32 ExternallyAnchored,
34 AuthorityGrade,
37}
38
39impl RuntimeMode {
40 #[must_use]
42 pub const fn claim_ceiling(self) -> ClaimCeiling {
43 match self {
44 Self::Unknown => ClaimCeiling::DevOnly,
45 Self::Dev => ClaimCeiling::DevOnly,
46 Self::RemoteUnsigned => ClaimCeiling::LocalUnsigned,
47 Self::LocalUnsigned => ClaimCeiling::LocalUnsigned,
48 Self::SignedLocalLedger => ClaimCeiling::SignedLocalLedger,
49 Self::ExternallyAnchored => ClaimCeiling::ExternallyAnchored,
50 Self::AuthorityGrade => ClaimCeiling::AuthorityGrade,
51 }
52 }
53}
54
55#[derive(
57 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
58)]
59#[serde(rename_all = "snake_case")]
60pub enum AuthorityClass {
61 Untrusted,
63 Derived,
65 Observed,
67 Verified,
69 Operator,
71}
72
73#[derive(
78 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
79)]
80#[serde(rename_all = "snake_case")]
81pub enum ClaimProofState {
82 Unknown,
84 Broken,
86 Partial,
88 FullChainVerified,
90}
91
92impl ClaimProofState {
93 #[must_use]
95 pub const fn claim_ceiling(self) -> ClaimCeiling {
96 match self {
97 Self::Unknown | Self::Broken => ClaimCeiling::DevOnly,
98 Self::Partial => ClaimCeiling::LocalUnsigned,
99 Self::FullChainVerified => ClaimCeiling::AuthorityGrade,
100 }
101 }
102}
103
104impl From<ProofState> for ClaimProofState {
105 fn from(value: ProofState) -> Self {
106 match value {
107 ProofState::FullChainVerified => Self::FullChainVerified,
108 ProofState::Partial => Self::Partial,
109 ProofState::Broken => Self::Broken,
110 }
111 }
112}
113
114impl AuthorityClass {
115 #[must_use]
117 pub const fn claim_ceiling(self) -> ClaimCeiling {
118 match self {
119 Self::Untrusted => ClaimCeiling::DevOnly,
120 Self::Derived | Self::Observed => ClaimCeiling::LocalUnsigned,
121 Self::Verified => ClaimCeiling::SignedLocalLedger,
122 Self::Operator => ClaimCeiling::AuthorityGrade,
123 }
124 }
125
126 #[must_use]
128 pub fn weakest<I>(classes: I) -> Option<Self>
129 where
130 I: IntoIterator<Item = Self>,
131 {
132 classes.into_iter().min()
133 }
134}
135
136#[derive(
138 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
139)]
140#[serde(rename_all = "snake_case")]
141pub enum ClaimCeiling {
142 DevOnly,
144 LocalUnsigned,
146 SignedLocalLedger,
148 ExternallyAnchored,
150 AuthorityGrade,
152}
153
154impl ClaimCeiling {
155 #[must_use]
157 pub fn weakest<I>(ceilings: I) -> Option<Self>
158 where
159 I: IntoIterator<Item = Self>,
160 {
161 ceilings.into_iter().min()
162 }
163
164 #[must_use]
166 pub fn mix_to_weakest(self, other: Self) -> Self {
167 self.min(other)
168 }
169}
170
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
177pub struct ReportableClaim {
178 claim: String,
179 runtime_mode: RuntimeMode,
180 authority_class: AuthorityClass,
181 proof_state: ClaimProofState,
182 requested_ceiling: ClaimCeiling,
183 effective_ceiling: ClaimCeiling,
184 downgrade_reasons: Vec<String>,
185}
186
187impl ReportableClaim {
188 #[must_use]
191 pub fn new(
192 claim: impl Into<String>,
193 runtime_mode: RuntimeMode,
194 authority_class: AuthorityClass,
195 proof_state: ClaimProofState,
196 requested_ceiling: ClaimCeiling,
197 ) -> Self {
198 let mut downgrade_reasons = Vec::new();
199 let effective_ceiling = effective_ceiling(
200 runtime_mode,
201 authority_class,
202 proof_state,
203 requested_ceiling,
204 );
205
206 if effective_ceiling < requested_ceiling {
207 downgrade_reasons.push(format!(
208 "requested ceiling {requested_ceiling:?} downgraded to {effective_ceiling:?}"
209 ));
210 }
211 if proof_state != ClaimProofState::FullChainVerified {
212 downgrade_reasons.push(format!(
213 "proof state {proof_state:?} limits authority claims"
214 ));
215 }
216
217 Self {
218 claim: claim.into(),
219 runtime_mode,
220 authority_class,
221 proof_state,
222 requested_ceiling,
223 effective_ceiling,
224 downgrade_reasons,
225 }
226 }
227
228 #[must_use]
230 pub fn claim(&self) -> &str {
231 &self.claim
232 }
233
234 #[must_use]
236 pub const fn runtime_mode(&self) -> RuntimeMode {
237 self.runtime_mode
238 }
239
240 #[must_use]
242 pub const fn authority_class(&self) -> AuthorityClass {
243 self.authority_class
244 }
245
246 #[must_use]
248 pub const fn proof_state(&self) -> ClaimProofState {
249 self.proof_state
250 }
251
252 #[must_use]
254 pub const fn requested_ceiling(&self) -> ClaimCeiling {
255 self.requested_ceiling
256 }
257
258 #[must_use]
260 pub const fn effective_ceiling(&self) -> ClaimCeiling {
261 self.effective_ceiling
262 }
263
264 #[must_use]
266 pub fn downgrade_reasons(&self) -> &[String] {
267 &self.downgrade_reasons
268 }
269
270 #[must_use]
272 pub fn downgraded_to(mut self, ceiling: ClaimCeiling, reason: impl Into<String>) -> Self {
273 self.requested_ceiling = self.requested_ceiling.min(ceiling);
274 self.effective_ceiling = effective_ceiling(
275 self.runtime_mode,
276 self.authority_class,
277 self.proof_state,
278 self.requested_ceiling,
279 );
280 self.downgrade_reasons.push(reason.into());
281 self
282 }
283
284 #[must_use]
287 pub fn mix_to_weakest(mut self, other: &Self, claim: impl Into<String>) -> Self {
288 self.claim = claim.into();
289 self.runtime_mode = self.runtime_mode.min(other.runtime_mode);
290 self.authority_class = self.authority_class.min(other.authority_class);
291 self.proof_state = self.proof_state.min(other.proof_state);
292 self.requested_ceiling = self.requested_ceiling.min(other.requested_ceiling);
293 self.effective_ceiling = self.effective_ceiling.min(other.effective_ceiling);
294 self.downgrade_reasons
295 .extend(other.downgrade_reasons.iter().cloned());
296 self.downgrade_reasons
297 .push("mixed claim downgraded to weakest contributing claim".into());
298 self
299 }
300}
301
302#[must_use]
304pub fn effective_ceiling(
305 runtime_mode: RuntimeMode,
306 authority_class: AuthorityClass,
307 proof_state: ClaimProofState,
308 requested_ceiling: ClaimCeiling,
309) -> ClaimCeiling {
310 ClaimCeiling::weakest([
311 runtime_mode.claim_ceiling(),
312 authority_class.claim_ceiling(),
313 proof_state.claim_ceiling(),
314 requested_ceiling,
315 ])
316 .expect("fixed-size ceiling set is non-empty")
317}
318
319#[must_use]
321pub fn mix_authority_to_weakest<I>(classes: I) -> Option<AuthorityClass>
322where
323 I: IntoIterator<Item = AuthorityClass>,
324{
325 AuthorityClass::weakest(classes)
326}
327
328#[must_use]
330pub fn mix_claims_to_weakest<I>(ceilings: I) -> Option<ClaimCeiling>
331where
332 I: IntoIterator<Item = ClaimCeiling>,
333{
334 ClaimCeiling::weakest(ceilings)
335}
336
337#[must_use]
339pub fn mix_reportable_claims_to_weakest<'a, I>(claims: I) -> Option<ClaimCeiling>
340where
341 I: IntoIterator<Item = &'a ReportableClaim>,
342{
343 claims
344 .into_iter()
345 .map(ReportableClaim::effective_ceiling)
346 .min()
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 #[test]
354 fn runtime_mode_caps_claim_ceiling() {
355 assert_eq!(RuntimeMode::Unknown.claim_ceiling(), ClaimCeiling::DevOnly);
356 assert_eq!(RuntimeMode::Dev.claim_ceiling(), ClaimCeiling::DevOnly);
357 assert_eq!(
358 RuntimeMode::RemoteUnsigned.claim_ceiling(),
359 ClaimCeiling::LocalUnsigned
360 );
361 assert_eq!(
362 RuntimeMode::LocalUnsigned.claim_ceiling(),
363 ClaimCeiling::LocalUnsigned
364 );
365 assert_eq!(
366 RuntimeMode::SignedLocalLedger.claim_ceiling(),
367 ClaimCeiling::SignedLocalLedger
368 );
369 assert_eq!(
370 RuntimeMode::ExternallyAnchored.claim_ceiling(),
371 ClaimCeiling::ExternallyAnchored
372 );
373 assert_eq!(
374 RuntimeMode::AuthorityGrade.claim_ceiling(),
375 ClaimCeiling::AuthorityGrade
376 );
377 }
378
379 #[test]
380 fn authority_class_caps_claim_ceiling() {
381 assert_eq!(
382 AuthorityClass::Untrusted.claim_ceiling(),
383 ClaimCeiling::DevOnly
384 );
385 assert_eq!(
386 AuthorityClass::Derived.claim_ceiling(),
387 ClaimCeiling::LocalUnsigned
388 );
389 assert_eq!(
390 AuthorityClass::Observed.claim_ceiling(),
391 ClaimCeiling::LocalUnsigned
392 );
393 assert_eq!(
394 AuthorityClass::Verified.claim_ceiling(),
395 ClaimCeiling::SignedLocalLedger
396 );
397 assert_eq!(
398 AuthorityClass::Operator.claim_ceiling(),
399 ClaimCeiling::AuthorityGrade
400 );
401 }
402
403 #[test]
404 fn reportable_claim_clamps_to_weakest_signal() {
405 let claim = ReportableClaim::new(
406 "phase 2 mechanics verified",
407 RuntimeMode::LocalUnsigned,
408 AuthorityClass::Operator,
409 ClaimProofState::FullChainVerified,
410 ClaimCeiling::AuthorityGrade,
411 );
412
413 assert_eq!(claim.effective_ceiling(), ClaimCeiling::LocalUnsigned);
414 assert!(!claim.downgrade_reasons().is_empty());
415 }
416
417 #[test]
418 fn verified_source_cannot_lift_dev_runtime() {
419 let claim = ReportableClaim::new(
420 "trusted run history",
421 RuntimeMode::Dev,
422 AuthorityClass::Verified,
423 ClaimProofState::FullChainVerified,
424 ClaimCeiling::SignedLocalLedger,
425 );
426
427 assert_eq!(claim.effective_ceiling(), ClaimCeiling::DevOnly);
428 }
429
430 #[test]
431 fn proof_state_caps_claim_ceiling() {
432 let partial = ReportableClaim::new(
433 "operator action observed",
434 RuntimeMode::AuthorityGrade,
435 AuthorityClass::Operator,
436 ClaimProofState::Partial,
437 ClaimCeiling::AuthorityGrade,
438 );
439 let broken = ReportableClaim::new(
440 "trusted run history",
441 RuntimeMode::AuthorityGrade,
442 AuthorityClass::Operator,
443 ClaimProofState::Broken,
444 ClaimCeiling::AuthorityGrade,
445 );
446 let unknown = ReportableClaim::new(
447 "export-ready evidence",
448 RuntimeMode::AuthorityGrade,
449 AuthorityClass::Operator,
450 ClaimProofState::Unknown,
451 ClaimCeiling::AuthorityGrade,
452 );
453
454 assert_eq!(partial.effective_ceiling(), ClaimCeiling::LocalUnsigned);
455 assert_eq!(broken.effective_ceiling(), ClaimCeiling::DevOnly);
456 assert_eq!(unknown.effective_ceiling(), ClaimCeiling::DevOnly);
457 }
458
459 #[test]
460 fn mixed_claims_use_weakest_effective_ceiling() {
461 let strong = ReportableClaim::new(
462 "anchored ledger tip",
463 RuntimeMode::ExternallyAnchored,
464 AuthorityClass::Operator,
465 ClaimProofState::FullChainVerified,
466 ClaimCeiling::ExternallyAnchored,
467 );
468 let weak = ReportableClaim::new(
469 "development ledger append",
470 RuntimeMode::LocalUnsigned,
471 AuthorityClass::Observed,
472 ClaimProofState::Partial,
473 ClaimCeiling::AuthorityGrade,
474 );
475
476 assert_eq!(
477 mix_reportable_claims_to_weakest([&strong, &weak]),
478 Some(ClaimCeiling::LocalUnsigned)
479 );
480
481 let mixed = strong.mix_to_weakest(&weak, "combined claim");
482 assert_eq!(mixed.effective_ceiling(), ClaimCeiling::LocalUnsigned);
483 }
484
485 #[test]
486 fn runtime_mode_wire_strings_are_stable() {
487 assert_eq!(
488 serde_json::to_value(RuntimeMode::Unknown).unwrap(),
489 serde_json::json!("unknown")
490 );
491 assert_eq!(
492 serde_json::to_value(RuntimeMode::Dev).unwrap(),
493 serde_json::json!("dev")
494 );
495 assert_eq!(
496 serde_json::to_value(RuntimeMode::RemoteUnsigned).unwrap(),
497 serde_json::json!("remote_unsigned")
498 );
499 assert_eq!(
500 serde_json::to_value(RuntimeMode::LocalUnsigned).unwrap(),
501 serde_json::json!("local_unsigned")
502 );
503 assert_eq!(
504 serde_json::to_value(RuntimeMode::SignedLocalLedger).unwrap(),
505 serde_json::json!("signed_local_ledger")
506 );
507 assert_eq!(
508 serde_json::to_value(RuntimeMode::ExternallyAnchored).unwrap(),
509 serde_json::json!("externally_anchored")
510 );
511 assert_eq!(
512 serde_json::to_value(RuntimeMode::AuthorityGrade).unwrap(),
513 serde_json::json!("authority_grade")
514 );
515 }
516}