Skip to main content

verification_adjudication/
lib.rs

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    /// Rejects promotion decisions whose promotability and rollback vocabulary drift apart.
170    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    /// Rejects refutation decisions whose boolean and refutation vocabulary disagree.
186    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    /// Rejects rollback plans whose required flag conflicts with their rollback geometry.
207    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}