1#![cfg_attr(test, allow(clippy::expect_used))]
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use stack_ids::{
5 EffectAdjudicationReceiptId, EffectExecutionReceiptId, EffectObservationBundleId,
6 IncidentCaseId, PolicyDecisionId, PromotionDecisionId, RefutationDecisionId,
7 ReleaseReadinessDecisionId, ReleaseRollbackDecisionId, RollbackPlanId,
8};
9use verification_calibration::CalibrationSnapshot;
10use verification_control::{
11 interpret_promotion_eligibility, interpret_rollback_requirement, CheckPlan, ControlReceipt,
12 PromotionClass, ReversibilityClass, TerminalDisposition, VerificationAttempt, VerificationCase,
13};
14use verification_policy::{PolicyDecision, V25CitationContext};
15
16pub mod v14;
17pub use v14::{
18 RollbackDecisionV1, RolloutDecisionV1, ROLLBACK_DECISION_V1_SCHEMA, ROLLOUT_DECISION_V1_SCHEMA,
19};
20
21pub const VERIFICATION_DISPOSITION_V1_SCHEMA: &str = "verification_disposition_v1";
22pub const PROMOTION_DECISION_V1_SCHEMA: &str = "promotion_decision_v1";
23pub const REFUTATION_DECISION_V1_SCHEMA: &str = "refutation_decision_v1";
24pub const ROLLBACK_PLAN_V1_SCHEMA: &str = "rollback_plan_v1";
25pub const EFFECT_ADJUDICATION_RECEIPT_V1_SCHEMA: &str = "effect_adjudication_receipt_v1";
26pub const RELEASE_ROLLBACK_DECISION_V1_SCHEMA: &str = "release_rollback_decision_v1";
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
29pub struct EffectAdjudicationReceiptV1 {
30 pub schema_version: String,
31 pub effect_adjudication_receipt_id: EffectAdjudicationReceiptId,
32 pub effect_execution_receipt_id: EffectExecutionReceiptId,
33 pub observation_bundle_id: EffectObservationBundleId,
34 pub policy_decision_id: PolicyDecisionId,
35 #[serde(flatten)]
36 pub citation: V25CitationContext,
37 pub adjudicated_state: String,
38 pub generated_at: String,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
42pub struct ReleaseRollbackDecisionV1 {
43 pub schema_version: String,
44 pub release_rollback_decision_id: ReleaseRollbackDecisionId,
45 pub release_readiness_decision_id: ReleaseReadinessDecisionId,
46 pub incident_case_id: IncidentCaseId,
47 pub policy_decision_id: PolicyDecisionId,
48 #[serde(flatten)]
49 pub citation: V25CitationContext,
50 pub rollback_required: bool,
51 pub generated_at: String,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
55#[serde(rename_all = "snake_case")]
56pub enum VerificationDisposition {
57 EligibleForPromotion,
58 AdvisoryOnly,
59 BlockedByPolicy,
60 PendingApproval,
61 Refuted,
62 DegradedNoPromotion,
63 BudgetExhausted,
64 Failed,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
68pub struct PromotionDecision {
69 pub schema_version: String,
70 pub decision_id: PromotionDecisionId,
71 pub case_id: stack_ids::VerificationCaseId,
72 pub disposition: VerificationDisposition,
73 pub promotion_class: PromotionClass,
74 pub reversibility_class: ReversibilityClass,
75 pub policy_decision_id: PolicyDecisionId,
76 #[serde(flatten)]
77 pub citation: V25CitationContext,
78 pub promotable: bool,
79 pub evidence_sufficiency_summary: String,
80 pub approval_basis: String,
81 pub rollback_scope: RollbackScopeV1,
82 #[serde(default)]
83 pub reasons: Vec<String>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub control_receipt_id: Option<stack_ids::ControlReceiptId>,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
89pub struct RefutationDecision {
90 pub schema_version: String,
91 pub decision_id: RefutationDecisionId,
92 pub case_id: stack_ids::VerificationCaseId,
93 pub disposition: VerificationDisposition,
94 pub refuted: bool,
95 pub refutation_class: RefutationClassV1,
96 pub policy_decision_id: PolicyDecisionId,
97 #[serde(flatten)]
98 pub citation: V25CitationContext,
99 #[serde(default)]
100 pub violated_constraints: Vec<String>,
101 #[serde(default)]
102 pub witness_refs: Vec<String>,
103 pub follow_up_required: bool,
104 pub reason: String,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
108pub struct RollbackPlan {
109 pub schema_version: String,
110 pub rollback_plan_id: RollbackPlanId,
111 pub case_id: stack_ids::VerificationCaseId,
112 pub policy_decision_id: PolicyDecisionId,
113 #[serde(flatten)]
114 pub citation: V25CitationContext,
115 pub required: bool,
116 pub rollback_scope: RollbackScopeV1,
117 pub invalidation_radius: InvalidationRadiusV1,
118 #[serde(default)]
119 pub affected_artifacts: Vec<String>,
120 #[serde(default)]
121 pub stale_surfaces: Vec<String>,
122 #[serde(default)]
123 pub recomputation_requirements: Vec<String>,
124 #[serde(default)]
125 pub quarantine_targets: Vec<String>,
126 #[serde(default)]
127 pub operator_actions: Vec<String>,
128 #[serde(default)]
129 pub completion_criteria: Vec<String>,
130 pub reason: String,
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
134#[serde(rename_all = "snake_case")]
135pub enum RollbackScopeV1 {
136 None,
137 LocalDerivedOnly,
138 ProjectionScope,
139 AuthorityEscalationRequired,
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
143#[serde(rename_all = "snake_case")]
144pub enum RefutationClassV1 {
145 None,
146 CausalRefuted,
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
150#[serde(rename_all = "snake_case")]
151pub enum InvalidationRadiusV1 {
152 None,
153 Local,
154 ClaimLineage,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
158pub struct AdjudicationResult {
159 pub schema_version: String,
160 pub case_id: stack_ids::VerificationCaseId,
161 pub disposition: VerificationDisposition,
162 pub terminal_disposition: TerminalDisposition,
163 pub promotion_decision: PromotionDecision,
164 pub refutation_decision: RefutationDecision,
165 pub rollback_plan: RollbackPlan,
166}
167
168impl PromotionDecision {
169 pub fn validate(&self) -> Result<(), String> {
171 if self.schema_version != PROMOTION_DECISION_V1_SCHEMA {
172 return Err(format!(
173 "expected schema_version `{PROMOTION_DECISION_V1_SCHEMA}`, found `{}`",
174 self.schema_version
175 ));
176 }
177 if self.promotable != (self.disposition == VerificationDisposition::EligibleForPromotion) {
178 return Err("promotion decision promotable flag must match disposition".into());
179 }
180 Ok(())
181 }
182}
183
184impl RefutationDecision {
185 pub fn validate(&self) -> Result<(), String> {
187 if self.schema_version != REFUTATION_DECISION_V1_SCHEMA {
188 return Err(format!(
189 "expected schema_version `{REFUTATION_DECISION_V1_SCHEMA}`, found `{}`",
190 self.schema_version
191 ));
192 }
193 match (self.refuted, self.refutation_class) {
194 (true, RefutationClassV1::CausalRefuted) | (false, RefutationClassV1::None) => Ok(()),
195 (true, RefutationClassV1::None) => {
196 Err("refuted decision cannot carry refutation_class `none`".into())
197 }
198 (false, RefutationClassV1::CausalRefuted) => {
199 Err("non-refuted decision cannot carry causal_refuted classification".into())
200 }
201 }
202 }
203}
204
205impl RollbackPlan {
206 pub fn validate(&self) -> Result<(), String> {
208 if self.schema_version != ROLLBACK_PLAN_V1_SCHEMA {
209 return Err(format!(
210 "expected schema_version `{ROLLBACK_PLAN_V1_SCHEMA}`, found `{}`",
211 self.schema_version
212 ));
213 }
214 if self.required {
215 if matches!(self.rollback_scope, RollbackScopeV1::None) {
216 return Err("required rollback plan cannot use rollback_scope `none`".into());
217 }
218 if matches!(self.invalidation_radius, InvalidationRadiusV1::None) {
219 return Err("required rollback plan cannot use invalidation_radius `none`".into());
220 }
221 } else if !matches!(self.rollback_scope, RollbackScopeV1::None)
222 || !matches!(self.invalidation_radius, InvalidationRadiusV1::None)
223 {
224 return Err("non-required rollback plan must use `none` rollback geometry".into());
225 }
226 Ok(())
227 }
228}
229
230#[allow(clippy::too_many_arguments)]
231pub fn adjudicate_case(
232 case: &VerificationCase,
233 plan: &CheckPlan,
234 attempt: &VerificationAttempt,
235 control_receipt: &ControlReceipt,
236 policy_decision: &PolicyDecision,
237 calibration_snapshot: &CalibrationSnapshot,
238 refuted: bool,
239 budget_exhausted: bool,
240 currently_promoted: bool,
241) -> AdjudicationResult {
242 let promotion_eligible = interpret_promotion_eligibility(
243 policy_decision.promotion_allowed,
244 calibration_snapshot.forces_advisory_only,
245 attempt.degraded,
246 budget_exhausted,
247 matches!(
248 attempt.state,
249 verification_control::VerificationAttemptState::Succeeded
250 ),
251 );
252
253 let (disposition, terminal_disposition, reason) =
254 if !policy_decision.method_allowed || !policy_decision.autonomy_allowed {
255 (
256 VerificationDisposition::BlockedByPolicy,
257 TerminalDisposition::Blocked,
258 "blocked by policy".to_string(),
259 )
260 } else if policy_decision.approval_required && !policy_decision.approval_satisfied {
261 (
262 VerificationDisposition::PendingApproval,
263 TerminalDisposition::Blocked,
264 "approval required".to_string(),
265 )
266 } else if refuted {
267 (
268 VerificationDisposition::Refuted,
269 TerminalDisposition::Refuted,
270 "refutation evidence prevents promotion".to_string(),
271 )
272 } else if budget_exhausted {
273 (
274 VerificationDisposition::BudgetExhausted,
275 TerminalDisposition::DegradedNoPromotion,
276 "budget exhausted".to_string(),
277 )
278 } else if attempt.degraded
279 || calibration_snapshot.forces_advisory_only
280 || !policy_decision.promotion_allowed
281 {
282 (
283 VerificationDisposition::AdvisoryOnly,
284 TerminalDisposition::DegradedNoPromotion,
285 "degraded or poorly calibrated path is advisory only".to_string(),
286 )
287 } else if promotion_eligible {
288 (
289 VerificationDisposition::EligibleForPromotion,
290 TerminalDisposition::EligibleForPromotion,
291 "all required gates satisfied".to_string(),
292 )
293 } else {
294 (
295 VerificationDisposition::Failed,
296 TerminalDisposition::Exhausted,
297 "attempt did not succeed".to_string(),
298 )
299 };
300
301 let promotion_decision = PromotionDecision {
302 schema_version: PROMOTION_DECISION_V1_SCHEMA.into(),
303 decision_id: PromotionDecisionId::generate(),
304 case_id: case.case_id.clone(),
305 policy_decision_id: policy_decision.decision_id.clone(),
306 citation: policy_decision.citation.clone(),
307 disposition,
308 promotion_class: plan.promotion_class,
309 reversibility_class: plan.reversibility_class,
310 promotable: disposition == VerificationDisposition::EligibleForPromotion,
311 evidence_sufficiency_summary: if disposition
312 == VerificationDisposition::EligibleForPromotion
313 {
314 format!(
315 "method {:?} succeeded with replayable receipt lineage",
316 plan.method
317 )
318 } else {
319 format!(
320 "promotion class {:?} was not satisfied",
321 plan.promotion_class
322 )
323 },
324 approval_basis: if policy_decision.approval_required {
325 if policy_decision.approval_satisfied {
326 "typed approval record present".into()
327 } else {
328 "required approval record absent".into()
329 }
330 } else {
331 "approval not required".into()
332 },
333 rollback_scope: match plan.promotion_class {
334 PromotionClass::P0 => RollbackScopeV1::None,
335 PromotionClass::P1 => RollbackScopeV1::LocalDerivedOnly,
336 PromotionClass::P2 => RollbackScopeV1::ProjectionScope,
337 PromotionClass::P3 => RollbackScopeV1::AuthorityEscalationRequired,
338 },
339 reasons: policy_decision.reasons.clone(),
340 control_receipt_id: Some(control_receipt.receipt_id.clone()),
341 };
342 debug_assert!(promotion_decision.validate().is_ok());
343 let refutation_decision = RefutationDecision {
344 schema_version: REFUTATION_DECISION_V1_SCHEMA.into(),
345 decision_id: RefutationDecisionId::generate(),
346 case_id: case.case_id.clone(),
347 policy_decision_id: policy_decision.decision_id.clone(),
348 citation: policy_decision.citation.clone(),
349 disposition,
350 refuted,
351 refutation_class: if refuted {
352 RefutationClassV1::CausalRefuted
353 } else {
354 RefutationClassV1::None
355 },
356 violated_constraints: if refuted {
357 vec!["promotion eligibility".into()]
358 } else {
359 Vec::new()
360 },
361 witness_refs: control_receipt
362 .details
363 .get("target_key")
364 .and_then(|value| value.as_str())
365 .map(|target| vec![format!("target:{target}")])
366 .unwrap_or_default(),
367 follow_up_required: refuted && !currently_promoted,
368 reason: if refuted {
369 "refutation witness present".into()
370 } else {
371 "no refutation witness triggered".into()
372 },
373 };
374 debug_assert!(refutation_decision.validate().is_ok());
375 let rollback_required = interpret_rollback_requirement(refuted, currently_promoted);
376 let rollback_plan = RollbackPlan {
377 schema_version: ROLLBACK_PLAN_V1_SCHEMA.into(),
378 rollback_plan_id: RollbackPlanId::generate(),
379 case_id: case.case_id.clone(),
380 policy_decision_id: policy_decision.decision_id.clone(),
381 citation: policy_decision.citation.clone(),
382 required: rollback_required,
383 rollback_scope: if rollback_required {
384 match plan.promotion_class {
385 PromotionClass::P0 | PromotionClass::P1 => RollbackScopeV1::LocalDerivedOnly,
386 PromotionClass::P2 => RollbackScopeV1::ProjectionScope,
387 PromotionClass::P3 => RollbackScopeV1::AuthorityEscalationRequired,
388 }
389 } else {
390 RollbackScopeV1::None
391 },
392 invalidation_radius: if rollback_required {
393 InvalidationRadiusV1::ClaimLineage
394 } else {
395 InvalidationRadiusV1::None
396 },
397 affected_artifacts: if rollback_required {
398 vec![case.region.target_key.clone()]
399 } else {
400 Vec::new()
401 },
402 stale_surfaces: if rollback_required {
403 vec!["semantic_memory_projection".into()]
404 } else {
405 Vec::new()
406 },
407 recomputation_requirements: if rollback_required {
408 vec!["invalidate bounded derivations".into()]
409 } else {
410 Vec::new()
411 },
412 quarantine_targets: if refuted {
413 vec![case.region.target_key.clone()]
414 } else {
415 Vec::new()
416 },
417 operator_actions: if rollback_required {
418 vec!["block automatic promotion until recomputation completes".into()]
419 } else {
420 Vec::new()
421 },
422 completion_criteria: if rollback_required {
423 vec![
424 "derived state invalidated".into(),
425 "stale surfaces marked".into(),
426 ]
427 } else {
428 Vec::new()
429 },
430 reason,
431 };
432 debug_assert!(rollback_plan.validate().is_ok());
433
434 AdjudicationResult {
435 schema_version: VERIFICATION_DISPOSITION_V1_SCHEMA.into(),
436 case_id: case.case_id.clone(),
437 disposition,
438 terminal_disposition,
439 promotion_decision,
440 refutation_decision,
441 rollback_plan,
442 }
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448 use stack_ids::{AttemptId, ScopeKey, TraceCtx, TrialId};
449 use verification_calibration::CalibrationSnapshot;
450 use verification_control::{
451 CaseRegion, CheckMethod, CheckPlan, ControlReceipt, PromotionClass, ReversibilityClass,
452 VerificationAttempt, VerificationAttemptState, VerificationCase, VerificationCaseClass,
453 };
454 use verification_policy::{
455 evaluate_policy, ApprovalRequirement, AutonomyCeiling, MethodPolicy, PolicySnapshot,
456 };
457
458 #[test]
459 fn calibration_can_force_advisory_only_disposition() {
460 let case = VerificationCase::new(
461 VerificationCaseClass::UnverifiedClaimVersion,
462 CaseRegion {
463 namespace: "demo".into(),
464 scope_key: Some(ScopeKey::namespace_only("demo")),
465 target_key: "unverified:claim-v1".into(),
466 region_id: None,
467 region_digest_id: None,
468 claim_version_id: Some(stack_ids::ClaimVersionId::new("claim-v1")),
469 as_of_recorded_at: None,
470 },
471 TraceCtx::generate(),
472 AttemptId::new("attempt-1"),
473 "2026-03-12T00:00:00Z",
474 false,
475 false,
476 );
477 let plan = CheckPlan::new(
478 case.case_id.clone(),
479 CheckMethod::ExactBoundedOracle,
480 vec!["kernel_oracle".into()],
481 PromotionClass::P2,
482 ReversibilityClass::ReversibleScoped,
483 true,
484 false,
485 false,
486 "oracle",
487 serde_json::json!({}),
488 );
489 let attempt = VerificationAttempt::completed(
490 case.case_id.clone(),
491 plan.plan_id.clone(),
492 case.attempt_id.clone(),
493 Some(TrialId::new("trial-1")),
494 VerificationAttemptState::Succeeded,
495 false,
496 false,
497 "2026-03-12T00:00:00Z",
498 "2026-03-12T00:00:01Z",
499 Some("supported".into()),
500 );
501 let control_receipt =
502 ControlReceipt::new_case_execution(&case, &plan, &attempt, true, serde_json::json!({}));
503 let policy = PolicySnapshot {
504 schema_version: verification_policy::POLICY_SNAPSHOT_V1_SCHEMA.into(),
505 policy_version: "policy-1".into(),
506 effective_from: "2026-03-01T00:00:00Z".into(),
507 effective_to: None,
508 autonomy_ceiling: AutonomyCeiling::PromotionEligible,
509 method_rules: vec![MethodPolicy {
510 method: CheckMethod::ExactBoundedOracle,
511 allowed: true,
512 max_autonomy: AutonomyCeiling::PromotionEligible,
513 approval_requirement: ApprovalRequirement::None,
514 }],
515 blocked_promotions_when_degraded: true,
516 blocked_promotions_when_budget_exhausted: true,
517 citation: verification_policy::V25CitationContext::missing(),
518 obligation_refs: verification_policy::V25ControlObligationRefs::missing(),
519 };
520 let policy_decision = evaluate_policy(&policy, &case, &plan, &[], false, false);
521 let calibration = CalibrationSnapshot::evaluate(
522 case.case_id.clone(),
523 "2026-03-12T00:00:00Z",
524 false,
525 true,
526 500_000,
527 100_000,
528 vec![],
529 );
530
531 let result = adjudicate_case(
532 &case,
533 &plan,
534 &attempt,
535 &control_receipt,
536 &policy_decision,
537 &calibration,
538 false,
539 false,
540 false,
541 );
542 assert_eq!(result.disposition, VerificationDisposition::AdvisoryOnly);
543 }
544
545 #[test]
546 fn v21_v24_adjudication_receipts_roundtrip() {
547 let effect = EffectAdjudicationReceiptV1 {
548 schema_version: EFFECT_ADJUDICATION_RECEIPT_V1_SCHEMA.into(),
549 effect_adjudication_receipt_id: stack_ids::EffectAdjudicationReceiptId::new(
550 "effect-adjudication-receipt-1",
551 ),
552 effect_execution_receipt_id: stack_ids::EffectExecutionReceiptId::new(
553 "effect-execution-receipt-1",
554 ),
555 observation_bundle_id: stack_ids::EffectObservationBundleId::new(
556 "effect-observation-bundle-1",
557 ),
558 policy_decision_id: stack_ids::PolicyDecisionId::new("policy-decision-1"),
559 citation: V25CitationContext {
560 applicability_context_id: Some(stack_ids::ApplicabilityContextId::new(
561 "applicability-context-1",
562 )),
563 profile_set_id: Some(stack_ids::ProfileSetId::new("profile-set-1")),
564 composition_receipt_id: Some(stack_ids::CompositionReceiptId::new(
565 "composition-receipt-1",
566 )),
567 effective_constitution_id: Some(stack_ids::EffectiveConstitutionId::new(
568 "effective-constitution-1",
569 )),
570 compiled_obligation_set_id: Some(stack_ids::CompiledObligationSetId::new(
571 "compiled-obligation-set-1",
572 )),
573 composition_conflict_set_id: None,
574 profile_exception_bundle_ids: vec![stack_ids::ProfileExceptionBundleId::new(
575 "profile-exception-bundle-1",
576 )],
577 },
578 adjudicated_state: "closed_successfully".into(),
579 generated_at: "2026-03-15T13:00:00Z".into(),
580 };
581 let release = ReleaseRollbackDecisionV1 {
582 schema_version: RELEASE_ROLLBACK_DECISION_V1_SCHEMA.into(),
583 release_rollback_decision_id: stack_ids::ReleaseRollbackDecisionId::new(
584 "release-rollback-decision-1",
585 ),
586 release_readiness_decision_id: stack_ids::ReleaseReadinessDecisionId::new(
587 "release-readiness-decision-1",
588 ),
589 incident_case_id: stack_ids::IncidentCaseId::new("incident-case-1"),
590 policy_decision_id: stack_ids::PolicyDecisionId::new("policy-decision-1"),
591 citation: V25CitationContext {
592 applicability_context_id: Some(stack_ids::ApplicabilityContextId::new(
593 "applicability-context-1",
594 )),
595 profile_set_id: Some(stack_ids::ProfileSetId::new("profile-set-1")),
596 composition_receipt_id: Some(stack_ids::CompositionReceiptId::new(
597 "composition-receipt-1",
598 )),
599 effective_constitution_id: Some(stack_ids::EffectiveConstitutionId::new(
600 "effective-constitution-1",
601 )),
602 compiled_obligation_set_id: Some(stack_ids::CompiledObligationSetId::new(
603 "compiled-obligation-set-1",
604 )),
605 composition_conflict_set_id: None,
606 profile_exception_bundle_ids: vec![stack_ids::ProfileExceptionBundleId::new(
607 "profile-exception-bundle-1",
608 )],
609 },
610 rollback_required: true,
611 generated_at: "2026-03-15T13:01:00Z".into(),
612 };
613
614 let json = serde_json::to_string(&(effect, release)).expect("serialize receipts");
615 let _: (EffectAdjudicationReceiptV1, ReleaseRollbackDecisionV1) =
616 serde_json::from_str(&json).expect("deserialize receipts");
617 }
618}