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                (
61                    SafetyClass::BackfillRequired,
62                    SafetyPolicyDecision::RequireApproval,
63                ),
64                (SafetyClass::Unsupported, SafetyPolicyDecision::Reject),
65            ]),
66        }
67    }
68
69    /// Override one class decision, refusing any loosening of the floor.
70    pub fn with_decision(
71        mut self,
72        class: SafetyClass,
73        decision: SafetyPolicyDecision,
74    ) -> Result<Self, Diagnostic> {
75        match (class, decision) {
76            (SafetyClass::Destructive | SafetyClass::Opaque, SafetyPolicyDecision::Allow) => {
77                return Err(failure(
78                    "migration_policy_forbidden_allow",
79                    "destructive and opaque work cannot carry a standing allowance",
80                ));
81            }
82            (SafetyClass::BackfillRequired, SafetyPolicyDecision::Allow) => {
83                return Err(failure(
84                    "migration_policy_forbidden_allow",
85                    "backfill work cannot carry a standing allowance",
86                ));
87            }
88            (SafetyClass::Unsupported, decision) if decision != SafetyPolicyDecision::Reject => {
89                return Err(failure(
90                    "migration_policy_unresolvable_class",
91                    "unsupported work cannot be admitted by policy",
92                ));
93            }
94            _ => {}
95        }
96        self.decisions.insert(class, decision);
97        Ok(self)
98    }
99
100    /// Return the decision for one manifest safety classification.
101    pub fn decision(&self, class: SafetyClass) -> SafetyPolicyDecision {
102        self.decisions
103            .get(&class)
104            .copied()
105            .unwrap_or(SafetyPolicyDecision::Reject)
106    }
107}
108
109/// A one-time operator approval bound to one exact verified transition.
110///
111/// Every element the plan executes under is captured: any change to the
112/// manifest bytes, ordered steps, profiles, or the source/target managed
113/// states breaks the binding and the approval no longer matches.
114#[derive(Clone, Debug, Eq, PartialEq)]
115pub struct MigrationApplyApproval {
116    id: MigrationId,
117    lowering_profile: SchemaLoweringProfileBinding,
118    manifest_digest: MigrationManifestDigest,
119    plan_fingerprint: MigrationPlanFingerprint,
120    safety: SafetyClass,
121    semantic_profile: SemanticProfileBinding,
122    source_state: ManagedSchemaState,
123    target_state: ManagedSchemaState,
124}
125
126impl MigrationApplyApproval {
127    /// Record an approval for the exact transition a verified manifest claims.
128    pub fn for_manifest(manifest: &VerifiedSchemaMigrationManifest) -> Result<Self, Diagnostic> {
129        Self::for_transition(manifest, manifest.safety(), false)
130    }
131
132    /// Record an approval for rolling one verified manifest back.
133    ///
134    /// The approval binds the executed transition, so the manifest's states
135    /// enter swapped: rolling back moves the managed schema from the
136    /// manifest's target back to its source. A forward approval therefore
137    /// never authorizes a rollback, and vice versa.
138    pub fn for_rollback(
139        manifest: &VerifiedSchemaMigrationManifest,
140        rollback_safety: SafetyClass,
141    ) -> Result<Self, Diagnostic> {
142        Self::for_transition(manifest, rollback_safety, true)
143    }
144
145    fn for_transition(
146        manifest: &VerifiedSchemaMigrationManifest,
147        safety: SafetyClass,
148        rollback: bool,
149    ) -> Result<Self, Diagnostic> {
150        let (source_state, target_state) = if rollback {
151            (
152                manifest.target_state().clone(),
153                manifest.source_state().clone(),
154            )
155        } else {
156            (
157                manifest.source_state().clone(),
158                manifest.target_state().clone(),
159            )
160        };
161        Ok(Self {
162            id: manifest.id().clone(),
163            lowering_profile: manifest.lowering_profile().clone(),
164            manifest_digest: verified_manifest_digest(manifest)?,
165            plan_fingerprint: manifest.plan_fingerprint().clone(),
166            safety,
167            semantic_profile: manifest.semantic_profile().clone(),
168            source_state,
169            target_state,
170        })
171    }
172
173    /// Return the approved compound migration identity.
174    pub const fn id(&self) -> &MigrationId {
175        &self.id
176    }
177
178    /// Return the approved safety classification.
179    pub const fn safety(&self) -> SafetyClass {
180        self.safety
181    }
182
183    /// Return whether this approval binds the exact forward transition.
184    pub fn binds(&self, manifest: &VerifiedSchemaMigrationManifest) -> Result<bool, Diagnostic> {
185        Ok(self.safety == manifest.safety()
186            && self.source_state == *manifest.source_state()
187            && self.target_state == *manifest.target_state()
188            && self.binds_identity(manifest)?)
189    }
190
191    /// Return whether this approval binds the exact rollback transition.
192    pub fn binds_rollback(
193        &self,
194        manifest: &VerifiedSchemaMigrationManifest,
195        rollback_safety: SafetyClass,
196    ) -> Result<bool, Diagnostic> {
197        Ok(self.safety == rollback_safety
198            && self.source_state == *manifest.target_state()
199            && self.target_state == *manifest.source_state()
200            && self.binds_identity(manifest)?)
201    }
202
203    fn binds_identity(
204        &self,
205        manifest: &VerifiedSchemaMigrationManifest,
206    ) -> Result<bool, Diagnostic> {
207        Ok(self.id == *manifest.id()
208            && self.plan_fingerprint == *manifest.plan_fingerprint()
209            && self.semantic_profile == *manifest.semantic_profile()
210            && self.lowering_profile == *manifest.lowering_profile()
211            && self.manifest_digest == verified_manifest_digest(manifest)?)
212    }
213}
214
215fn failure(code: &'static str, message: &'static str) -> Diagnostic {
216    Diagnostic::new(
217        DiagnosticCategory::InvalidContract,
218        DiagnosticCode::new(code).expect("static policy diagnostic code is canonical"),
219        message,
220    )
221}