Skip to main content

type_bridge_schema_migration/
policy.rs

1//! Explicit apply-side safety policy and identity-bound operator approvals.
2//!
3//! Policy may reject a safety class or require an explicit approval; it can
4//! never relabel the verifier's classification or grant a standing bypass. An
5//! approval is bound to one exact verified transition — the manifest digest,
6//! plan fingerprint, profiles, and source/target managed states — so applying
7//! the transition consumes it structurally: the frontier moves and the same
8//! binding can never match again.
9
10use std::collections::BTreeMap;
11
12use type_bridge_contract::diagnostic::{Diagnostic, DiagnosticCategory, DiagnosticCode};
13use type_bridge_contract::managed_scope::SemanticProfileBinding;
14use type_bridge_contract::migration::MigrationPlanFingerprint;
15use type_bridge_contract::migration::{MigrationId, MigrationManifestDigest};
16use type_bridge_contract::schema::ManagedSchemaState;
17use type_bridge_contract::schema_lowering::SchemaLoweringProfileBinding;
18use type_bridge_schema::SafetyClass;
19
20use crate::manifest::{VerifiedSchemaMigrationManifest, verified_manifest_digest};
21
22/// How the apply gate treats one manifest safety classification.
23#[derive(Clone, Copy, Debug, Eq, PartialEq)]
24pub enum SafetyPolicyDecision {
25    /// The class executes without operator involvement.
26    Allow,
27    /// The class executes only under a matching identity-bound approval.
28    RequireApproval,
29    /// The class never executes under this policy.
30    Reject,
31}
32
33/// Explicit per-class apply policy.
34///
35/// The verifier's classification is a floor the policy can only tighten:
36/// destructive and opaque work can be rejected or gated behind approval but
37/// never permanently allowed (a standing `Allow` is the invalid permanent
38/// `force = true` shape), and classes the manifest verifier refuses to carry
39/// stay rejected.
40#[derive(Clone, Debug, Eq, PartialEq)]
41pub struct MigrationSafetyPolicy {
42    decisions: BTreeMap<SafetyClass, SafetyPolicyDecision>,
43}
44
45impl MigrationSafetyPolicy {
46    /// Return the default policy: verified-safe classes execute, destructive
47    /// and opaque work requires approval, unresolved work stays rejected.
48    pub fn default_policy() -> Self {
49        Self {
50            decisions: BTreeMap::from([
51                (SafetyClass::FormalOnly, SafetyPolicyDecision::Allow),
52                (SafetyClass::SchemaMetadata, SafetyPolicyDecision::Allow),
53                (SafetyClass::Additive, SafetyPolicyDecision::Allow),
54                (SafetyClass::Conditional, SafetyPolicyDecision::Allow),
55                (
56                    SafetyClass::Destructive,
57                    SafetyPolicyDecision::RequireApproval,
58                ),
59                (SafetyClass::Opaque, SafetyPolicyDecision::RequireApproval),
60                (SafetyClass::BackfillRequired, SafetyPolicyDecision::Reject),
61                (SafetyClass::Unsupported, SafetyPolicyDecision::Reject),
62            ]),
63        }
64    }
65
66    /// Override one class decision, refusing any loosening of the floor.
67    pub fn with_decision(
68        mut self,
69        class: SafetyClass,
70        decision: SafetyPolicyDecision,
71    ) -> Result<Self, Diagnostic> {
72        match (class, decision) {
73            (SafetyClass::Destructive | SafetyClass::Opaque, SafetyPolicyDecision::Allow) => {
74                return Err(failure(
75                    "migration_policy_forbidden_allow",
76                    "destructive and opaque work cannot carry a standing allowance",
77                ));
78            }
79            (SafetyClass::BackfillRequired | SafetyClass::Unsupported, decision)
80                if decision != SafetyPolicyDecision::Reject =>
81            {
82                return Err(failure(
83                    "migration_policy_unresolvable_class",
84                    "classes the manifest verifier refuses cannot be admitted by policy",
85                ));
86            }
87            _ => {}
88        }
89        self.decisions.insert(class, decision);
90        Ok(self)
91    }
92
93    /// Return the decision for one manifest safety classification.
94    pub fn decision(&self, class: SafetyClass) -> SafetyPolicyDecision {
95        self.decisions
96            .get(&class)
97            .copied()
98            .unwrap_or(SafetyPolicyDecision::Reject)
99    }
100}
101
102/// A one-time operator approval bound to one exact verified transition.
103///
104/// Every element the plan executes under is captured: any change to the
105/// manifest bytes, ordered steps, profiles, or the source/target managed
106/// states breaks the binding and the approval no longer matches.
107#[derive(Clone, Debug, Eq, PartialEq)]
108pub struct MigrationApplyApproval {
109    id: MigrationId,
110    lowering_profile: SchemaLoweringProfileBinding,
111    manifest_digest: MigrationManifestDigest,
112    plan_fingerprint: MigrationPlanFingerprint,
113    safety: SafetyClass,
114    semantic_profile: SemanticProfileBinding,
115    source_state: ManagedSchemaState,
116    target_state: ManagedSchemaState,
117}
118
119impl MigrationApplyApproval {
120    /// Record an approval for the exact transition a verified manifest claims.
121    pub fn for_manifest(manifest: &VerifiedSchemaMigrationManifest) -> Result<Self, Diagnostic> {
122        Self::for_transition(manifest, manifest.safety(), false)
123    }
124
125    /// Record an approval for rolling one verified manifest back.
126    ///
127    /// The approval binds the executed transition, so the manifest's states
128    /// enter swapped: rolling back moves the managed schema from the
129    /// manifest's target back to its source. A forward approval therefore
130    /// never authorizes a rollback, and vice versa.
131    pub fn for_rollback(
132        manifest: &VerifiedSchemaMigrationManifest,
133        rollback_safety: SafetyClass,
134    ) -> Result<Self, Diagnostic> {
135        Self::for_transition(manifest, rollback_safety, true)
136    }
137
138    fn for_transition(
139        manifest: &VerifiedSchemaMigrationManifest,
140        safety: SafetyClass,
141        rollback: bool,
142    ) -> Result<Self, Diagnostic> {
143        let (source_state, target_state) = if rollback {
144            (
145                manifest.target_state().clone(),
146                manifest.source_state().clone(),
147            )
148        } else {
149            (
150                manifest.source_state().clone(),
151                manifest.target_state().clone(),
152            )
153        };
154        Ok(Self {
155            id: manifest.id().clone(),
156            lowering_profile: manifest.lowering_profile().clone(),
157            manifest_digest: verified_manifest_digest(manifest)?,
158            plan_fingerprint: manifest.plan_fingerprint().clone(),
159            safety,
160            semantic_profile: manifest.semantic_profile().clone(),
161            source_state,
162            target_state,
163        })
164    }
165
166    /// Return the approved compound migration identity.
167    pub const fn id(&self) -> &MigrationId {
168        &self.id
169    }
170
171    /// Return the approved safety classification.
172    pub const fn safety(&self) -> SafetyClass {
173        self.safety
174    }
175
176    /// Return whether this approval binds the exact forward transition.
177    pub fn binds(&self, manifest: &VerifiedSchemaMigrationManifest) -> Result<bool, Diagnostic> {
178        Ok(self.safety == manifest.safety()
179            && self.source_state == *manifest.source_state()
180            && self.target_state == *manifest.target_state()
181            && self.binds_identity(manifest)?)
182    }
183
184    /// Return whether this approval binds the exact rollback transition.
185    pub fn binds_rollback(
186        &self,
187        manifest: &VerifiedSchemaMigrationManifest,
188        rollback_safety: SafetyClass,
189    ) -> Result<bool, Diagnostic> {
190        Ok(self.safety == rollback_safety
191            && self.source_state == *manifest.target_state()
192            && self.target_state == *manifest.source_state()
193            && self.binds_identity(manifest)?)
194    }
195
196    fn binds_identity(
197        &self,
198        manifest: &VerifiedSchemaMigrationManifest,
199    ) -> Result<bool, Diagnostic> {
200        Ok(self.id == *manifest.id()
201            && self.plan_fingerprint == *manifest.plan_fingerprint()
202            && self.semantic_profile == *manifest.semantic_profile()
203            && self.lowering_profile == *manifest.lowering_profile()
204            && self.manifest_digest == verified_manifest_digest(manifest)?)
205    }
206}
207
208fn failure(code: &'static str, message: &'static str) -> Diagnostic {
209    Diagnostic::new(
210        DiagnosticCategory::InvalidContract,
211        DiagnosticCode::new(code).expect("static policy diagnostic code is canonical"),
212        message,
213    )
214}