Skip to main content

verification_policy/
lib.rs

1#![cfg_attr(test, allow(clippy::expect_used))]
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use stack_ids::{
5    ApplicabilityContextId, ApprovalRecordId, CompiledObligationSetId, CompositionReceiptId,
6    ContinuityPolicyProfileId, DelegationPolicyProfileId, EffectPolicyProfileId,
7    EffectiveConstitutionId, PolicyDecisionId, ProfileSetId, ReleasePolicyProfileId,
8};
9use std::collections::BTreeSet;
10use verification_control::{CheckMethod, CheckPlan, PromotionClass, VerificationCase};
11
12pub mod v14;
13pub use v14::{
14    ArtifactAdmissionPolicyV1, DisclosureBudgetV1, DisclosurePolicyV1, ExperimentBudgetV1,
15    RefuterSuiteV1, ARTIFACT_ADMISSION_POLICY_V1_SCHEMA, DISCLOSURE_BUDGET_V1_SCHEMA,
16    DISCLOSURE_POLICY_V1_SCHEMA, EXPERIMENT_BUDGET_V1_SCHEMA, REFUTER_SUITE_V1_SCHEMA,
17};
18
19pub mod permit;
20pub mod profile_p1_privacy;
21pub mod profile_p2_locality;
22pub use permit::{CommitToken, ExecutionPermit, ExecutionPermitScope, PermitIssuanceError};
23pub use profile_p1_privacy::*;
24pub use profile_p2_locality::*;
25
26pub const POLICY_SNAPSHOT_V1_SCHEMA: &str = "policy_snapshot_v1";
27pub const APPROVAL_RECORD_V1_SCHEMA: &str = "approval_record_v1";
28pub const POLICY_DECISION_V1_SCHEMA: &str = "policy_decision_v1";
29pub const EFFECT_POLICY_PROFILE_V1_SCHEMA: &str = "effect_policy_profile_v1";
30pub const DELEGATION_POLICY_PROFILE_V1_SCHEMA: &str = "delegation_policy_profile_v1";
31pub const RELEASE_POLICY_PROFILE_V1_SCHEMA: &str = "release_policy_profile_v1";
32pub const CONTINUITY_POLICY_PROFILE_V1_SCHEMA: &str = "continuity_policy_profile_v1";
33
34pub use verification_control::{
35    ConstitutionalContextStatus, V25CitationContext, V25ControlObligationRefs,
36};
37
38fn require_schema_version(
39    found: &str,
40    expected: &'static str,
41    field: &'static str,
42) -> Result<(), &'static str> {
43    if found != expected {
44        return Err(field);
45    }
46    Ok(())
47}
48
49fn require_non_empty(value: &str, field: &'static str) -> Result<(), &'static str> {
50    if value.trim().is_empty() {
51        return Err(field);
52    }
53    Ok(())
54}
55
56fn require_unique<T>(values: &[T], field: &'static str) -> Result<(), &'static str>
57where
58    T: Copy + Ord,
59{
60    let mut seen = BTreeSet::new();
61    for value in values {
62        if !seen.insert(*value) {
63            return Err(field);
64        }
65    }
66    Ok(())
67}
68
69#[derive(
70    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
71)]
72#[serde(rename_all = "snake_case")]
73pub enum EffectPolicyRunModeV1 {
74    Shadow,
75    DryRun,
76    Live,
77}
78
79#[derive(
80    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
81)]
82#[serde(rename_all = "snake_case")]
83pub enum EffectPreflightCheckV1 {
84    Integrity,
85    Admission,
86    Reachability,
87    Budget,
88}
89
90#[derive(
91    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
92)]
93#[serde(rename_all = "snake_case")]
94pub enum EffectObservationClassV1 {
95    Latency,
96    ErrorBudget,
97    Compensation,
98    Monitoring,
99}
100
101#[derive(
102    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
103)]
104#[serde(rename_all = "snake_case")]
105pub enum CompensationPolicyTriggerV1 {
106    Mutation,
107    ExternalSideEffect,
108    ExternalWrite,
109}
110
111#[derive(
112    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
113)]
114#[serde(rename_all = "snake_case")]
115pub enum DelegationRoleCombinationV1 {
116    #[serde(rename = "requester+approver")]
117    RequesterApprover,
118    #[serde(rename = "commander+auditor")]
119    CommanderAuditor,
120}
121
122#[derive(
123    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
124)]
125#[serde(rename_all = "snake_case")]
126pub enum ReleaseAssuranceSectionV1 {
127    HazardSummary,
128    ControlEvidence,
129    RollbackPlan,
130}
131
132#[derive(
133    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
134)]
135#[serde(rename_all = "snake_case")]
136pub enum ReleaseMonitorClassV1 {
137    ErrorBudget,
138    LatencyP95,
139    Saturation,
140}
141
142#[derive(
143    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
144)]
145#[serde(rename_all = "snake_case")]
146pub enum ForensicFreezeSurfaceV1 {
147    AuditLog,
148    EffectLedger,
149    DeploymentConfig,
150}
151
152#[derive(
153    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
154)]
155#[serde(rename_all = "snake_case")]
156pub enum PolicySeverityV1 {
157    Sev1,
158    Sev2,
159    Sev3,
160    Sev4,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
164pub struct EffectPolicyProfileV1 {
165    pub schema_version: String,
166    pub effect_policy_profile_id: EffectPolicyProfileId,
167    #[serde(default)]
168    pub allowed_run_modes: Vec<EffectPolicyRunModeV1>,
169    #[serde(default)]
170    pub required_preflight_checks: Vec<EffectPreflightCheckV1>,
171    #[serde(default)]
172    pub required_observation_classes: Vec<EffectObservationClassV1>,
173    #[serde(default)]
174    pub requires_compensation_plan_for: Vec<CompensationPolicyTriggerV1>,
175    pub block_live_without_commit: bool,
176}
177
178impl EffectPolicyProfileV1 {
179    /// Builds an effect policy profile with typed run, preflight, observation,
180    /// and compensation requirements.
181    pub fn new(
182        effect_policy_profile_id: EffectPolicyProfileId,
183        allowed_run_modes: Vec<EffectPolicyRunModeV1>,
184        required_preflight_checks: Vec<EffectPreflightCheckV1>,
185        required_observation_classes: Vec<EffectObservationClassV1>,
186        requires_compensation_plan_for: Vec<CompensationPolicyTriggerV1>,
187        block_live_without_commit: bool,
188    ) -> Result<Self, &'static str> {
189        let value = Self {
190            schema_version: EFFECT_POLICY_PROFILE_V1_SCHEMA.to_string(),
191            effect_policy_profile_id,
192            allowed_run_modes,
193            required_preflight_checks,
194            required_observation_classes,
195            requires_compensation_plan_for,
196            block_live_without_commit,
197        };
198        value.validate()?;
199        Ok(value)
200    }
201
202    /// Validates schema identity and rejects duplicate typed policy vocab.
203    pub fn validate(&self) -> Result<(), &'static str> {
204        require_schema_version(
205            &self.schema_version,
206            EFFECT_POLICY_PROFILE_V1_SCHEMA,
207            "schema_version",
208        )?;
209        require_unique(&self.allowed_run_modes, "allowed_run_modes")?;
210        require_unique(&self.required_preflight_checks, "required_preflight_checks")?;
211        require_unique(
212            &self.required_observation_classes,
213            "required_observation_classes",
214        )?;
215        require_unique(
216            &self.requires_compensation_plan_for,
217            "requires_compensation_plan_for",
218        )?;
219        Ok(())
220    }
221}
222
223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
224pub struct DelegationPolicyProfileV1 {
225    pub schema_version: String,
226    pub delegation_policy_profile_id: DelegationPolicyProfileId,
227    pub max_delegation_depth: i64,
228    pub break_glass_requires_post_hoc_review: bool,
229    #[serde(default)]
230    pub forbidden_role_combinations: Vec<DelegationRoleCombinationV1>,
231    pub require_typed_authority_chain: bool,
232}
233
234impl DelegationPolicyProfileV1 {
235    /// Builds a delegation policy profile with typed role-combination bans.
236    pub fn new(
237        delegation_policy_profile_id: DelegationPolicyProfileId,
238        max_delegation_depth: i64,
239        break_glass_requires_post_hoc_review: bool,
240        forbidden_role_combinations: Vec<DelegationRoleCombinationV1>,
241        require_typed_authority_chain: bool,
242    ) -> Result<Self, &'static str> {
243        let value = Self {
244            schema_version: DELEGATION_POLICY_PROFILE_V1_SCHEMA.to_string(),
245            delegation_policy_profile_id,
246            max_delegation_depth,
247            break_glass_requires_post_hoc_review,
248            forbidden_role_combinations,
249            require_typed_authority_chain,
250        };
251        value.validate()?;
252        Ok(value)
253    }
254
255    /// Validates schema identity, delegation depth, and duplicate role bans.
256    pub fn validate(&self) -> Result<(), &'static str> {
257        require_schema_version(
258            &self.schema_version,
259            DELEGATION_POLICY_PROFILE_V1_SCHEMA,
260            "schema_version",
261        )?;
262        if self.max_delegation_depth < 0 {
263            return Err("max_delegation_depth");
264        }
265        require_unique(
266            &self.forbidden_role_combinations,
267            "forbidden_role_combinations",
268        )?;
269        Ok(())
270    }
271}
272
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
274pub struct ReleasePolicyProfileV1 {
275    pub schema_version: String,
276    pub release_policy_profile_id: ReleasePolicyProfileId,
277    #[serde(default)]
278    pub required_assurance_sections: Vec<ReleaseAssuranceSectionV1>,
279    #[serde(default)]
280    pub required_monitor_classes: Vec<ReleaseMonitorClassV1>,
281    pub block_on_open_obligations: bool,
282    pub forbid_score_only_gate: bool,
283}
284
285impl ReleasePolicyProfileV1 {
286    /// Builds a release policy profile with typed assurance and monitoring
287    /// requirements.
288    pub fn new(
289        release_policy_profile_id: ReleasePolicyProfileId,
290        required_assurance_sections: Vec<ReleaseAssuranceSectionV1>,
291        required_monitor_classes: Vec<ReleaseMonitorClassV1>,
292        block_on_open_obligations: bool,
293        forbid_score_only_gate: bool,
294    ) -> Result<Self, &'static str> {
295        let value = Self {
296            schema_version: RELEASE_POLICY_PROFILE_V1_SCHEMA.to_string(),
297            release_policy_profile_id,
298            required_assurance_sections,
299            required_monitor_classes,
300            block_on_open_obligations,
301            forbid_score_only_gate,
302        };
303        value.validate()?;
304        Ok(value)
305    }
306
307    /// Validates schema identity and rejects duplicate release requirements.
308    pub fn validate(&self) -> Result<(), &'static str> {
309        require_schema_version(
310            &self.schema_version,
311            RELEASE_POLICY_PROFILE_V1_SCHEMA,
312            "schema_version",
313        )?;
314        require_unique(
315            &self.required_assurance_sections,
316            "required_assurance_sections",
317        )?;
318        require_unique(&self.required_monitor_classes, "required_monitor_classes")?;
319        Ok(())
320    }
321}
322
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
324pub struct ContinuityPolicyProfileV1 {
325    pub schema_version: String,
326    pub continuity_policy_profile_id: ContinuityPolicyProfileId,
327    #[serde(default)]
328    pub required_forensic_freeze_surfaces: Vec<ForensicFreezeSurfaceV1>,
329    pub continuity_exception_ttl_minutes: i64,
330    #[serde(default)]
331    pub requires_postmortem_for_severity: Vec<PolicySeverityV1>,
332    pub require_error_budget_linkage: bool,
333}
334
335impl ContinuityPolicyProfileV1 {
336    /// Builds a continuity policy profile with typed freeze-surface and
337    /// severity requirements.
338    pub fn new(
339        continuity_policy_profile_id: ContinuityPolicyProfileId,
340        required_forensic_freeze_surfaces: Vec<ForensicFreezeSurfaceV1>,
341        continuity_exception_ttl_minutes: i64,
342        requires_postmortem_for_severity: Vec<PolicySeverityV1>,
343        require_error_budget_linkage: bool,
344    ) -> Result<Self, &'static str> {
345        let value = Self {
346            schema_version: CONTINUITY_POLICY_PROFILE_V1_SCHEMA.to_string(),
347            continuity_policy_profile_id,
348            required_forensic_freeze_surfaces,
349            continuity_exception_ttl_minutes,
350            requires_postmortem_for_severity,
351            require_error_budget_linkage,
352        };
353        value.validate()?;
354        Ok(value)
355    }
356
357    /// Validates schema identity, positive TTL, and duplicate continuity
358    /// vocab entries.
359    pub fn validate(&self) -> Result<(), &'static str> {
360        require_schema_version(
361            &self.schema_version,
362            CONTINUITY_POLICY_PROFILE_V1_SCHEMA,
363            "schema_version",
364        )?;
365        if self.continuity_exception_ttl_minutes <= 0 {
366            return Err("continuity_exception_ttl_minutes");
367        }
368        require_unique(
369            &self.required_forensic_freeze_surfaces,
370            "required_forensic_freeze_surfaces",
371        )?;
372        require_unique(
373            &self.requires_postmortem_for_severity,
374            "requires_postmortem_for_severity",
375        )?;
376        Ok(())
377    }
378}
379
380#[derive(
381    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
382)]
383#[serde(rename_all = "snake_case")]
384pub enum AutonomyCeiling {
385    AdvisoryOnly,
386    VerificationOnly,
387    PromotionEligible,
388}
389
390#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
391#[serde(rename_all = "snake_case")]
392pub enum ApprovalRequirement {
393    None,
394    HumanReview,
395}
396
397#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
398pub struct ApprovalScope {
399    pub namespace: String,
400    #[serde(default, skip_serializing_if = "Option::is_none")]
401    pub target_key: Option<String>,
402    #[serde(default, skip_serializing_if = "Option::is_none")]
403    pub method: Option<CheckMethod>,
404    #[serde(default, skip_serializing_if = "Option::is_none")]
405    pub promotion_class: Option<PromotionClass>,
406    #[serde(default, skip_serializing_if = "Option::is_none")]
407    pub expires_at: Option<String>,
408    pub reusable: bool,
409}
410
411#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
412pub struct MethodPolicy {
413    pub method: CheckMethod,
414    pub allowed: bool,
415    pub max_autonomy: AutonomyCeiling,
416    pub approval_requirement: ApprovalRequirement,
417}
418
419#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
420pub struct PolicySnapshot {
421    pub schema_version: String,
422    pub policy_version: String,
423    pub effective_from: String,
424    #[serde(default, skip_serializing_if = "Option::is_none")]
425    pub effective_to: Option<String>,
426    pub autonomy_ceiling: AutonomyCeiling,
427    #[serde(default)]
428    pub method_rules: Vec<MethodPolicy>,
429    #[serde(default)]
430    pub blocked_promotions_when_degraded: bool,
431    #[serde(default)]
432    pub blocked_promotions_when_budget_exhausted: bool,
433    /// Carries ProfileExceptionBundleId through the shared v25 citation context when present.
434    #[serde(flatten)]
435    pub citation: V25CitationContext,
436    #[serde(flatten)]
437    pub obligation_refs: V25ControlObligationRefs,
438}
439
440impl PolicySnapshot {
441    /// Builds a permissive reference policy snapshot for tests and compatibility fixtures.
442    pub fn permissive(
443        policy_version: impl Into<String>,
444        effective_from: impl Into<String>,
445    ) -> Self {
446        Self {
447            schema_version: POLICY_SNAPSHOT_V1_SCHEMA.into(),
448            policy_version: policy_version.into(),
449            effective_from: effective_from.into(),
450            effective_to: None,
451            autonomy_ceiling: AutonomyCeiling::PromotionEligible,
452            method_rules: vec![
453                CheckMethod::ExactBoundedOracle,
454                CheckMethod::ConservativeOracle,
455                CheckMethod::DeltaParityOracle,
456                CheckMethod::TemporalReplayOracle,
457                CheckMethod::CausalRefuter,
458                CheckMethod::MinimalPerturbationOracle,
459                CheckMethod::PairedPatch,
460                CheckMethod::AdvisoryOnly,
461            ]
462            .into_iter()
463            .map(|method| MethodPolicy {
464                method,
465                allowed: true,
466                max_autonomy: AutonomyCeiling::PromotionEligible,
467                approval_requirement: ApprovalRequirement::None,
468            })
469            .collect(),
470            blocked_promotions_when_degraded: true,
471            blocked_promotions_when_budget_exhausted: true,
472            citation: V25CitationContext {
473                applicability_context_id: Some(ApplicabilityContextId::new(
474                    "permissive-applicability-context",
475                )),
476                profile_set_id: Some(ProfileSetId::new("permissive-profile-set")),
477                composition_receipt_id: Some(CompositionReceiptId::new(
478                    "permissive-composition-receipt",
479                )),
480                effective_constitution_id: Some(EffectiveConstitutionId::new(
481                    "permissive-effective-constitution",
482                )),
483                compiled_obligation_set_id: Some(CompiledObligationSetId::new(
484                    "permissive-compiled-obligation-set",
485                )),
486                composition_conflict_set_id: None,
487                profile_exception_bundle_ids: Vec::new(),
488            },
489            obligation_refs: V25ControlObligationRefs {
490                required_obligation_refs: vec!["obligation:permissive-required".into()],
491                blocking_obligation_refs: vec!["obligation:permissive-blocking".into()],
492                monitoring_obligation_refs: vec!["obligation:permissive-monitoring".into()],
493            },
494        }
495    }
496
497    /// Validates snapshot metadata needed to evaluate a policy version.
498    pub fn validate(&self) -> Result<(), &'static str> {
499        require_schema_version(
500            &self.schema_version,
501            POLICY_SNAPSHOT_V1_SCHEMA,
502            "schema_version",
503        )?;
504        require_non_empty(&self.policy_version, "policy_version")?;
505        require_non_empty(&self.effective_from, "effective_from")?;
506        Ok(())
507    }
508}
509
510#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
511pub struct ApprovalRecord {
512    pub schema_version: String,
513    pub approval_record_id: ApprovalRecordId,
514    pub policy_version: String,
515    #[serde(default, skip_serializing_if = "Option::is_none")]
516    pub case_id: Option<stack_ids::VerificationCaseId>,
517    #[serde(default, skip_serializing_if = "Option::is_none")]
518    pub plan_id: Option<stack_ids::CheckPlanId>,
519    pub scope: ApprovalScope,
520    pub approver: String,
521    pub approved_at: String,
522    #[serde(default)]
523    pub reviewed_artifact_refs: Vec<String>,
524    pub rationale: String,
525}
526
527impl ApprovalRecord {
528    #[allow(clippy::too_many_arguments)]
529    /// Creates an approval record bound to a policy version, optional case, and optional plan.
530    pub fn new(
531        policy_version: impl Into<String>,
532        case_id: Option<stack_ids::VerificationCaseId>,
533        plan_id: Option<stack_ids::CheckPlanId>,
534        scope: ApprovalScope,
535        approver: impl Into<String>,
536        approved_at: impl Into<String>,
537        reviewed_artifact_refs: Vec<String>,
538        rationale: impl Into<String>,
539    ) -> Self {
540        Self {
541            schema_version: APPROVAL_RECORD_V1_SCHEMA.into(),
542            approval_record_id: ApprovalRecordId::generate(),
543            policy_version: policy_version.into(),
544            case_id,
545            plan_id,
546            scope,
547            approver: approver.into(),
548            approved_at: approved_at.into(),
549            reviewed_artifact_refs,
550            rationale: rationale.into(),
551        }
552    }
553
554    /// Validates approval-record metadata and its scoped approval target.
555    pub fn validate(&self) -> Result<(), &'static str> {
556        require_schema_version(
557            &self.schema_version,
558            APPROVAL_RECORD_V1_SCHEMA,
559            "schema_version",
560        )?;
561        require_non_empty(&self.policy_version, "policy_version")?;
562        self.scope.validate()?;
563        require_non_empty(&self.approver, "approver")?;
564        require_non_empty(&self.approved_at, "approved_at")?;
565        require_non_empty(&self.rationale, "rationale")?;
566        Ok(())
567    }
568}
569
570impl ApprovalScope {
571    /// Validates the namespace-scoped approval target and optional expiry.
572    pub fn validate(&self) -> Result<(), &'static str> {
573        require_non_empty(&self.namespace, "namespace")?;
574        if self
575            .target_key
576            .as_ref()
577            .is_some_and(|value| value.trim().is_empty())
578        {
579            return Err("target_key");
580        }
581        if self
582            .expires_at
583            .as_ref()
584            .is_some_and(|value| value.trim().is_empty())
585        {
586            return Err("expires_at");
587        }
588        Ok(())
589    }
590}
591
592#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
593pub struct PolicyDecision {
594    pub schema_version: String,
595    pub decision_id: PolicyDecisionId,
596    pub policy_version: String,
597    pub case_id: stack_ids::VerificationCaseId,
598    pub plan_id: stack_ids::CheckPlanId,
599    #[serde(flatten)]
600    pub citation: V25CitationContext,
601    #[serde(flatten)]
602    pub obligation_refs: V25ControlObligationRefs,
603    pub citation_status: ConstitutionalContextStatus,
604    pub obligation_refs_status: ConstitutionalContextStatus,
605    pub evaluated_at: String,
606    pub method_allowed: bool,
607    pub autonomy_allowed: bool,
608    pub promotion_allowed: bool,
609    pub approval_required: bool,
610    pub approval_satisfied: bool,
611    #[serde(default)]
612    pub reasons: Vec<String>,
613}
614
615impl PolicyDecision {
616    /// Validates the emitted policy decision envelope metadata.
617    pub fn validate(&self) -> Result<(), &'static str> {
618        require_schema_version(
619            &self.schema_version,
620            POLICY_DECISION_V1_SCHEMA,
621            "schema_version",
622        )?;
623        require_non_empty(&self.policy_version, "policy_version")?;
624        require_non_empty(&self.evaluated_at, "evaluated_at")?;
625        Ok(())
626    }
627}
628
629/// Returns true when an approval still covers the evaluated case and plan scope.
630pub fn approval_matches(
631    approval: &ApprovalRecord,
632    case: &VerificationCase,
633    plan: &CheckPlan,
634    evaluated_at: &str,
635) -> bool {
636    if approval
637        .case_id
638        .as_ref()
639        .is_some_and(|case_id| *case_id != case.case_id)
640    {
641        return false;
642    }
643    if approval
644        .plan_id
645        .as_ref()
646        .is_some_and(|plan_id| *plan_id != plan.plan_id)
647    {
648        return false;
649    }
650    if approval.scope.namespace != case.region.namespace {
651        return false;
652    }
653    if approval
654        .scope
655        .target_key
656        .as_ref()
657        .is_some_and(|target_key| target_key != &case.region.target_key)
658    {
659        return false;
660    }
661    if approval
662        .scope
663        .method
664        .is_some_and(|method| method != plan.method)
665    {
666        return false;
667    }
668    if approval
669        .scope
670        .promotion_class
671        .is_some_and(|promotion_class| promotion_class != plan.promotion_class)
672    {
673        return false;
674    }
675    if approval
676        .scope
677        .expires_at
678        .as_ref()
679        .is_some_and(|expires_at| expires_at.as_str() <= evaluated_at)
680    {
681        return false;
682    }
683
684    true
685}
686
687/// Selects the newest policy snapshot that is effective for the requested timestamp.
688pub fn policy_as_of<'a>(policies: &'a [PolicySnapshot], as_of: &str) -> Option<&'a PolicySnapshot> {
689    policies
690        .iter()
691        .filter(|policy| {
692            policy.effective_from.as_str() <= as_of
693                && policy
694                    .effective_to
695                    .as_ref()
696                    .map_or(true, |effective_to| effective_to.as_str() > as_of)
697        })
698        .max_by(|left, right| left.effective_from.cmp(&right.effective_from))
699}
700
701/// Evaluates one verification case against policy and emits a promotion-aware decision record.
702pub fn evaluate_policy(
703    snapshot: &PolicySnapshot,
704    case: &VerificationCase,
705    plan: &CheckPlan,
706    approvals: &[ApprovalRecord],
707    degraded: bool,
708    budget_exhausted: bool,
709) -> PolicyDecision {
710    let rule = snapshot
711        .method_rules
712        .iter()
713        .find(|rule| rule.method == plan.method);
714    let mut reasons = Vec::new();
715    let method_allowed = rule.is_some_and(|rule| rule.allowed);
716    if !method_allowed {
717        reasons.push(format!("method {:?} is denied by policy", plan.method));
718    }
719
720    let required_autonomy = if plan.advisory_only {
721        AutonomyCeiling::AdvisoryOnly
722    } else if plan.promotable_if_completed {
723        AutonomyCeiling::PromotionEligible
724    } else {
725        AutonomyCeiling::VerificationOnly
726    };
727    let max_autonomy = rule
728        .map(|rule| rule.max_autonomy)
729        .unwrap_or(AutonomyCeiling::AdvisoryOnly);
730    let autonomy_ceiling = std::cmp::min(snapshot.autonomy_ceiling, max_autonomy);
731    let autonomy_allowed = required_autonomy <= autonomy_ceiling;
732    if !autonomy_allowed {
733        reasons.push(format!(
734            "required autonomy {:?} exceeds ceiling {:?}",
735            required_autonomy, autonomy_ceiling
736        ));
737    }
738
739    let approval_required =
740        rule.is_some_and(|rule| rule.approval_requirement == ApprovalRequirement::HumanReview);
741    let approval_satisfied = !approval_required
742        || approvals.iter().any(|approval| {
743            approval.policy_version == snapshot.policy_version
744                && approval_matches(approval, case, plan, case.opened_at.as_str())
745        });
746    if approval_required && !approval_satisfied {
747        reasons.push("human approval is required before execution".into());
748    }
749
750    let citation = snapshot.citation.clone();
751    let citation_status = citation.context_status();
752    let obligation_refs = snapshot.obligation_refs.clone();
753    let obligation_refs_status = obligation_refs.context_status();
754
755    let promotion_requested = plan.promotable_if_completed && !plan.advisory_only;
756    let mut promotion_allowed = promotion_requested && method_allowed && autonomy_allowed;
757    if degraded && snapshot.blocked_promotions_when_degraded {
758        promotion_allowed = false;
759        reasons.push("degraded path cannot promote".into());
760    }
761    if budget_exhausted && snapshot.blocked_promotions_when_budget_exhausted {
762        promotion_allowed = false;
763        reasons.push("budget-exhausted path cannot promote".into());
764    }
765    if !citation_status.is_complete() {
766        promotion_allowed = false;
767        reasons.push("constitutional citation context is missing or partial".into());
768    }
769    if !obligation_refs_status.is_complete() {
770        promotion_allowed = false;
771        reasons.push("constitutional obligation refs are missing".into());
772    }
773
774    PolicyDecision {
775        schema_version: POLICY_DECISION_V1_SCHEMA.into(),
776        decision_id: PolicyDecisionId::generate(),
777        policy_version: snapshot.policy_version.clone(),
778        case_id: case.case_id.clone(),
779        plan_id: plan.plan_id.clone(),
780        citation,
781        obligation_refs,
782        citation_status,
783        obligation_refs_status,
784        evaluated_at: case.opened_at.clone(),
785        method_allowed,
786        autonomy_allowed,
787        promotion_allowed,
788        approval_required,
789        approval_satisfied,
790        reasons,
791    }
792}
793
794#[cfg(test)]
795mod tests {
796    use super::*;
797    use stack_ids::{AttemptId, ScopeKey, TraceCtx};
798    use verification_control::{
799        CaseRegion, CheckPlan, PromotionClass, ReversibilityClass, VerificationCase,
800        VerificationCaseClass,
801    };
802
803    fn complete_citation() -> V25CitationContext {
804        V25CitationContext {
805            applicability_context_id: Some(ApplicabilityContextId::new("test-applicability")),
806            profile_set_id: Some(ProfileSetId::new("test-profile-set")),
807            composition_receipt_id: Some(CompositionReceiptId::new("test-composition")),
808            effective_constitution_id: Some(EffectiveConstitutionId::new(
809                "test-effective-constitution",
810            )),
811            compiled_obligation_set_id: Some(CompiledObligationSetId::new(
812                "test-compiled-obligations",
813            )),
814            composition_conflict_set_id: None,
815            profile_exception_bundle_ids: Vec::new(),
816        }
817    }
818
819    fn complete_obligation_refs() -> V25ControlObligationRefs {
820        V25ControlObligationRefs {
821            required_obligation_refs: vec!["obligation:test-required".into()],
822            blocking_obligation_refs: vec!["obligation:test-blocking".into()],
823            monitoring_obligation_refs: vec!["obligation:test-monitoring".into()],
824        }
825    }
826
827    #[test]
828    fn denied_method_cannot_execute() {
829        let case = VerificationCase::new(
830            VerificationCaseClass::ThinExport,
831            CaseRegion {
832                namespace: "demo".into(),
833                scope_key: Some(ScopeKey::namespace_only("demo")),
834                target_key: "thin_export".into(),
835                region_id: None,
836                region_digest_id: None,
837                claim_version_id: None,
838                as_of_recorded_at: None,
839            },
840            TraceCtx::generate(),
841            AttemptId::new("attempt-1"),
842            "2026-03-12T00:00:00Z",
843            true,
844            true,
845        );
846        let plan = CheckPlan::new(
847            case.case_id.clone(),
848            CheckMethod::PairedPatch,
849            vec!["test".into()],
850            PromotionClass::P2,
851            ReversibilityClass::RequiresSupersession,
852            true,
853            false,
854            false,
855            "patch",
856            serde_json::json!({}),
857        );
858        let policy = PolicySnapshot {
859            schema_version: POLICY_SNAPSHOT_V1_SCHEMA.into(),
860            policy_version: "policy-1".into(),
861            effective_from: "2026-03-01T00:00:00Z".into(),
862            effective_to: None,
863            autonomy_ceiling: AutonomyCeiling::PromotionEligible,
864            method_rules: vec![MethodPolicy {
865                method: CheckMethod::PairedPatch,
866                allowed: false,
867                max_autonomy: AutonomyCeiling::PromotionEligible,
868                approval_requirement: ApprovalRequirement::None,
869            }],
870            blocked_promotions_when_degraded: true,
871            blocked_promotions_when_budget_exhausted: true,
872            citation: complete_citation(),
873            obligation_refs: complete_obligation_refs(),
874        };
875
876        let decision = evaluate_policy(&policy, &case, &plan, &[], false, false);
877        assert_eq!(
878            decision.issue_execution_permit(&case, &plan, &[]),
879            Err(PermitIssuanceError::MethodDenied)
880        );
881    }
882
883    #[test]
884    fn scoped_approval_can_satisfy_runtime_generated_case_ids() {
885        let case = VerificationCase::new(
886            VerificationCaseClass::UnverifiedClaimVersion,
887            CaseRegion {
888                namespace: "demo".into(),
889                scope_key: Some(ScopeKey::namespace_only("demo")),
890                target_key: "unverified:claim-v1".into(),
891                region_id: None,
892                region_digest_id: None,
893                claim_version_id: Some(stack_ids::ClaimVersionId::new("claim-v1")),
894                as_of_recorded_at: None,
895            },
896            TraceCtx::generate(),
897            AttemptId::new("attempt-1"),
898            "2026-03-12T00:00:00Z",
899            false,
900            false,
901        );
902        let plan = CheckPlan::new(
903            case.case_id.clone(),
904            CheckMethod::ExactBoundedOracle,
905            vec!["kernel_oracle".into()],
906            PromotionClass::P2,
907            ReversibilityClass::ReversibleScoped,
908            true,
909            false,
910            false,
911            "oracle",
912            serde_json::json!({}),
913        );
914        let policy = PolicySnapshot {
915            schema_version: POLICY_SNAPSHOT_V1_SCHEMA.into(),
916            policy_version: "policy-1".into(),
917            effective_from: "2026-03-01T00:00:00Z".into(),
918            effective_to: None,
919            autonomy_ceiling: AutonomyCeiling::PromotionEligible,
920            method_rules: vec![MethodPolicy {
921                method: CheckMethod::ExactBoundedOracle,
922                allowed: true,
923                max_autonomy: AutonomyCeiling::PromotionEligible,
924                approval_requirement: ApprovalRequirement::HumanReview,
925            }],
926            blocked_promotions_when_degraded: true,
927            blocked_promotions_when_budget_exhausted: true,
928            citation: complete_citation(),
929            obligation_refs: complete_obligation_refs(),
930        };
931        let approval = ApprovalRecord::new(
932            "policy-1",
933            None,
934            None,
935            ApprovalScope {
936                namespace: "demo".into(),
937                target_key: Some("unverified:claim-v1".into()),
938                method: Some(CheckMethod::ExactBoundedOracle),
939                promotion_class: Some(PromotionClass::P2),
940                expires_at: None,
941                reusable: true,
942            },
943            "operator",
944            "2026-03-12T00:00:00Z",
945            vec!["claim-v1".into()],
946            "reviewed exact oracle promotion",
947        );
948
949        let decision = evaluate_policy(
950            &policy,
951            &case,
952            &plan,
953            std::slice::from_ref(&approval),
954            false,
955            false,
956        );
957        assert!(decision.approval_required);
958        assert!(decision.approval_satisfied);
959        let permit = decision
960            .issue_execution_permit(&case, &plan, &[approval])
961            .expect("permit should mint for approved promotable plan");
962        assert_eq!(permit.scope().namespace(), "demo");
963        assert_eq!(permit.scope().target_key(), "unverified:claim-v1");
964    }
965
966    #[test]
967    fn v21_v24_policy_profiles_roundtrip() {
968        let effect = EffectPolicyProfileV1 {
969            schema_version: EFFECT_POLICY_PROFILE_V1_SCHEMA.into(),
970            effect_policy_profile_id: stack_ids::EffectPolicyProfileId::new(
971                "effect-policy-profile-1",
972            ),
973            allowed_run_modes: vec![EffectPolicyRunModeV1::DryRun, EffectPolicyRunModeV1::Live],
974            required_preflight_checks: vec![
975                EffectPreflightCheckV1::Admission,
976                EffectPreflightCheckV1::Budget,
977            ],
978            required_observation_classes: vec![EffectObservationClassV1::Monitoring],
979            requires_compensation_plan_for: vec![CompensationPolicyTriggerV1::ExternalWrite],
980            block_live_without_commit: true,
981        };
982        let delegation = DelegationPolicyProfileV1 {
983            schema_version: DELEGATION_POLICY_PROFILE_V1_SCHEMA.into(),
984            delegation_policy_profile_id: stack_ids::DelegationPolicyProfileId::new(
985                "delegation-policy-profile-1",
986            ),
987            max_delegation_depth: 1,
988            break_glass_requires_post_hoc_review: true,
989            forbidden_role_combinations: vec![DelegationRoleCombinationV1::RequesterApprover],
990            require_typed_authority_chain: true,
991        };
992        let release = ReleasePolicyProfileV1 {
993            schema_version: RELEASE_POLICY_PROFILE_V1_SCHEMA.into(),
994            release_policy_profile_id: stack_ids::ReleasePolicyProfileId::new(
995                "release-policy-profile-1",
996            ),
997            required_assurance_sections: vec![
998                ReleaseAssuranceSectionV1::HazardSummary,
999                ReleaseAssuranceSectionV1::ControlEvidence,
1000            ],
1001            required_monitor_classes: vec![ReleaseMonitorClassV1::ErrorBudget],
1002            block_on_open_obligations: true,
1003            forbid_score_only_gate: true,
1004        };
1005        let continuity = ContinuityPolicyProfileV1 {
1006            schema_version: CONTINUITY_POLICY_PROFILE_V1_SCHEMA.into(),
1007            continuity_policy_profile_id: stack_ids::ContinuityPolicyProfileId::new(
1008                "continuity-policy-profile-1",
1009            ),
1010            required_forensic_freeze_surfaces: vec![
1011                ForensicFreezeSurfaceV1::AuditLog,
1012                ForensicFreezeSurfaceV1::EffectLedger,
1013            ],
1014            continuity_exception_ttl_minutes: 30,
1015            requires_postmortem_for_severity: vec![PolicySeverityV1::Sev1, PolicySeverityV1::Sev2],
1016            require_error_budget_linkage: true,
1017        };
1018
1019        effect.validate().expect("effect profile validates");
1020        delegation.validate().expect("delegation profile validates");
1021        release.validate().expect("release profile validates");
1022        continuity.validate().expect("continuity profile validates");
1023
1024        let json = serde_json::to_string(&(effect, delegation, release, continuity))
1025            .expect("serialize policy profiles");
1026        let _: (
1027            EffectPolicyProfileV1,
1028            DelegationPolicyProfileV1,
1029            ReleasePolicyProfileV1,
1030            ContinuityPolicyProfileV1,
1031        ) = serde_json::from_str(&json).expect("deserialize policy profiles");
1032    }
1033
1034    #[test]
1035    fn duplicate_policy_vocab_is_rejected() {
1036        let effect = EffectPolicyProfileV1 {
1037            schema_version: EFFECT_POLICY_PROFILE_V1_SCHEMA.into(),
1038            effect_policy_profile_id: stack_ids::EffectPolicyProfileId::new(
1039                "effect-policy-profile-dup",
1040            ),
1041            allowed_run_modes: vec![EffectPolicyRunModeV1::Live, EffectPolicyRunModeV1::Live],
1042            required_preflight_checks: vec![EffectPreflightCheckV1::Integrity],
1043            required_observation_classes: vec![EffectObservationClassV1::Latency],
1044            requires_compensation_plan_for: vec![CompensationPolicyTriggerV1::Mutation],
1045            block_live_without_commit: true,
1046        };
1047
1048        assert_eq!(effect.validate(), Err("allowed_run_modes"));
1049    }
1050}