type_bridge_schema_migration/
policy.rs1use 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
24pub enum SafetyPolicyDecision {
25 Allow,
27 RequireApproval,
29 Reject,
31}
32
33#[derive(Clone, Debug, Eq, PartialEq)]
41pub struct MigrationSafetyPolicy {
42 decisions: BTreeMap<SafetyClass, SafetyPolicyDecision>,
43}
44
45impl MigrationSafetyPolicy {
46 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 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 pub fn decision(&self, class: SafetyClass) -> SafetyPolicyDecision {
102 self.decisions
103 .get(&class)
104 .copied()
105 .unwrap_or(SafetyPolicyDecision::Reject)
106 }
107}
108
109#[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 pub fn for_manifest(manifest: &VerifiedSchemaMigrationManifest) -> Result<Self, Diagnostic> {
129 Self::for_transition(manifest, manifest.safety(), false)
130 }
131
132 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 pub const fn id(&self) -> &MigrationId {
175 &self.id
176 }
177
178 pub const fn safety(&self) -> SafetyClass {
180 self.safety
181 }
182
183 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 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}