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 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 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 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 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 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 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 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 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 #[serde(flatten)]
435 pub citation: V25CitationContext,
436 #[serde(flatten)]
437 pub obligation_refs: V25ControlObligationRefs,
438}
439
440impl PolicySnapshot {
441 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 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 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 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 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 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
629pub 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
687pub 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
701pub 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}