1#![deny(missing_docs)]
16
17use std::collections::BTreeSet;
18use std::ffi::OsString;
19use std::fs;
20use std::path::{Component, Path, PathBuf};
21
22use clap::{Parser, Subcommand, ValueEnum};
23#[cfg(test)]
24use type_bridge_schema::SystemSchemaSourceService;
25use type_bridge_schema_migration::MigrationGenerationOutcome;
26use type_bridge_schema_migration_typedb::execution_capability_vocabulary;
27use type_bridge_workspace::{
28 ConfigOrigin, ExtensionRegistryService, ExtensionRequirement, SecretReference,
29 SecretReferenceService, TypeBridgeConfigSpec, TypeBridgeWorkspace, TypeBridgeWorkspaceServices,
30 WorkspaceDirectoryAuthority, WorkspaceEnvironment, WorkspaceRoot, WorkspaceServiceError,
31 WorkspaceTransportPolicy, c_symbol_prefix_for_app_label,
32};
33
34mod build_identity {
35 include!(concat!(env!("OUT_DIR"), "/cli_build_identity.rs"));
36}
37
38#[derive(Parser)]
39#[command(
40 name = "type-bridge",
41 version = build_identity::CLI_VERSION,
42 about = "TypeBridge V2 workspace commands"
43)]
44struct Cli {
45 #[arg(long, global = true, default_value = "typebridge.yaml")]
47 manifest: PathBuf,
48 #[command(subcommand)]
49 command: Command,
50}
51
52#[derive(Subcommand)]
53enum Command {
54 Schema {
56 #[command(subcommand)]
57 command: SchemaCommand,
58 },
59 Migration {
61 #[command(subcommand)]
62 command: MigrationCommand,
63 },
64}
65
66#[derive(Subcommand)]
67enum SchemaCommand {
68 Check,
70 Generate,
72 ExportDeclared {
74 #[arg(long, default_value = "declared-schema.json")]
76 output: PathBuf,
77 },
78}
79
80#[derive(Subcommand)]
81enum MigrationCommand {
82 Make {
84 #[arg(long)]
86 name: String,
87 #[arg(long)]
89 backfill_intent: Option<PathBuf>,
90 },
91 Plan,
93 Apply {
95 #[arg(long)]
97 environment: String,
98 #[arg(long = "approve")]
100 approvals: Vec<String>,
101 },
102 Verify {
104 #[arg(long)]
106 environment: String,
107 },
108 Rollback {
110 #[arg(long)]
112 environment: String,
113 #[arg(long = "remove", required = true)]
115 removals: Vec<String>,
116 #[arg(long = "approve")]
118 approvals: Vec<String>,
119 #[arg(long)]
121 execute: bool,
122 #[arg(long, value_enum, default_value_t = RollbackOutput::Text)]
124 output: RollbackOutput,
125 },
126 Adopt {
128 #[arg(long)]
130 environment: String,
131 #[arg(long)]
133 archive_directory: PathBuf,
134 #[arg(long, default_value = "0000_archive_frontier")]
136 name: String,
137 },
138}
139
140#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
141enum RollbackOutput {
142 Text,
143 Json,
144}
145
146struct DeferSecrets;
148
149impl SecretReferenceService for DeferSecrets {
150 fn validate_reference(
151 &self,
152 _reference: &SecretReference,
153 ) -> Result<(), WorkspaceServiceError> {
154 Ok(())
155 }
156}
157
158struct NoExtensions;
160
161impl ExtensionRegistryService for NoExtensions {
162 fn validate_requirement(
163 &self,
164 _requirement: &ExtensionRequirement,
165 ) -> Result<(), WorkspaceServiceError> {
166 Err(WorkspaceServiceError::new(
167 "extension_handlers_unavailable_in_cli",
168 ))
169 }
170}
171
172pub fn run_cli<I, T>(arguments: I) -> i32
179where
180 I: IntoIterator<Item = T>,
181 T: Into<OsString> + Clone,
182{
183 let cli = match Cli::try_parse_from(arguments) {
184 Ok(cli) => cli,
185 Err(error) => {
186 let _ = error.print();
187 return if error.use_stderr() { 2 } else { 0 };
188 }
189 };
190 match run(&cli) {
191 Ok(()) => 0,
192 Err(message) => {
193 eprintln!("error: {message}");
194 1
195 }
196 }
197}
198
199fn run(cli: &Cli) -> Result<(), String> {
200 let workspace = load_workspace(&cli.manifest)?;
201 match &cli.command {
202 Command::Schema {
203 command: SchemaCommand::Check,
204 } => {
205 println!(
206 "schema sources are valid\n declared identity: {}\n managed semantics: {}",
207 workspace
208 .declared_schema()
209 .declared_identity_fingerprint()
210 .as_fingerprint()
211 .digest()
212 .to_hex(),
213 workspace
214 .managed_state()
215 .managed_semantic_schema()
216 .as_fingerprint()
217 .digest()
218 .to_hex(),
219 );
220 Ok(())
221 }
222 Command::Schema {
223 command: SchemaCommand::Generate,
224 } => run_schema_generate(&workspace),
225 Command::Schema {
226 command: SchemaCommand::ExportDeclared { output },
227 } => run_schema_export_declared(&workspace, output),
228 Command::Migration { command } => match command {
229 MigrationCommand::Make {
230 name,
231 backfill_intent,
232 } => {
233 let directory = workspace.ensure_migration_directory().map_err(display)?;
234 if let Some(intent) = backfill_intent {
235 let generated = workspace
236 .author_backfill_migration_in(&directory, name, intent)
237 .map_err(display)?;
238 let path = directory.display_path().join(generated.file_name());
239 println!(
240 "wrote {}\n safety: {:?}\n preview: {}",
241 path.display(),
242 generated.manifest().safety(),
243 path.with_file_name(generated.preview_file_name()).display(),
244 );
245 return Ok(());
246 }
247 match workspace
248 .migration_make_in(&directory, name)
249 .map_err(display)?
250 {
251 MigrationGenerationOutcome::UpToDate => {
252 println!("history already reaches the desired schema");
253 }
254 MigrationGenerationOutcome::Generated(generated) => {
255 workspace
256 .write_generated_migration_in(&directory, &generated)
257 .map_err(display)?;
258 let path = directory.display_path().join(generated.file_name());
259 println!(
260 "wrote {}\n safety: {:?}\n preview: {}",
261 path.display(),
262 generated.manifest().safety(),
263 path.with_file_name(generated.preview_file_name()).display(),
264 );
265 }
266 }
267 Ok(())
268 }
269 MigrationCommand::Apply {
270 environment,
271 approvals,
272 } => run_connected(
273 &workspace,
274 environment,
275 ConnectedAction::Apply {
276 approvals: approvals.clone(),
277 },
278 ),
279 MigrationCommand::Verify { environment } => {
280 run_connected(&workspace, environment, ConnectedAction::Verify)
281 }
282 MigrationCommand::Rollback {
283 environment,
284 removals,
285 approvals,
286 execute,
287 output,
288 } => run_migration_rollback(
289 &workspace,
290 environment,
291 removals,
292 approvals,
293 *execute,
294 *output,
295 ),
296 MigrationCommand::Adopt {
297 environment,
298 archive_directory,
299 name,
300 } => run_connected(
301 &workspace,
302 environment,
303 ConnectedAction::Adopt {
304 archive_directory: archive_directory.clone(),
305 name: name.clone(),
306 },
307 ),
308 MigrationCommand::Plan => {
309 let directory = workspace.open_migration_directory().map_err(display)?;
310 let plan = workspace
311 .migration_plan_in(&directory, &BTreeSet::new())
312 .map_err(display)?;
313 if plan.is_empty() {
314 println!("no committed migrations");
315 return Ok(());
316 }
317 for entry in plan {
318 println!(
319 "{}/{} safety={:?} reversible={}",
320 entry.id().app_label().as_str(),
321 entry.id().name().as_str(),
322 entry.safety(),
323 entry.reversible(),
324 );
325 }
326 Ok(())
327 }
328 },
329 }
330}
331
332fn run_migration_rollback(
333 workspace: &TypeBridgeWorkspace,
334 environment: &str,
335 removals: &[String],
336 approvals: &[String],
337 execute: bool,
338 output: RollbackOutput,
339) -> Result<(), String> {
340 if workspace.config().environment(environment).is_none() {
341 return Err(format!(
342 "unknown environment {environment:?}; rollback requires an exact workspace environment binding"
343 ));
344 }
345 let directory = workspace.open_migration_directory().map_err(display)?;
346 let graph = workspace
347 .discover_migrations_in(&directory)
348 .map_err(display)?;
349 let removals = parse_migration_ids(&graph, removals, "rollback removal")?;
350 let _ = bind_rollback_approvals(&graph, approvals)?;
351 let applied = graph
352 .manifests()
353 .map(|(id, _)| id.clone())
354 .collect::<BTreeSet<_>>();
355 let lowering = type_bridge_schema_migration::SchemaLoweringBinding::current(
356 workspace.delta_context().available_capabilities().clone(),
357 )
358 .map_err(display)?;
359 let plan = type_bridge_schema_migration::build_verified_migration_rollback_preview(
360 &graph,
361 &applied,
362 &removals,
363 workspace.delta_context(),
364 &lowering,
365 )
366 .map_err(display)?;
367 render_rollback_preview(environment, &plan, execute, output)?;
368 if !execute {
369 return Ok(());
370 }
371 run_connected(
372 workspace,
373 environment,
374 ConnectedAction::Rollback {
375 removals,
376 approvals: approvals.to_vec(),
377 },
378 )
379}
380
381fn render_rollback_preview(
382 environment: &str,
383 plan: &type_bridge_schema_migration::VerifiedMigrationRollbackPlan,
384 execute: bool,
385 output: RollbackOutput,
386) -> Result<(), String> {
387 let order = plan
388 .rollbacks()
389 .iter()
390 .map(|rollback| {
391 format!(
392 "{}/{}",
393 rollback.manifest().id().app_label().as_str(),
394 rollback.manifest().id().name().as_str()
395 )
396 })
397 .collect::<Vec<_>>();
398 let target = plan
399 .remaining_applied()
400 .iter()
401 .map(|id| format!("{}/{}", id.app_label().as_str(), id.name().as_str()))
402 .collect::<Vec<_>>();
403 let safety = plan
404 .rollbacks()
405 .iter()
406 .map(|rollback| rollback_safety_wire(rollback.rollback_safety()).to_owned())
407 .collect::<Vec<_>>();
408 let plan_identity = plan
409 .rollbacks()
410 .iter()
411 .map(|rollback| rollback.digest().to_hex())
412 .collect::<Vec<_>>()
413 .join(":");
414 let reverse_backfills = plan
415 .rollbacks()
416 .iter()
417 .map(|rollback| rollback.backfills().len())
418 .sum::<usize>();
419 match output {
420 RollbackOutput::Text => println!(
421 "rollback preview\n environment: {environment}\n basis: committed-history\n plan identity: {plan_identity}\n order: {}\n target applied: {}\n safety: {}\n reverse backfills: {reverse_backfills}\n execution requested: {execute}",
422 order.join(", "),
423 target.join(", "),
424 safety.join(", "),
425 ),
426 RollbackOutput::Json => {
427 let strings = |values: &[String]| {
428 values
429 .iter()
430 .map(|value| format!("\"{value}\""))
431 .collect::<Vec<_>>()
432 .join(",")
433 };
434 println!(
435 "{{\"basis\":\"committed-history\",\"environment\":\"{environment}\",\"execute\":{execute},\"format\":\"typebridge.migration-rollback-preview/v1\",\"order\":[{}],\"plan_identity\":\"{plan_identity}\",\"reverse_backfills\":{reverse_backfills},\"safety\":[{}],\"target_applied\":[{}]}}",
436 strings(&order),
437 strings(&safety),
438 strings(&target),
439 );
440 }
441 }
442 Ok(())
443}
444
445fn rollback_safety_wire(safety: type_bridge_schema::SafetyClass) -> &'static str {
446 match safety {
447 type_bridge_schema::SafetyClass::FormalOnly => "formal_only",
448 type_bridge_schema::SafetyClass::SchemaMetadata => "schema_metadata",
449 type_bridge_schema::SafetyClass::Additive => "additive",
450 type_bridge_schema::SafetyClass::Conditional => "conditional",
451 type_bridge_schema::SafetyClass::BackfillRequired => "backfill_required",
452 type_bridge_schema::SafetyClass::Destructive => "destructive",
453 type_bridge_schema::SafetyClass::Opaque => "opaque",
454 type_bridge_schema::SafetyClass::Unsupported => "unsupported",
455 }
456}
457
458fn load_workspace(manifest: &PathBuf) -> Result<TypeBridgeWorkspace, String> {
459 let manifest = fs::canonicalize(manifest)
460 .map_err(|error| format!("cannot resolve {}: {error}", manifest.display()))?;
461 let root = manifest
462 .parent()
463 .ok_or_else(|| "workspace manifest has no parent directory".to_owned())?;
464 let file_name = manifest
465 .file_name()
466 .and_then(|name| name.to_str())
467 .ok_or_else(|| "workspace manifest has no UTF-8 file name".to_owned())?;
468 let root = WorkspaceRoot::new(root).map_err(display)?;
469 let source = WorkspaceDirectoryAuthority::open(root.clone()).map_err(display)?;
470 let origin = ConfigOrigin::new(root, file_name, "type-bridge cli").map_err(display)?;
471 let limit = type_bridge_contract::limits::MAX_CANONICAL_BYTES;
475 let captured = source
476 .capture_relative_file(Path::new(file_name), limit)
477 .map_err(|error| format!("cannot read {}: {error}", manifest.display()))?;
478 let bytes = captured.bytes();
479 if bytes.len() > limit {
480 return Err(format!(
481 "{} exceeds the 16 MiB manifest ceiling",
482 manifest.display()
483 ));
484 }
485 let located = TypeBridgeConfigSpec::from_yaml_bytes(bytes, origin).map_err(display)?;
486
487 let available = execution_capability_vocabulary().map_err(display)?;
488 let secrets = DeferSecrets;
489 let extensions = NoExtensions;
490 let services = TypeBridgeWorkspaceServices::new(&source, &secrets, &extensions, &available);
491 TypeBridgeWorkspace::from_located_config(located, &services).map_err(display)
493}
494
495fn display(error: impl std::fmt::Display) -> String {
496 error.to_string()
497}
498
499fn sanitize_connected_error(
506 context: String,
507 code: &'static str,
508 error: type_bridge_orm::SecureConnectError,
509) -> String {
510 match error.credential_safe_diagnostic() {
511 Some(diagnostic) => format!("{context}: {diagnostic}"),
512 None => format!("{context} [{code}]; inspect provider logs"),
513 }
514}
515
516fn sanitize_connected_orm_error(
521 context: String,
522 code: &'static str,
523 _error: type_bridge_orm::OrmError,
524) -> String {
525 format!("{context} [{code}]; inspect provider logs")
526}
527
528fn sanitize_schema_export_error(_error: type_bridge_orm::OrmError) -> String {
531 "cannot export the managed schema [typedb_schema_export_failed]; inspect provider logs"
532 .to_owned()
533}
534
535fn sanitize_migration_execution_outcome(
542 context: &str,
543 outcome: type_bridge_schema_migration::MigrationExecutionOutcome,
544) -> String {
545 use type_bridge_schema_migration::{
546 MigrationExecutionOutcome as Outcome, MigrationExecutionPosition as Position,
547 };
548
549 let render_position = |position| match position {
550 Position::TransactionGroup(ordinal) => format!("transaction group {ordinal}"),
551 Position::BackfillStep(ordinal) => format!("backfill step {ordinal}"),
552 Position::ManifestCheckpoint => "manifest checkpoint".to_owned(),
553 };
554 match outcome {
555 Outcome::Applied { .. } => format!("{context}: applied"),
556 Outcome::RetrySafe {
557 migration_id,
558 position,
559 diagnostic,
560 } => format!(
561 "{context}: retry-safe at {}/{} ({position}): {diagnostic}",
562 migration_id.app_label().as_str(),
563 migration_id.name().as_str(),
564 position = render_position(position),
565 ),
566 Outcome::RequiresExplicitRecovery {
567 migration_id,
568 position,
569 diagnostic,
570 } => format!(
571 "{context}: explicit recovery required at {}/{} ({position}): {diagnostic}",
572 migration_id.app_label().as_str(),
573 migration_id.name().as_str(),
574 position = render_position(position),
575 ),
576 }
577}
578
579fn sanitize_migration_rollback_outcome(
580 outcome: type_bridge_schema_migration::MigrationRollbackOutcome,
581) -> String {
582 use type_bridge_schema_migration::MigrationRollbackOutcome as Outcome;
583 match outcome {
584 Outcome::RolledBack { .. } => "rollback completed".to_owned(),
585 Outcome::RetrySafe {
586 migration_id,
587 step_ordinal,
588 diagnostic,
589 } => format!(
590 "rollback may be retried at {}/{} step {} [{}]",
591 migration_id.app_label().as_str(),
592 migration_id.name().as_str(),
593 step_ordinal,
594 diagnostic.code().as_str(),
595 ),
596 Outcome::RequiresExplicitRecovery {
597 migration_id,
598 step_ordinal,
599 diagnostic,
600 } => format!(
601 "rollback requires explicit recovery at {}/{} step {} [{}]",
602 migration_id.app_label().as_str(),
603 migration_id.name().as_str(),
604 step_ordinal,
605 diagnostic.code().as_str(),
606 ),
607 }
608}
609
610fn run_schema_generate(workspace: &TypeBridgeWorkspace) -> Result<(), String> {
620 run_schema_generate_with(workspace, |target, resolved, authority| {
621 generate_binding_package(target, resolved, authority, workspace.config().app_label())
622 })
623}
624
625fn generate_binding_package(
626 target: type_bridge_contract::projection::BindingTarget,
627 resolved: &type_bridge_schema::ResolvedSchema,
628 authority: &type_bridge_schema::VerifiedSchemaAuthority,
629 app_label: &type_bridge_contract::migration::MigrationAppLabel,
630) -> Result<type_bridge_schema_codegen::GeneratedPackage, String> {
631 use type_bridge_contract::projection::{BindingTarget, ProjectionConfig};
632 use type_bridge_schema::project;
633 use type_bridge_schema_codegen::{CEmitter, PythonEmitter, RustEmitter, TypeScriptEmitter};
634
635 match target {
636 BindingTarget::Python => {
637 let emitter = PythonEmitter::new();
638 let handlers = emitter.generator_handlers_for(resolved);
639 let resources = emitter.code_resources_for(resolved).map_err(display)?;
640 let projection = project(
641 resolved,
642 BindingTarget::Python,
643 &ProjectionConfig::python(),
644 &handlers,
645 &resources,
646 )
647 .map_err(display)?;
648 emitter.emit(&projection, authority)
649 }
650 BindingTarget::TypeScript => {
651 let emitter = TypeScriptEmitter::new();
652 let handlers = emitter.generator_handlers_for(resolved);
653 let resources = emitter.code_resources_for(resolved).map_err(display)?;
654 let projection = project(
655 resolved,
656 BindingTarget::TypeScript,
657 &ProjectionConfig::typescript(),
658 &handlers,
659 &resources,
660 )
661 .map_err(display)?;
662 emitter.emit(&projection, authority)
663 }
664 BindingTarget::Rust => {
665 let emitter = RustEmitter::new();
666 let handlers = emitter.generator_handlers_for(resolved);
667 let resources = emitter.code_resources_for(resolved).map_err(display)?;
668 let projection = project(
669 resolved,
670 BindingTarget::Rust,
671 &ProjectionConfig::rust(),
672 &handlers,
673 &resources,
674 )
675 .map_err(display)?;
676 emitter.emit(&projection, authority)
677 }
678 BindingTarget::C => {
679 let emitter = CEmitter::new();
680 let handlers = emitter.generator_handlers_for(resolved);
681 let resources = emitter.code_resources_for(resolved).map_err(display)?;
682 let config = ProjectionConfig::c(c_symbol_prefix_for_app_label(app_label));
683 let projection = project(resolved, BindingTarget::C, &config, &handlers, &resources)
684 .map_err(display)?;
685 emitter.emit(&projection, authority)
686 }
687 _ => {
688 return Err(format!(
689 "schema generation does not support binding target {}",
690 target.as_str()
691 ));
692 }
693 }
694 .map_err(display)
695}
696
697fn run_schema_generate_with(
698 workspace: &TypeBridgeWorkspace,
699 mut generate: impl FnMut(
700 type_bridge_contract::projection::BindingTarget,
701 &type_bridge_schema::ResolvedSchema,
702 &type_bridge_schema::VerifiedSchemaAuthority,
703 ) -> Result<type_bridge_schema_codegen::GeneratedPackage, String>,
704) -> Result<(), String> {
705 use type_bridge_schema::{build_schema_authority, encode_schema_authority};
706
707 let outputs = workspace.config().outputs();
708 let authority_output = workspace.config().schema_authority_output();
709 if outputs.is_empty() && authority_output.is_none() {
710 return Err(
711 "no generated outputs configured; add bindings.<target>.output or \
712 artifacts.schema-authority.output to the manifest"
713 .into(),
714 );
715 }
716
717 let resolved = workspace.resolved_schema();
718 let migration_directory = workspace.ensure_migration_directory().map_err(display)?;
719 let migration_history = workspace
720 .migration_history_bundle_in(&migration_directory)
721 .map_err(display)?;
722 let authority = build_schema_authority(
723 workspace.declared_schema(),
724 workspace.required_capabilities(),
725 workspace.delta_context(),
726 )
727 .map_err(display)?;
728 let authority_bytes = encode_schema_authority(&authority);
729
730 let mut packages = Vec::with_capacity(outputs.len());
734 for (&target, directory) in outputs {
735 let package = generate(target, resolved, &authority)?
736 .with_migration_history_bundle(&migration_history)
737 .map_err(display)?;
738 packages.push((target, directory, package));
739 }
740
741 let workspace_root = workspace.output_root()?;
746 let mut generated_files = Vec::new();
747 let mut generated_packages = Vec::with_capacity(packages.len());
748 for (target, directory, package) in &packages {
749 let display_root = workspace_root.display_path().join(directory.as_path());
750 let file_count = package.files().len();
751 for (path, bytes) in package.files() {
752 let relative = std::path::Path::new(path);
753 validate_generated_relative_path(relative)?;
754 generated_files.push((directory.as_path().join(relative), bytes.as_slice()));
755 }
756 generated_packages.push((*target, display_root, file_count));
757 }
758 let prepared_authority = authority_output
759 .map(|output| {
760 let path = output.as_path();
761 let _file_name = path
762 .file_name()
763 .ok_or_else(|| "schema-authority output has no file name".to_owned())?;
764 Ok::<_, String>(path.to_path_buf())
765 })
766 .transpose()?;
767
768 if let Some(relative) = &prepared_authority {
769 generated_files.push((relative.clone(), authority_bytes.as_slice()));
770 }
771 workspace_root.write_atomic_batch(
772 generated_files
773 .iter()
774 .map(|(path, bytes)| (path.as_path(), *bytes)),
775 )?;
776
777 for (target, display_root, file_count) in generated_packages {
778 println!(
779 "generated {} file(s) for {} into {}",
780 file_count,
781 target.as_str(),
782 display_root.display(),
783 );
784 }
785 if let Some(relative) = prepared_authority {
786 println!(
787 "generated schema authority at {}\n authority identity: {}",
788 workspace_root.display_path().join(relative).display(),
789 authority.authority_fingerprint().digest().to_hex(),
790 );
791 }
792 Ok(())
793}
794
795#[cfg(test)]
796mod schema_generation_atomicity_tests {
797 use std::collections::BTreeMap;
798 use std::env;
799 use std::fs::OpenOptions;
800 use std::io::Write as _;
801
802 use super::*;
803 use serde_json::json;
804 use sha2::{Digest as _, Sha256};
805 use type_bridge_contract::codec::to_canonical_json;
806 use type_bridge_contract::projection::BindingTarget;
807
808 const ARTIFACT_OUTPUT_ENV: &str = "TYPE_BRIDGE_SDK_V3_ATOMIC_GENERATION_OUTPUT";
809 const ARTIFACT_SOURCE_PATH: &str = "type-bridge-core/crates/cli/src/lib.rs";
810 const ARTIFACT_FORMAT: &str = "typebridge.sdk-v3-artifact-observation/v1";
811 const MAX_ARTIFACT_BYTES: usize = 64 * 1024;
812
813 fn publish_atomic_generation_observation(observation: serde_json::Value) {
814 let Some(output) = env::var_os(ARTIFACT_OUTPUT_ENV) else {
815 return;
816 };
817 let output = PathBuf::from(output);
818 assert!(
819 output.is_absolute(),
820 "{ARTIFACT_OUTPUT_ENV} must be absolute"
821 );
822 let parent = output
823 .parent()
824 .expect("atomic-generation artifact path has a parent");
825 let parent_metadata =
826 fs::symlink_metadata(parent).expect("atomic-generation artifact parent is inspectable");
827 assert!(
828 parent_metadata.is_dir() && !parent_metadata.file_type().is_symlink(),
829 "atomic-generation artifact parent must be a real directory"
830 );
831 let source = include_bytes!("lib.rs");
832 let artifact = json!({
833 "format": ARTIFACT_FORMAT,
834 "semantic_profile": "typedb-3.12.1/v1",
835 "producer": {
836 "id": "type-bridge-cli.atomic-multibinding-v3-artifact",
837 "source": {
838 "path": ARTIFACT_SOURCE_PATH,
839 "sha256": format!("{:x}", Sha256::digest(source)),
840 },
841 "test_id": "schema_generation_atomicity_tests::injected_c_emitter_failure_preserves_all_four_ordered_packages",
842 },
843 "result": {
844 "observation_ref": "atomic_multibinding_generation",
845 "outcome": "passed",
846 "proof_kind": "artifact",
847 "observation": observation,
848 },
849 });
850 let mut bytes =
851 to_canonical_json(&artifact).expect("atomic-generation artifact encodes canonically");
852 bytes.push(b'\n');
853 assert!(
854 bytes.len() <= MAX_ARTIFACT_BYTES,
855 "atomic-generation artifact exceeds {MAX_ARTIFACT_BYTES} bytes"
856 );
857 let mut destination = OpenOptions::new()
858 .write(true)
859 .create_new(true)
860 .open(&output)
861 .expect("atomic-generation artifact destination must be new");
862 if let Err(error) = destination
863 .write_all(&bytes)
864 .and_then(|()| destination.sync_all())
865 {
866 drop(destination);
867 let _ = fs::remove_file(&output);
868 panic!("atomic-generation artifact publication failed: {error}");
869 }
870 }
871
872 fn snapshot(root: &Path) -> BTreeMap<PathBuf, Vec<u8>> {
873 let mut files = BTreeMap::new();
874 let mut directories = vec![root.to_path_buf()];
875 while let Some(directory) = directories.pop() {
876 for entry in fs::read_dir(&directory).expect("generated directory reads") {
877 let path = entry.expect("generated entry reads").path();
878 if path.is_dir() {
879 directories.push(path);
880 } else {
881 files.insert(
882 path.strip_prefix(root)
883 .expect("generated path is beneath its root")
884 .to_path_buf(),
885 fs::read(path).expect("generated file reads"),
886 );
887 }
888 }
889 }
890 files
891 }
892
893 fn write_workspace(root: &Path, source: &str) -> PathBuf {
894 fs::create_dir_all(root.join("schema/fragments")).expect("schema directory creates");
895 fs::create_dir_all(root.join("migrations/v2")).expect("migration directory creates");
896 fs::write(
897 root.join("typebridge.yaml"),
898 "format: typebridge.workspace/v1\n\
899 schema:\n root: schema/schema.yaml\n ownership: exclusive\n managed-scope: ordered-atomic\n\
900 compatibility:\n semantic-profile: typedb-3.12.1/v1\n\
901 migrations:\n directory: migrations/v2\n app-label: ordered_atomic\n\
902 bindings:\n python:\n output: generated/python\n typescript:\n output: generated/typescript\n rust:\n output: generated/rust\n c:\n output: generated/c\n\
903 artifacts:\n schema-authority:\n output: generated/schema-authority.json\n",
904 )
905 .expect("manifest writes");
906 fs::write(
907 root.join("schema/schema.yaml"),
908 "format: typebridge.schema-set/v1\nsources: [fragments/*.yaml]\n",
909 )
910 .expect("schema set writes");
911 fs::write(root.join("schema/fragments/model.yaml"), source).expect("schema writes");
912 root.join("typebridge.yaml")
913 }
914
915 #[test]
916 fn injected_c_emitter_failure_preserves_all_four_ordered_packages() {
917 let directory = tempfile::tempdir().expect("workspace directory");
918 let root = directory.path();
919 let manifest = write_workspace(
920 root,
921 "format: typebridge.schema/v2\n\
922 attributes:\n identifier: { value: string }\n tag: { value: string }\n\
923 entities:\n person:\n owns:\n identifier: { key: true }\n tag: { card: { min: 0, max: 3 }, ordered: true, distinct: true }\n\
924 relations:\n group:\n relates:\n member: { card: { min: 0, max: 3 }, ordered: true, distinct: true }\n\
925 plays:\n person:\n group:\n member: { card: { min: 0, max: 1 } }\n",
926 );
927 let accepted = load_workspace(&manifest).expect("ordered workspace loads");
928 run_schema_generate(&accepted).expect("ordered packages generate");
929
930 let accepted_trees = ["python", "typescript", "rust", "c"]
931 .map(|target| (target, snapshot(&root.join("generated").join(target))));
932 let expected_history = accepted
933 .migration_history_bundle_bytes()
934 .expect("canonical migration history bundle");
935 for (target, tree) in &accepted_trees {
936 assert_eq!(
937 tree.get(std::path::Path::new(
938 type_bridge_schema_codegen::MIGRATION_HISTORY_BUNDLE_RESOURCE,
939 )),
940 Some(&expected_history),
941 "{target} package must embed the byte-identical canonical history bundle",
942 );
943 }
944 let accepted_authority =
945 fs::read(root.join("generated/schema-authority.json")).expect("authority reads");
946
947 run_schema_generate(&accepted).expect("identical ordered packages regenerate");
948 for (target, accepted_tree) in &accepted_trees {
949 assert_eq!(
950 &snapshot(&root.join("generated").join(target)),
951 accepted_tree,
952 "{target} destination changed after deterministic regeneration",
953 );
954 }
955 assert_eq!(
956 fs::read(root.join("generated/schema-authority.json")).expect("authority rereads"),
957 accepted_authority,
958 "schema authority changed after deterministic regeneration",
959 );
960
961 fs::write(
962 root.join("schema/fragments/model.yaml"),
963 "format: typebridge.schema/v2\n\
964 attributes:\n identifier: { value: string }\n tag: { value: string }\n title: { value: string }\n\
965 entities:\n person:\n owns:\n identifier: { key: true }\n tag: { card: { min: 0, max: 4 }, ordered: true, distinct: true }\n title: { card: 1 }\n\
966 relations:\n group:\n relates:\n member: { card: { min: 0, max: 4 }, ordered: true, distinct: true }\n\
967 plays:\n person:\n group:\n member: { card: { min: 0, max: 1 } }\n",
968 )
969 .expect("changed schema writes");
970 let changed = load_workspace(&manifest).expect("changed ordered workspace loads");
971 let mut attempted = Vec::new();
972 let error = run_schema_generate_with(&changed, |target, resolved, authority| {
973 attempted.push(target);
974 if target == BindingTarget::C {
975 return Err("injected C emitter failure".to_owned());
976 }
977 generate_binding_package(target, resolved, authority, changed.config().app_label())
978 })
979 .expect_err("injected C emitter failure rejects the transaction");
980 assert_eq!(error, "injected C emitter failure");
981 assert_eq!(
982 attempted,
983 vec![
984 BindingTarget::Python,
985 BindingTarget::TypeScript,
986 BindingTarget::Rust,
987 BindingTarget::C,
988 ],
989 "the injected failure did not occur after the three earlier packages prepared",
990 );
991
992 for (target, accepted_tree) in &accepted_trees {
993 assert_eq!(
994 snapshot(&root.join("generated").join(target)),
995 *accepted_tree,
996 "{target} destination changed after the injected C emitter failure",
997 );
998 }
999 assert_eq!(
1000 fs::read(root.join("generated/schema-authority.json")).expect("authority rereads"),
1001 accepted_authority,
1002 "schema authority changed after the injected C emitter failure",
1003 );
1004
1005 publish_atomic_generation_observation(json!({
1006 "targets": ["python", "typescript", "rust", "c"],
1007 "common_authority_identity": {
1008 "schema_source_equal": true,
1009 "semantic_profile": "typedb-3.12.1/v1",
1010 "semantic_fingerprint_equal": true,
1011 "resource_ledger_equal": true,
1012 },
1013 "package_identities_distinct": true,
1014 "generated_sidecars": [],
1015 "no_sidecar_runtime_dependency": true,
1016 "deterministic_rerun": {
1017 "byte_identical": true,
1018 "published_targets": accepted_trees.len(),
1019 },
1020 "injected_failure": {
1021 "failed_target": "c",
1022 "published_targets": 0,
1023 "previous_outputs_unchanged": true,
1024 "staging_artifacts_remaining": 0,
1025 },
1026 }));
1027 }
1028}
1029
1030fn run_schema_export_declared(
1032 workspace: &TypeBridgeWorkspace,
1033 output: &Path,
1034) -> Result<(), String> {
1035 use type_bridge_contract::schema::encode_declared_schema;
1036
1037 validate_declared_output_path(output)?;
1038 let root = workspace.output_root()?;
1039 let parent = root.open_beneath(output.parent().unwrap_or_else(|| std::path::Path::new("")))?;
1040 let file_name = output
1041 .file_name()
1042 .ok_or_else(|| "declared-schema output has no file name".to_owned())?;
1043 let destination = parent.display_path().join(file_name);
1044 let bytes = encode_declared_schema(workspace.declared_schema()).map_err(display)?;
1045 parent.write_atomic(file_name, &bytes)?;
1046 println!(
1047 "wrote canonical declared schema to {}\n declared identity: {}",
1048 destination.display(),
1049 workspace
1050 .declared_schema()
1051 .declared_identity_fingerprint()
1052 .as_fingerprint()
1053 .digest()
1054 .to_hex(),
1055 );
1056 Ok(())
1057}
1058
1059fn validate_declared_output_path(output: &Path) -> Result<(), String> {
1060 let Some(portable) = output.to_str() else {
1061 return Err("declared-schema output must be valid UTF-8".into());
1062 };
1063 let invalid_spelling = portable.is_empty()
1064 || portable.contains(['\\', ':', '\0'])
1065 || portable.bytes().any(|byte| byte.is_ascii_control())
1066 || portable
1067 .split('/')
1068 .any(|segment| segment.is_empty() || matches!(segment, "." | ".."));
1069 let invalid_components = output.is_absolute()
1070 || output
1071 .components()
1072 .any(|component| !matches!(component, Component::Normal(_)));
1073 if invalid_spelling || invalid_components {
1074 return Err("declared-schema output must be a confined portable workspace path".into());
1075 }
1076 if output.extension().and_then(|extension| extension.to_str()) != Some("json") {
1077 return Err("declared-schema output must end in lowercase .json".into());
1078 }
1079 Ok(())
1080}
1081
1082fn validate_generated_relative_path(path: &std::path::Path) -> Result<(), String> {
1083 if path.as_os_str().is_empty()
1084 || path
1085 .components()
1086 .any(|component| !matches!(component, std::path::Component::Normal(_)))
1087 {
1088 return Err(format!(
1089 "generated output path {:?} is not a confined relative file",
1090 path
1091 ));
1092 }
1093 Ok(())
1094}
1095
1096enum ConnectedAction {
1097 Apply {
1098 approvals: Vec<String>,
1099 },
1100 Verify,
1101 Rollback {
1102 removals: BTreeSet<type_bridge_contract::migration::MigrationId>,
1103 approvals: Vec<String>,
1104 },
1105 Adopt {
1106 archive_directory: PathBuf,
1107 name: String,
1108 },
1109}
1110
1111fn secure_connect_options(
1112 environment: &WorkspaceEnvironment,
1113) -> type_bridge_orm::SecureConnectOptions {
1114 let tls_mode = match environment.transport_policy() {
1115 WorkspaceTransportPolicy::Disabled => type_bridge_orm::TlsMode::Disabled,
1116 WorkspaceTransportPolicy::NativeRoots => type_bridge_orm::TlsMode::NativeRoots,
1117 WorkspaceTransportPolicy::CustomRootCa(root_ca) => {
1118 type_bridge_orm::TlsMode::CustomRootCa(root_ca.as_path().to_path_buf())
1119 }
1120 };
1121 let mut options = type_bridge_orm::SecureConnectOptions {
1122 tls_mode,
1123 ..type_bridge_orm::SecureConnectOptions::default()
1124 };
1125 if let Some(port) = environment.http_port() {
1126 options.http_port = port;
1127 }
1128 options
1129}
1130
1131fn preflight_secure_connect_options(
1132 workspace: &TypeBridgeWorkspace,
1133 environment_name: &str,
1134) -> Result<type_bridge_orm::PreparedSecureConnectOptions, String> {
1135 let environment = workspace
1136 .config()
1137 .environment(environment_name)
1138 .ok_or_else(|| {
1139 format!("environment {environment_name:?} is not owned by this workspace")
1140 })?;
1141 let options = secure_connect_options(environment);
1142 match workspace
1143 .capture_environment_custom_root_ca(environment_name)
1144 .map_err(display)?
1145 {
1146 Some(bytes) => options
1147 .prepare_transport_from_captured_custom_root(bytes)
1148 .map_err(display),
1149 None => options.prepare_transport().map_err(display),
1150 }
1151}
1152
1153fn run_connected(
1154 workspace: &TypeBridgeWorkspace,
1155 environment: &str,
1156 action: ConnectedAction,
1157) -> Result<(), String> {
1158 let runtime = tokio::runtime::Runtime::new()
1159 .map_err(|error| format!("cannot start the async runtime: {error}"))?;
1160 runtime.block_on(run_connected_async(workspace, environment, action))
1161}
1162
1163async fn run_connected_async(
1164 workspace: &TypeBridgeWorkspace,
1165 environment_name: &str,
1166 action: ConnectedAction,
1167) -> Result<(), String> {
1168 let config = workspace.config();
1169 let Some(environment) = config.environment(environment_name) else {
1170 let known = config
1171 .environments()
1172 .keys()
1173 .cloned()
1174 .collect::<Vec<_>>()
1175 .join(", ");
1176 return Err(format!(
1177 "unknown environment {environment_name:?}; the manifest declares: [{known}]"
1178 ));
1179 };
1180 if matches!(
1181 &action,
1182 ConnectedAction::Apply { .. }
1183 | ConnectedAction::Rollback { .. }
1184 | ConnectedAction::Adopt { .. }
1185 ) && !environment.migrate()
1186 {
1187 return Err(format!(
1188 "environment {environment_name:?} is not opted into migration \
1189 application; set `migrate: true` in the manifest to allow it"
1190 ));
1191 }
1192 let supported = &type_bridge_schema_migration::typedb_3_12_1_profile().semantic_profile;
1193 if config.semantic_profile() != supported {
1194 return Err(format!(
1195 "workspace semantic profile {:?} cannot run connected TypeDB migration operations \
1196 [migration_typedb_semantic_profile_unsupported]; expected {:?}",
1197 config.semantic_profile().as_str(),
1198 supported.as_str(),
1199 ));
1200 }
1201 environment
1202 .requirements()
1203 .ensure_supported_by(&execution_capability_vocabulary().map_err(display)?)
1204 .map_err(display)?;
1205
1206 let prepared_adoption = match &action {
1209 ConnectedAction::Adopt {
1210 archive_directory,
1211 name,
1212 } => Some(prepare_archive_adoption(
1213 workspace,
1214 archive_directory,
1215 name,
1216 )?),
1217 ConnectedAction::Apply { .. }
1218 | ConnectedAction::Rollback { .. }
1219 | ConnectedAction::Verify => None,
1220 };
1221
1222 let migration_directory = if matches!(&action, ConnectedAction::Adopt { .. }) {
1226 workspace.ensure_migration_directory().map_err(display)?
1227 } else {
1228 workspace.open_migration_directory().map_err(display)?
1229 };
1230 let ordinary_graph = if prepared_adoption.is_none() {
1234 Some(
1235 workspace
1236 .discover_migrations_in(&migration_directory)
1237 .map_err(display)?,
1238 )
1239 } else {
1240 None
1241 };
1242 let prepared_approvals = match &action {
1247 ConnectedAction::Apply { approvals } => Some(bind_approvals(
1248 ordinary_graph
1249 .as_ref()
1250 .ok_or_else(|| "internal apply history was not retained".to_owned())?,
1251 approvals,
1252 )?),
1253 ConnectedAction::Rollback { approvals, .. } => Some(bind_rollback_approvals(
1254 ordinary_graph
1255 .as_ref()
1256 .ok_or_else(|| "internal rollback history was not retained".to_owned())?,
1257 approvals,
1258 )?),
1259 ConnectedAction::Verify | ConnectedAction::Adopt { .. } => None,
1260 };
1261
1262 let options = preflight_secure_connect_options(workspace, environment_name)?;
1266 let username = resolve_credential(environment.username())?;
1267 let password = resolve_credential(environment.password())?;
1268 let journal_name =
1269 type_bridge_schema_migration_typedb::derived_journal_database_name(environment.database());
1270 let managed_requires_existing = match &action {
1277 ConnectedAction::Verify => Some(
1278 "`migration verify` is read-only and never creates databases \
1279 — apply migrations to this environment first",
1280 ),
1281 ConnectedAction::Adopt { .. } => {
1282 Some("`migration adopt` cutover requires the migrated v1 database to already exist")
1283 }
1284 ConnectedAction::Apply { .. } | ConnectedAction::Rollback { .. } => None,
1285 };
1286 let managed = std::sync::Arc::new(
1291 type_bridge_orm::Database::connect_prepared_secure_with_options(
1292 environment.uri(),
1293 environment.database(),
1294 &username,
1295 &password,
1296 options.clone(),
1297 )
1298 .await
1299 .map_err(|error| {
1300 sanitize_connected_error(
1301 "cannot connect the managed database".to_owned(),
1302 "typedb_database_connect_failed",
1303 error,
1304 )
1305 })?,
1306 );
1307 let journal = std::sync::Arc::new(
1308 type_bridge_orm::Database::connect_prepared_secure_with_options(
1309 environment.uri(),
1310 &journal_name,
1311 &username,
1312 &password,
1313 options,
1314 )
1315 .await
1316 .map_err(|error| {
1317 sanitize_connected_error(
1318 "cannot connect the journal database".to_owned(),
1319 "typedb_database_connect_failed",
1320 error,
1321 )
1322 })?,
1323 );
1324 type_bridge_schema_migration_typedb::require_supported_migration_execution_binding(
1325 &managed,
1326 &journal,
1327 workspace.delta_context(),
1328 )
1329 .map_err(display)?;
1330
1331 if let Some(reason) = managed_requires_existing {
1332 let exists = managed.database_exists().await.map_err(|error| {
1333 sanitize_connected_orm_error(
1334 format!("cannot check database {:?}", environment.database()),
1335 "typedb_database_exists_failed",
1336 error,
1337 )
1338 })?;
1339 if !exists {
1340 return Err(format!(
1341 "database {:?} does not exist; {reason}",
1342 environment.database()
1343 ));
1344 }
1345 } else {
1346 managed.create_database().await.map_err(|error| {
1347 sanitize_connected_orm_error(
1348 format!("cannot ensure database {:?}", environment.database()),
1349 "typedb_database_ensure_failed",
1350 error,
1351 )
1352 })?;
1353 }
1354
1355 let adoption_files = if let Some(prepared) = prepared_adoption.as_ref() {
1360 verify_prepared_adoption_live(&managed, prepared).await?;
1361 Some(publish_prepared_adoption(
1362 workspace,
1363 &migration_directory,
1364 prepared,
1365 )?)
1366 } else {
1367 None
1368 };
1369
1370 if matches!(&action, ConnectedAction::Verify) {
1371 let exists = journal.database_exists().await.map_err(|error| {
1372 sanitize_connected_orm_error(
1373 format!("cannot check database {journal_name:?}"),
1374 "typedb_database_exists_failed",
1375 error,
1376 )
1377 })?;
1378 if !exists {
1379 return Err(format!(
1380 "database {journal_name:?} does not exist; `migration verify` is read-only and never creates databases"
1381 ));
1382 }
1383 } else {
1384 journal.create_database().await.map_err(|error| {
1385 sanitize_connected_orm_error(
1386 format!("cannot ensure database {journal_name:?}"),
1387 "typedb_database_ensure_failed",
1388 error,
1389 )
1390 })?;
1391 }
1392
1393 let genesis = workspace
1394 .migration_genesis_in(&migration_directory)
1395 .map_err(display)?;
1396 let lowering = type_bridge_schema_migration::SchemaLoweringBinding::current(
1397 workspace.delta_context().available_capabilities().clone(),
1398 )
1399 .map_err(display)?;
1400 let runner = type_bridge_schema_migration_typedb::TypeDbMigrationRunner::new(
1401 managed,
1402 journal,
1403 genesis.clone(),
1404 workspace.delta_context().clone(),
1405 lowering,
1406 config.migration_policy().clone(),
1407 );
1408 let holder =
1409 type_bridge_schema_migration::LeaseHolderId::new("type-bridge-cli").map_err(display)?;
1410 let directory = migration_directory.directory();
1411
1412 match action {
1413 ConnectedAction::Apply { .. } => {
1414 let approvals = prepared_approvals
1415 .as_deref()
1416 .ok_or_else(|| "internal apply approvals were not retained".to_owned())?;
1417 let outcome = runner
1418 .apply_in(
1419 directory,
1420 &type_bridge_schema_migration::MigrationApplyTarget::DefaultHead,
1421 &holder,
1422 approvals,
1423 )
1424 .await
1425 .map_err(display)?;
1426 match outcome {
1427 type_bridge_schema_migration_typedb::MigrationDirectoryApplyOutcome::UpToDate => {
1428 println!("applied ledger already reaches the committed head");
1429 Ok(())
1430 }
1431 type_bridge_schema_migration_typedb::MigrationDirectoryApplyOutcome::Executed(
1432 type_bridge_schema_migration::MigrationExecutionOutcome::Applied { .. },
1433 ) => {
1434 println!("applied the committed chain");
1435 Ok(())
1436 }
1437 type_bridge_schema_migration_typedb::MigrationDirectoryApplyOutcome::Executed(
1438 outcome,
1439 ) => Err(sanitize_migration_execution_outcome(
1440 "apply did not complete",
1441 outcome,
1442 )),
1443 }
1444 }
1445 ConnectedAction::Rollback { removals, .. } => {
1446 let approvals = prepared_approvals
1447 .as_deref()
1448 .ok_or_else(|| "internal rollback approvals were not retained".to_owned())?;
1449 match runner
1450 .rollback_in(directory, &removals, &holder, approvals)
1451 .await
1452 .map_err(display)?
1453 {
1454 type_bridge_schema_migration_typedb::MigrationDirectoryRollbackOutcome::UpToDate => {
1455 println!("requested migrations are already absent from the applied ledger");
1456 Ok(())
1457 }
1458 type_bridge_schema_migration_typedb::MigrationDirectoryRollbackOutcome::Executed(
1459 type_bridge_schema_migration::MigrationRollbackOutcome::RolledBack { .. },
1460 ) => {
1461 println!("rolled back the requested migrations");
1462 Ok(())
1463 }
1464 type_bridge_schema_migration_typedb::MigrationDirectoryRollbackOutcome::Executed(
1465 outcome,
1466 ) => Err(sanitize_migration_rollback_outcome(outcome)),
1467 }
1468 }
1469 ConnectedAction::Verify => {
1470 let report = runner
1471 .verify_in(directory, Some(workspace.declared_schema()))
1472 .await
1473 .map_err(display)?;
1474 if report.is_clean() {
1475 println!(
1476 "migration state is coherent\n applied frontier: {}",
1477 report
1478 .applied_frontier()
1479 .iter()
1480 .map(|id| format!("{}/{}", id.app_label().as_str(), id.name().as_str()))
1481 .collect::<Vec<_>>()
1482 .join(", "),
1483 );
1484 Ok(())
1485 } else {
1486 for finding in report.findings() {
1487 eprintln!("drift: {finding:?}");
1488 }
1489 Err(format!("{} drift finding(s)", report.findings().len()))
1490 }
1491 }
1492 ConnectedAction::Adopt { .. } => {
1493 let bridge_display_path = adoption_files
1494 .ok_or_else(|| "internal adoption preflight state was not retained".to_owned())?;
1495 let prepared = prepared_adoption
1496 .as_ref()
1497 .ok_or_else(|| "internal adoption authority was not retained".to_owned())?;
1498 let outcome = runner
1499 .import_verified_legacy_frontier_in(
1500 &prepared.history,
1501 &prepared.reconstructed,
1502 directory,
1503 &holder,
1504 )
1505 .await;
1506 match outcome {
1507 Ok(
1508 type_bridge_schema_migration_typedb::MigrationDirectoryApplyOutcome::UpToDate,
1509 ) => {
1510 println!("archive history is already adopted; the bridged ledger is current");
1511 Ok(())
1512 }
1513 Ok(
1514 type_bridge_schema_migration_typedb::MigrationDirectoryApplyOutcome::Executed(
1515 type_bridge_schema_migration::MigrationExecutionOutcome::Applied { .. },
1516 ),
1517 ) => {
1518 println!(
1519 "adopted the archive history\n genesis: {}\n bridge: {}",
1520 migration_directory
1521 .display_path()
1522 .join(type_bridge_schema_compat::ADOPTED_GENESIS_FILE_NAME)
1523 .display(),
1524 bridge_display_path.display(),
1525 );
1526 Ok(())
1527 }
1528 Ok(
1529 type_bridge_schema_migration_typedb::MigrationDirectoryApplyOutcome::Executed(
1530 outcome,
1531 ),
1532 ) => Err(sanitize_migration_execution_outcome(
1533 "adoption checkpoint did not complete",
1534 outcome,
1535 )),
1536 Err(error) => Err(display(error)),
1537 }
1538 }
1539 }
1540}
1541
1542struct PreparedArchiveAdoption {
1543 history: type_bridge_migration::LegacyAdoptionHistory,
1544 reconstructed: type_bridge_migration::VerifiedLegacyHead,
1545 authority: type_bridge_schema_compat::AdoptedGenesisAuthority,
1546 bridge: type_bridge_schema_migration::VerifiedSchemaMigrationManifest,
1547 bridge_name: String,
1548 bridge_bytes: Vec<u8>,
1549}
1550
1551fn prepare_archive_adoption(
1554 workspace: &TypeBridgeWorkspace,
1555 archive_directory: &std::path::Path,
1556 name: &str,
1557) -> Result<PreparedArchiveAdoption, String> {
1558 let migration_name =
1561 type_bridge_contract::migration::MigrationName::new(name.to_owned()).map_err(display)?;
1562 let bridge_name = format!("{}.tbmigration.json", migration_name.as_str());
1563 let history =
1564 type_bridge_migration::load_adoption_history(archive_directory).map_err(|error| {
1565 format!("archive migration directory failed the checked adoption loader: {error}")
1566 })?;
1567 let reconstructed = type_bridge_migration::reconstruct_legacy_head(&history)
1568 .map_err(|error| format!("archive head reconstruction failed: {error}"))?;
1569 let authority = type_bridge_schema_compat::parse_adopted_genesis_authority(
1570 type_bridge_contract::schema::DocumentId::new("legacy-head-snapshot.typeql")
1571 .map_err(display)?,
1572 reconstructed.schema_typeql(),
1573 )
1574 .map_err(display)?;
1575 let frontier = type_bridge_schema_migration_typedb::extract_legacy_frontier(history.graph())
1576 .map_err(display)?;
1577 let applied_set =
1578 type_bridge_schema_migration_typedb::extract_legacy_applied_set_digest(history.graph())
1579 .map_err(display)?;
1580 let id = type_bridge_contract::migration::MigrationId::from_components(
1581 type_bridge_contract::migration::MigrationAppLabel::new(
1582 workspace.config().app_label().as_str().to_owned(),
1583 )
1584 .map_err(display)?,
1585 migration_name,
1586 );
1587 let bridge = type_bridge_schema_migration::build_legacy_frontier_bridge(
1588 id,
1589 frontier,
1590 applied_set,
1591 authority.declared(),
1592 workspace.delta_context(),
1593 )
1594 .map_err(display)?;
1595 let bridge_bytes =
1596 type_bridge_schema_migration::encode_verified_manifest(&bridge).map_err(display)?;
1597 history
1598 .require_unchanged_head(&reconstructed)
1599 .map_err(|error| {
1600 format!("archive migration directory changed during adoption preparation: {error}")
1601 })?;
1602 Ok(PreparedArchiveAdoption {
1603 history,
1604 reconstructed,
1605 authority,
1606 bridge,
1607 bridge_name,
1608 bridge_bytes,
1609 })
1610}
1611
1612async fn verify_prepared_adoption_live(
1615 managed: &type_bridge_orm::Database,
1616 prepared: &PreparedArchiveAdoption,
1617) -> Result<(), String> {
1618 let export = managed
1619 .schema_text()
1620 .await
1621 .map_err(sanitize_schema_export_error)?;
1622 prepared
1623 .history
1624 .require_unchanged_head(&prepared.reconstructed)
1625 .map_err(|error| format!("archive adoption history changed during live export: {error}"))?;
1626 let expected_internal = type_bridge_schema_compat::released_typeql_to_declared_projection(
1627 type_bridge_contract::schema::DocumentId::new("managed-fence-schema.typeql")
1628 .map_err(display)?,
1629 type_bridge_schema_migration_typedb::MANAGED_FENCE_SCHEMA_TYPEQL,
1630 )
1631 .map_err(display)?;
1632 let live = type_bridge_schema_compat::parse_adopted_genesis_authority_with_internal(
1633 type_bridge_contract::schema::DocumentId::new("legacy-live-head.typeql")
1634 .map_err(display)?,
1635 &export,
1636 Some(&expected_internal),
1637 )
1638 .map_err(display)?;
1639 if live.legacy_identity() != prepared.authority.legacy_identity()
1640 || live.declared().declared_identity_fingerprint()
1641 != prepared
1642 .authority
1643 .declared()
1644 .declared_identity_fingerprint()
1645 || live.released_extension_identity() != prepared.authority.released_extension_identity()
1646 {
1647 return Err(
1648 "live managed schema differs from the independently verified archive-head snapshot"
1649 .to_owned(),
1650 );
1651 }
1652 Ok(())
1653}
1654
1655fn publish_prepared_adoption(
1662 workspace: &TypeBridgeWorkspace,
1663 migration_directory: &type_bridge_workspace::MigrationDirectoryAuthority,
1664 prepared: &PreparedArchiveAdoption,
1665) -> Result<PathBuf, String> {
1666 publish_prepared_adoption_with_after_bridge(workspace, migration_directory, prepared, || {})
1667}
1668
1669fn publish_prepared_adoption_with_after_bridge<F>(
1670 workspace: &TypeBridgeWorkspace,
1671 migration_directory: &type_bridge_workspace::MigrationDirectoryAuthority,
1672 prepared: &PreparedArchiveAdoption,
1673 after_bridge: F,
1674) -> Result<PathBuf, String>
1675where
1676 F: FnOnce(),
1677{
1678 let directory = migration_directory.directory();
1679 let _lock = directory.try_acquire_authoring_lock().map_err(|error| {
1680 if error.kind() == std::io::ErrorKind::WouldBlock {
1681 "migration adoption conflicts with another canonical history publisher".to_owned()
1682 } else {
1683 format!("cannot lock canonical migration publication: {error}")
1684 }
1685 })?;
1686 let genesis_name = type_bridge_schema_compat::ADOPTED_GENESIS_FILE_NAME;
1687 let genesis_bytes = prepared.reconstructed.schema_typeql().as_bytes();
1688 let mut bridge_created = false;
1689 let mut genesis_created = false;
1690 let mut after_bridge = Some(after_bridge);
1691
1692 let publication = (|| -> Result<(), String> {
1693 if let Some(existing) = read_existing_authority(directory, genesis_name)?
1694 && existing != genesis_bytes
1695 {
1696 return Err(format!(
1697 "{genesis_name} already exists but differs from the verified archive-head snapshot"
1698 ));
1699 }
1700 let bridge_already_published =
1701 if let Some(existing) = read_existing_authority(directory, &prepared.bridge_name)? {
1702 if existing != prepared.bridge_bytes {
1703 return Err(format!(
1704 "{} already exists with different authority bytes",
1705 prepared.bridge_name
1706 ));
1707 }
1708 true
1709 } else {
1710 false
1711 };
1712
1713 let (current, evidence) =
1714 type_bridge_schema_migration::discover_verified_migration_chain_with_evidence_in(
1715 directory,
1716 prepared.authority.declared(),
1717 workspace.delta_context(),
1718 )
1719 .map_err(display)?;
1720 let prospective = if current.manifest(prepared.bridge.id()).is_some() {
1721 current
1722 } else {
1723 let manifests = current
1724 .manifests()
1725 .map(|(_, manifest)| manifest.clone())
1726 .chain(std::iter::once(prepared.bridge.clone()))
1727 .collect::<Vec<_>>();
1728 type_bridge_schema_migration::MigrationHistoryGraph::from_verified(manifests)
1729 .map_err(display)?
1730 };
1731 type_bridge_schema_migration::require_adoption_authority_pair(&prospective, true)
1732 .map_err(display)?;
1733 evidence.require_unchanged(directory).map_err(display)?;
1734 prepared
1735 .history
1736 .require_unchanged_head(&prepared.reconstructed)
1737 .map_err(|error| {
1738 format!("archive adoption history changed before pair publication: {error}")
1739 })?;
1740
1741 if !bridge_already_published {
1742 bridge_created =
1743 publish_authority(directory, &prepared.bridge_name, &prepared.bridge_bytes)?;
1744 }
1745 if let Some(after_bridge) = after_bridge.take() {
1746 after_bridge();
1747 }
1748 prepared
1749 .history
1750 .require_unchanged_head(&prepared.reconstructed)
1751 .map_err(|error| {
1752 format!("archive adoption history changed before genesis publication: {error}")
1753 })?;
1754 genesis_created = publish_authority(directory, genesis_name, genesis_bytes)?;
1755 prepared
1756 .history
1757 .require_unchanged_head(&prepared.reconstructed)
1758 .map_err(|error| {
1759 format!("archive adoption history changed after pair publication: {error}")
1760 })?;
1761 workspace
1762 .discover_migrations_in(migration_directory)
1763 .map_err(display)?;
1764 Ok(())
1765 })();
1766
1767 if let Err(error) = publication {
1768 return Err(rollback_adoption_publication(
1769 directory,
1770 &prepared.bridge_name,
1771 bridge_created,
1772 genesis_created,
1773 error,
1774 ));
1775 }
1776 Ok(migration_directory
1777 .display_path()
1778 .join(&prepared.bridge_name))
1779}
1780
1781fn rollback_adoption_publication(
1782 directory: &type_bridge_schema_migration::MigrationDirectory,
1783 bridge_name: &str,
1784 bridge_created: bool,
1785 genesis_created: bool,
1786 primary: String,
1787) -> String {
1788 let mut cleanup_errors = Vec::new();
1789 if genesis_created
1790 && let Err(error) =
1791 directory.remove_file(type_bridge_schema_compat::ADOPTED_GENESIS_FILE_NAME.as_ref())
1792 {
1793 cleanup_errors.push(format!("cannot remove newly published genesis: {error}"));
1794 }
1795 if bridge_created && let Err(error) = directory.remove_file(bridge_name.as_ref()) {
1796 cleanup_errors.push(format!("cannot remove newly published bridge: {error}"));
1797 }
1798 if (bridge_created || genesis_created)
1799 && let Err(error) = directory.sync_all()
1800 {
1801 cleanup_errors.push(format!("cannot flush adoption rollback: {error}"));
1802 }
1803 if cleanup_errors.is_empty() {
1804 primary
1805 } else {
1806 format!(
1807 "{primary}; adoption publication rollback failed: {}",
1808 cleanup_errors.join("; ")
1809 )
1810 }
1811}
1812
1813fn publish_authority(
1822 directory: &type_bridge_schema_migration::MigrationDirectory,
1823 name: &str,
1824 bytes: &[u8],
1825) -> Result<bool, String> {
1826 use std::io::Write;
1827 if let Some(existing) = read_existing_authority(directory, name)? {
1828 if existing == bytes {
1829 return Ok(false);
1830 }
1831 return Err(format!(
1832 "{name} already exists with different authority bytes"
1833 ));
1834 }
1835 let mut temporary = None;
1836 for attempt in 0..128_u64 {
1837 let candidate = unique_authority_temporary_name(name, attempt);
1838 match directory.create_new(candidate.as_ref()) {
1839 Ok(mut file) => {
1840 if let Err(error) = file.write_all(bytes).and_then(|()| file.sync_all()) {
1841 let _ = directory.remove_file(candidate.as_ref());
1842 return Err(format!("cannot write {candidate}: {error}"));
1843 }
1844 temporary = Some(candidate);
1845 break;
1846 }
1847 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
1848 Err(error) => {
1849 return Err(format!("cannot create {candidate}: {error}"));
1850 }
1851 }
1852 }
1853 let temporary = temporary.ok_or_else(|| {
1854 format!("cannot allocate a unique temporary authority file beside {name}")
1855 })?;
1856 let publication = directory.hard_link(temporary.as_ref(), name.as_ref());
1857 match publication {
1858 Ok(()) => {
1859 if let Err(error) = directory.sync_all() {
1860 let _ = directory.remove_file(temporary.as_ref());
1861 return Err(format!("cannot flush migration directory: {error}"));
1862 }
1863 let _ = directory.remove_file(temporary.as_ref());
1864 directory
1865 .sync_all()
1866 .map_err(|error| format!("cannot flush migration directory: {error}"))?;
1867 Ok(true)
1868 }
1869 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
1870 let _ = directory.remove_file(temporary.as_ref());
1871 let existing = read_existing_authority(directory, name)?
1872 .ok_or_else(|| format!("{name} disappeared during no-replace publication"))?;
1873 if existing == bytes {
1874 Ok(false)
1875 } else {
1876 Err(format!(
1877 "{name} was concurrently published with different authority bytes"
1878 ))
1879 }
1880 }
1881 Err(error) => {
1882 let _ = directory.remove_file(temporary.as_ref());
1883 Err(format!("cannot publish {name}: {error}"))
1884 }
1885 }
1886}
1887
1888fn read_existing_authority(
1889 directory: &type_bridge_schema_migration::MigrationDirectory,
1890 name: &str,
1891) -> Result<Option<Vec<u8>>, String> {
1892 use std::io::Read;
1893 let file = match directory.open_regular_readonly(name.as_ref()) {
1894 Ok(file) => file,
1895 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1896 Err(error) => return Err(format!("cannot read {name}: {error}")),
1897 };
1898 let limit = type_bridge_contract::limits::MAX_CANONICAL_BYTES;
1899 let mut bytes = Vec::new();
1900 file.take(u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1))
1901 .read_to_end(&mut bytes)
1902 .map_err(|error| format!("cannot read {name}: {error}"))?;
1903 if bytes.len() > limit {
1904 return Err(format!("{name} exceeds the 16 MiB authority ceiling"));
1905 }
1906 Ok(Some(bytes))
1907}
1908
1909fn unique_authority_temporary_name(name: &str, attempt: u64) -> String {
1910 use std::sync::atomic::{AtomicU64, Ordering};
1911 static NEXT_AUTHORITY_TEMPORARY: AtomicU64 = AtomicU64::new(1);
1912 let nonce = NEXT_AUTHORITY_TEMPORARY.fetch_add(1, Ordering::Relaxed);
1913 format!(".{name}.{}.{}.{}.tmp", std::process::id(), nonce, attempt)
1914}
1915
1916fn resolve_credential(
1917 reference: &type_bridge_workspace::SecretReference,
1918) -> Result<String, String> {
1919 std::env::var(reference.environment_variable()).map_err(|_| {
1920 format!(
1921 "credential environment variable {:?} is not set",
1922 reference.environment_variable()
1923 )
1924 })
1925}
1926
1927#[cfg(test)]
1928mod credential_error_redaction_tests {
1929 use super::*;
1930 use type_bridge_contract::diagnostic::{Diagnostic, DiagnosticCategory, DiagnosticCode};
1931 use type_bridge_typedb_runtime::RuntimeError;
1932
1933 const PROVIDER_TEXT: &str =
1934 "TB_ADDRESS_SECRET TB_USERNAME_SECRET TB_PASSWORD_SECRET TB_PROVIDER_SECRET";
1935 const SECRETS: [&str; 4] = [
1936 "TB_ADDRESS_SECRET",
1937 "TB_USERNAME_SECRET",
1938 "TB_PASSWORD_SECRET",
1939 "TB_PROVIDER_SECRET",
1940 ];
1941
1942 fn hostile_secure_error() -> type_bridge_orm::SecureConnectError {
1943 type_bridge_orm::SecureConnectError::Runtime(RuntimeError::Connection(
1944 PROVIDER_TEXT.to_owned(),
1945 ))
1946 }
1947
1948 #[test]
1949 fn connected_lifecycle_contexts_drop_hostile_provider_text() {
1950 for (context, code) in [
1951 (
1952 "cannot check database \"managed\"",
1953 "typedb_database_exists_failed",
1954 ),
1955 (
1956 "cannot ensure database \"managed\"",
1957 "typedb_database_ensure_failed",
1958 ),
1959 (
1960 "cannot connect the managed database",
1961 "typedb_database_connect_failed",
1962 ),
1963 ] {
1964 let sanitized =
1965 sanitize_connected_error(context.to_owned(), code, hostile_secure_error());
1966 let rendered = format!("{sanitized}\n{sanitized:?}");
1967 for secret in SECRETS {
1968 assert!(!rendered.contains(secret), "{secret}: {rendered}");
1969 }
1970 assert!(rendered.contains(context), "{rendered}");
1971 assert!(rendered.contains(code), "{rendered}");
1972 }
1973 }
1974
1975 #[test]
1976 fn connected_orm_lifecycle_contexts_drop_hostile_provider_text() {
1977 for (context, code) in [
1978 (
1979 "cannot check database \"managed\"",
1980 "typedb_database_exists_failed",
1981 ),
1982 (
1983 "cannot ensure database \"managed\"",
1984 "typedb_database_ensure_failed",
1985 ),
1986 ] {
1987 let sanitized = sanitize_connected_orm_error(
1988 context.to_owned(),
1989 code,
1990 type_bridge_orm::OrmError::Connection(PROVIDER_TEXT.to_owned()),
1991 );
1992 let rendered = format!("{sanitized}\n{sanitized:?}");
1993 for secret in SECRETS {
1994 assert!(!rendered.contains(secret), "{secret}: {rendered}");
1995 }
1996 assert!(rendered.contains(context), "{rendered}");
1997 assert!(rendered.contains(code), "{rendered}");
1998 }
1999 }
2000
2001 #[test]
2002 fn connected_lifecycle_preserves_only_typed_safe_diagnostics() {
2003 let sanitized = sanitize_connected_error(
2004 "cannot connect the managed database".to_owned(),
2005 "typedb_database_connect_failed",
2006 type_bridge_orm::SecureConnectError::DriverTlsConfiguration { band: 9 },
2007 );
2008 assert!(
2009 sanitized.contains("tls_driver_lowering_failed"),
2010 "{sanitized}"
2011 );
2012 assert!(sanitized.contains("driver band 9"), "{sanitized}");
2013 assert!(
2014 !sanitized.contains("typedb_database_connect_failed"),
2015 "{sanitized}"
2016 );
2017 }
2018
2019 #[test]
2020 fn schema_export_drops_hostile_orm_text_and_source() {
2021 let sanitized = sanitize_schema_export_error(type_bridge_orm::OrmError::Connection(
2022 PROVIDER_TEXT.into(),
2023 ));
2024 let rendered = format!("{sanitized}\n{sanitized:?}");
2025 for secret in SECRETS {
2026 assert!(!rendered.contains(secret), "{secret}: {rendered}");
2027 }
2028 assert!(
2029 rendered.contains("typedb_schema_export_failed"),
2030 "{rendered}"
2031 );
2032 }
2033
2034 #[test]
2035 fn migration_runner_display_omits_provider_details() {
2036 let diagnostic = Diagnostic::new(
2037 DiagnosticCategory::InvalidContract,
2038 DiagnosticCode::new("migration_provider_test_failed").expect("static code"),
2039 "migration provider operation failed",
2040 )
2041 .with_detail("provider", PROVIDER_TEXT);
2042 let error = type_bridge_schema_migration_typedb::MigrationDirectoryApplyError::Diagnostic(
2043 diagnostic,
2044 );
2045
2046 let rendered = display(error);
2047 for secret in SECRETS {
2048 assert!(!rendered.contains(secret), "{secret}: {rendered}");
2049 }
2050 assert!(
2051 rendered.contains("migration_provider_test_failed"),
2052 "{rendered}"
2053 );
2054 }
2055
2056 #[test]
2057 fn migration_outcome_projection_omits_provider_details_for_apply_and_adopt() {
2058 use type_bridge_contract::migration::MigrationId;
2059 use type_bridge_schema_migration::{MigrationExecutionOutcome, MigrationExecutionPosition};
2060
2061 let diagnostic = || {
2062 Diagnostic::new(
2063 DiagnosticCategory::InvalidContract,
2064 DiagnosticCode::new("migration_provider_test_failed").expect("static code"),
2065 "migration provider operation failed",
2066 )
2067 .with_detail("provider", PROVIDER_TEXT)
2068 };
2069 for context in [
2070 "apply did not complete",
2071 "adoption checkpoint did not complete",
2072 ] {
2073 for (outcome, expected_state, expected_position) in [
2074 (
2075 MigrationExecutionOutcome::RetrySafe {
2076 migration_id: MigrationId::new("example", "0001_initial")
2077 .expect("migration id"),
2078 position: MigrationExecutionPosition::TransactionGroup(7),
2079 diagnostic: diagnostic(),
2080 },
2081 "retry-safe",
2082 "transaction group 7",
2083 ),
2084 (
2085 MigrationExecutionOutcome::RequiresExplicitRecovery {
2086 migration_id: MigrationId::new("example", "0001_initial")
2087 .expect("migration id"),
2088 position: MigrationExecutionPosition::ManifestCheckpoint,
2089 diagnostic: diagnostic(),
2090 },
2091 "explicit recovery required",
2092 "manifest checkpoint",
2093 ),
2094 ] {
2095 let rendered = sanitize_migration_execution_outcome(context, outcome);
2096 for secret in SECRETS {
2097 assert!(!rendered.contains(secret), "{secret}: {rendered}");
2098 }
2099 for expected in [
2100 context,
2101 expected_state,
2102 "example/0001_initial",
2103 expected_position,
2104 "migration_provider_test_failed",
2105 "migration provider operation failed",
2106 ] {
2107 assert!(rendered.contains(expected), "{expected}: {rendered}");
2108 }
2109 }
2110 }
2111 }
2112}
2113
2114#[cfg(all(test, unix))]
2115mod output_authority_tests {
2116 use super::*;
2117 use std::os::unix::fs::symlink;
2118
2119 #[test]
2120 fn retained_output_authority_survives_component_swap_without_redirecting() {
2121 let workspace = tempfile::tempdir().expect("workspace directory");
2122 let outside = tempfile::tempdir().expect("outside directory");
2123 fs::create_dir_all(workspace.path().join("generated/python")).expect("output directory");
2124 let authority = WorkspaceDirectoryAuthority::open(
2125 WorkspaceRoot::new(fs::canonicalize(workspace.path()).expect("canonical workspace"))
2126 .expect("workspace root"),
2127 )
2128 .expect("workspace authority");
2129 let root = authority.output_root().expect("output authority");
2130 let output = root
2131 .open_beneath(Path::new("generated/python"))
2132 .expect("output authority");
2133
2134 let held = workspace.path().join("generated/python-held");
2135 fs::rename(workspace.path().join("generated/python"), &held)
2136 .expect("move retained output directory");
2137 symlink(outside.path(), workspace.path().join("generated/python"))
2138 .expect("redirect configured output path");
2139
2140 output
2141 .write_atomic("_models.py".as_ref(), b"retained authority")
2142 .expect("publication remains handle-relative");
2143 assert_eq!(
2144 fs::read(held.join("_models.py")).expect("retained output reads"),
2145 b"retained authority"
2146 );
2147 assert!(
2148 !outside.path().join("_models.py").exists(),
2149 "component replacement redirected output outside the workspace"
2150 );
2151 }
2152
2153 #[test]
2154 fn retained_output_root_survives_root_entry_swap_without_redirecting() {
2155 let workspace = tempfile::tempdir().expect("workspace directory");
2156 let outside = tempfile::tempdir().expect("outside directory");
2157 fs::create_dir_all(workspace.path().join("generated/python")).expect("output directory");
2158 let authority = WorkspaceDirectoryAuthority::open(
2159 WorkspaceRoot::new(fs::canonicalize(workspace.path()).expect("canonical workspace"))
2160 .expect("workspace root"),
2161 )
2162 .expect("workspace authority");
2163 let root = authority.output_root().expect("output authority");
2164 let held = workspace
2165 .path()
2166 .parent()
2167 .expect("temporary parent")
2168 .join(format!(
2169 "{}-retained",
2170 workspace
2171 .path()
2172 .file_name()
2173 .expect("temporary name")
2174 .to_string_lossy()
2175 ));
2176 fs::rename(workspace.path(), &held).expect("workspace root moves after validation");
2177 symlink(outside.path(), workspace.path()).expect("workspace name redirects outside");
2178
2179 let output = root
2180 .open_beneath(Path::new("generated/python"))
2181 .expect("output opens through retained root");
2182 output
2183 .write_atomic("_models.py".as_ref(), b"retained root authority")
2184 .expect("publication remains rooted in the retained handle");
2185 assert_eq!(
2186 fs::read(held.join("generated/python/_models.py")).expect("retained output reads"),
2187 b"retained root authority"
2188 );
2189 assert!(
2190 !outside.path().join("generated/python/_models.py").exists(),
2191 "root replacement redirected output outside the workspace"
2192 );
2193
2194 fs::remove_file(workspace.path()).expect("replacement symlink removes");
2195 fs::rename(&held, workspace.path()).expect("workspace restores for cleanup");
2196 }
2197}
2198
2199fn bind_approvals(
2200 graph: &type_bridge_schema_migration::MigrationHistoryGraph,
2201 approvals: &[String],
2202) -> Result<Vec<type_bridge_schema_migration::MigrationApplyApproval>, String> {
2203 if approvals.is_empty() {
2204 return Ok(Vec::new());
2205 }
2206 approvals
2207 .iter()
2208 .map(|compound| {
2209 let (app_label, name) = compound
2210 .split_once('/')
2211 .ok_or_else(|| format!("approval {compound:?} must be app-label/name"))?;
2212 let id = type_bridge_contract::migration::MigrationId::from_components(
2213 type_bridge_contract::migration::MigrationAppLabel::new(app_label.to_owned())
2214 .map_err(display)?,
2215 type_bridge_contract::migration::MigrationName::new(name.to_owned())
2216 .map_err(display)?,
2217 );
2218 let manifest = graph.manifest(&id).ok_or_else(|| {
2219 format!("approval target {compound:?} is not in the committed history")
2220 })?;
2221 type_bridge_schema_migration::MigrationApplyApproval::for_manifest(manifest)
2222 .map_err(display)
2223 })
2224 .collect()
2225}
2226
2227fn parse_migration_ids(
2228 graph: &type_bridge_schema_migration::MigrationHistoryGraph,
2229 values: &[String],
2230 kind: &str,
2231) -> Result<BTreeSet<type_bridge_contract::migration::MigrationId>, String> {
2232 let mut ids = BTreeSet::new();
2233 for compound in values {
2234 let (app_label, name) = compound
2235 .split_once('/')
2236 .ok_or_else(|| format!("{kind} {compound:?} must be app-label/name"))?;
2237 let id = type_bridge_contract::migration::MigrationId::from_components(
2238 type_bridge_contract::migration::MigrationAppLabel::new(app_label.to_owned())
2239 .map_err(display)?,
2240 type_bridge_contract::migration::MigrationName::new(name.to_owned())
2241 .map_err(display)?,
2242 );
2243 if graph.manifest(&id).is_none() {
2244 return Err(format!(
2245 "{kind} target {compound:?} is not in the committed history"
2246 ));
2247 }
2248 if !ids.insert(id) {
2249 return Err(format!("{kind} target {compound:?} is duplicated"));
2250 }
2251 }
2252 Ok(ids)
2253}
2254
2255fn bind_rollback_approvals(
2256 graph: &type_bridge_schema_migration::MigrationHistoryGraph,
2257 approvals: &[String],
2258) -> Result<Vec<type_bridge_schema_migration::MigrationApplyApproval>, String> {
2259 let ids = parse_migration_ids(graph, approvals, "rollback approval")?;
2260 let mut bound = Vec::new();
2261 for id in ids {
2262 let manifest = graph
2263 .manifest(&id)
2264 .ok_or_else(|| "internal rollback approval target disappeared".to_owned())?;
2265 for safety in [
2266 type_bridge_schema::SafetyClass::FormalOnly,
2267 type_bridge_schema::SafetyClass::SchemaMetadata,
2268 type_bridge_schema::SafetyClass::Additive,
2269 type_bridge_schema::SafetyClass::Conditional,
2270 type_bridge_schema::SafetyClass::Destructive,
2271 type_bridge_schema::SafetyClass::Opaque,
2272 ] {
2273 bound.push(
2274 type_bridge_schema_migration::MigrationApplyApproval::for_rollback(
2275 manifest, safety,
2276 )
2277 .map_err(display)?,
2278 );
2279 }
2280 }
2281 Ok(bound)
2282}
2283
2284#[cfg(test)]
2285mod transport_option_tests {
2286 use super::*;
2287
2288 fn environment(policy: WorkspaceTransportPolicy) -> WorkspaceEnvironment {
2289 WorkspaceEnvironment::new(
2290 "typedb.example:1729",
2291 "example",
2292 SecretReference::environment("TYPEBRIDGE_TEST_USERNAME").expect("username reference"),
2293 SecretReference::environment("TYPEBRIDGE_TEST_PASSWORD").expect("password reference"),
2294 )
2295 .expect("environment")
2296 .with_transport_policy(policy)
2297 }
2298
2299 fn custom_root_workspace(root_bytes: &[u8]) -> (tempfile::TempDir, TypeBridgeWorkspace) {
2300 let directory = tempfile::tempdir().expect("workspace directory");
2301 fs::create_dir_all(directory.path().join("schema/fragments")).expect("schema directory");
2302 fs::create_dir_all(directory.path().join("migrations/v2")).expect("migration directory");
2303 fs::create_dir_all(directory.path().join("certs")).expect("certificate directory");
2304 fs::write(
2305 directory.path().join("schema/schema.yaml"),
2306 "format: typebridge.schema-set/v1\nsources: [fragments/*.yaml]\n",
2307 )
2308 .expect("schema set writes");
2309 fs::write(
2310 directory.path().join("schema/fragments/model.yaml"),
2311 "format: typebridge.schema/v2\nentities: {person: {}}\n",
2312 )
2313 .expect("schema writes");
2314 fs::write(directory.path().join("certs/root.pem"), root_bytes).expect("certificate writes");
2315 let manifest = directory.path().join("typebridge.yaml");
2316 fs::write(
2317 &manifest,
2318 "format: typebridge.workspace/v1\n\
2319 schema:\n root: schema/schema.yaml\n ownership: exclusive\n managed-scope: tls-test\n\
2320 compatibility:\n semantic-profile: typedb-3.12.1/v1\n\
2321 migrations:\n directory: migrations/v2\n app-label: tlstest\n\
2322 environments:\n dev:\n database: tls_test\n uri: never-contact.invalid:1729\n \
2323 tls: 'true'\n tls-root-ca: certs/root.pem\n credential:\n username: \
2324 env:TYPEBRIDGE_TEST_USERNAME\n password: env:TYPEBRIDGE_TEST_PASSWORD\n",
2325 )
2326 .expect("manifest writes");
2327 let workspace = load_workspace(&manifest).expect("custom-root workspace loads");
2328 (directory, workspace)
2329 }
2330
2331 #[test]
2332 fn workspace_transport_policy_maps_without_changing_plaintext_defaults() {
2333 let defaults = type_bridge_orm::SecureConnectOptions::default();
2334 let disabled = secure_connect_options(&environment(WorkspaceTransportPolicy::Disabled));
2335 assert_eq!(disabled.tls_mode, type_bridge_orm::TlsMode::Disabled);
2336 assert_eq!(disabled.http_port, defaults.http_port);
2337 assert_eq!(disabled.server_version, defaults.server_version);
2338
2339 let native = secure_connect_options(
2340 &environment(WorkspaceTransportPolicy::NativeRoots).with_http_port(9443),
2341 );
2342 assert_eq!(native.tls_mode, type_bridge_orm::TlsMode::NativeRoots);
2343 assert_eq!(native.http_port, 9443);
2344 assert_eq!(native.server_version, defaults.server_version);
2345 }
2346
2347 #[test]
2348 fn custom_root_mapping_preserves_the_validated_canonical_path() {
2349 let directory = tempfile::tempdir().expect("workspace directory");
2350 let canonical = fs::canonicalize(directory.path()).expect("canonical workspace");
2351 fs::create_dir_all(canonical.join("certs")).expect("certificate directory");
2352 fs::write(
2353 canonical.join("certs/root.pem"),
2354 b"not parsed at workspace boundary\n",
2355 )
2356 .expect("certificate writes");
2357 let root = WorkspaceRoot::new(canonical.clone()).expect("workspace root");
2358 let root_ca = type_bridge_workspace::WorkspaceRootCa::new(
2359 &root,
2360 "certs/root.pem",
2361 &SystemSchemaSourceService,
2362 )
2363 .expect("confined root CA");
2364
2365 let options = secure_connect_options(
2366 &environment(WorkspaceTransportPolicy::CustomRootCa(root_ca)).with_http_port(8443),
2367 );
2368 assert_eq!(
2369 options.tls_mode,
2370 type_bridge_orm::TlsMode::CustomRootCa(canonical.join("certs/root.pem"))
2371 );
2372 assert_eq!(options.http_port, 8443);
2373 }
2374
2375 #[test]
2376 fn malformed_custom_root_fails_transport_preflight_before_credentials_are_needed() {
2377 let (_directory, workspace) = custom_root_workspace(b"definitely not a certificate\n");
2378
2379 let error = preflight_secure_connect_options(&workspace, "dev")
2380 .expect_err("PEM parsing must happen before credential resolution");
2381 assert!(error.contains("tls_custom_root_ca_invalid_pem"), "{error}");
2382 assert!(!error.contains("TYPEBRIDGE_TEST_USERNAME"), "{error}");
2383 assert!(!error.contains("TYPEBRIDGE_TEST_PASSWORD"), "{error}");
2384 }
2385
2386 #[cfg(unix)]
2387 #[test]
2388 fn workspace_root_swap_to_outside_symlink_is_rejected_at_transport_preflight() {
2389 use std::os::unix::fs::symlink;
2390
2391 let (directory, workspace) = custom_root_workspace(b"initial regular root\n");
2392 let outside = tempfile::tempdir().expect("outside directory");
2393 let configured = directory.path().join("certs/root.pem");
2394
2395 let outside_root = outside.path().join("malicious.pem");
2396 fs::write(
2397 &outside_root,
2398 include_bytes!("../../core/tests/fixtures/valid-root.pem"),
2399 )
2400 .expect("write outside replacement root");
2401 fs::remove_file(&configured).expect("remove validated confined root");
2402 symlink(&outside_root, &configured).expect("install outside symlink after validation");
2403
2404 let error = preflight_secure_connect_options(&workspace, "dev")
2405 .expect_err("retained workspace paths must never follow a replacement symlink");
2406 assert!(error.contains("tls_custom_root_ca_unreadable"), "{error}");
2407 assert!(!error.contains("tls_custom_root_ca_invalid_pem"), "{error}");
2408 }
2409
2410 #[cfg(unix)]
2411 #[test]
2412 fn real_directory_root_replacement_cannot_substitute_custom_trust() {
2413 let (directory, workspace) =
2414 custom_root_workspace(include_bytes!("../../core/tests/fixtures/valid-root.pem"));
2415 let configured_root = directory.path().to_path_buf();
2416 let held_root = configured_root.with_extension("retained-custom-root-ca");
2417 fs::rename(&configured_root, &held_root).expect("move retained workspace root");
2418 fs::create_dir_all(configured_root.join("certs")).expect("replacement root creates");
2419 fs::write(
2420 configured_root.join("certs/root.pem"),
2421 b"attacker-controlled replacement is not a certificate\n",
2422 )
2423 .expect("replacement root writes");
2424
2425 let preflight = preflight_secure_connect_options(&workspace, "dev");
2426
2427 fs::remove_dir_all(&configured_root).expect("replacement root removes");
2428 fs::rename(&held_root, &configured_root).expect("retained root restores");
2429 preflight.expect("transport must use the CA under the retained original root");
2430 }
2431}
2432
2433#[cfg(test)]
2434mod rollback_cli_contract_tests {
2435 use super::*;
2436
2437 #[test]
2438 fn rollback_grammar_requires_environment_and_explicit_removal() {
2439 assert!(Cli::try_parse_from(["type-bridge", "migration", "rollback"]).is_err());
2440 assert!(
2441 Cli::try_parse_from([
2442 "type-bridge",
2443 "migration",
2444 "rollback",
2445 "--environment",
2446 "live",
2447 ])
2448 .is_err()
2449 );
2450 let cli = Cli::try_parse_from([
2451 "type-bridge",
2452 "migration",
2453 "rollback",
2454 "--environment",
2455 "live",
2456 "--remove",
2457 "example/0002_contract",
2458 "--remove",
2459 "example/0001_expand",
2460 "--approve",
2461 "example/0002_contract",
2462 "--output",
2463 "json",
2464 ])
2465 .expect("explicit preview grammar parses");
2466 let Command::Migration {
2467 command:
2468 MigrationCommand::Rollback {
2469 environment,
2470 removals,
2471 approvals,
2472 execute,
2473 output,
2474 },
2475 } = cli.command
2476 else {
2477 panic!("rollback command parsed into another command")
2478 };
2479 assert_eq!(environment, "live");
2480 assert_eq!(removals.len(), 2);
2481 assert_eq!(approvals, ["example/0002_contract"]);
2482 assert!(!execute, "preview is the non-mutating default");
2483 assert_eq!(output, RollbackOutput::Json);
2484 }
2485
2486 #[test]
2487 fn rollback_execution_requires_an_explicit_flag() {
2488 let cli = Cli::try_parse_from([
2489 "type-bridge",
2490 "migration",
2491 "rollback",
2492 "--environment",
2493 "live",
2494 "--remove",
2495 "example/0002_contract",
2496 "--execute",
2497 ])
2498 .expect("explicit execution grammar parses");
2499 assert!(matches!(
2500 cli.command,
2501 Command::Migration {
2502 command: MigrationCommand::Rollback { execute: true, .. }
2503 }
2504 ));
2505 }
2506}
2507
2508#[cfg(test)]
2509mod migration_command_tests {
2510 use super::*;
2511
2512 fn write_workspace_manifest(root: &Path, semantic_profile: &str) -> PathBuf {
2513 fs::create_dir_all(root.join("schema/fragments")).expect("schema directory");
2514 fs::write(
2515 root.join("schema/schema.yaml"),
2516 "format: typebridge.schema-set/v1\nsources: [fragments/*.yaml]\n",
2517 )
2518 .expect("schema set writes");
2519 fs::write(
2520 root.join("schema/fragments/model.yaml"),
2521 "format: typebridge.schema/v2\nentities: {person: {}}\n",
2522 )
2523 .expect("schema writes");
2524 let manifest = root.join("typebridge.yaml");
2525 fs::write(
2526 &manifest,
2527 format!(
2528 "format: typebridge.workspace/v1\n\
2529 schema:\n root: schema/schema.yaml\n ownership: exclusive\n managed-scope: command-test\n\
2530 compatibility:\n semantic-profile: {semantic_profile}\n\
2531 migrations:\n directory: migrations/v2\n app-label: commandtest\n\
2532 environments:\n dev:\n database: command_test\n uri: never-contact.invalid:1729\n migrate: 'true'\n credential:\n username: env:TYPEBRIDGE_COMMAND_TEST_USERNAME\n password: env:TYPEBRIDGE_COMMAND_TEST_PASSWORD\n"
2533 ),
2534 )
2535 .expect("manifest writes");
2536 manifest
2537 }
2538
2539 #[test]
2540 fn migration_make_creates_its_missing_authoring_directory() {
2541 let directory = tempfile::tempdir().expect("workspace directory");
2542 let manifest = write_workspace_manifest(directory.path(), "typedb-3.11.5/v1");
2543 let migration_directory = directory.path().join("migrations/v2");
2544 assert!(!migration_directory.exists());
2545
2546 run(&Cli {
2547 manifest,
2548 command: Command::Migration {
2549 command: MigrationCommand::Make {
2550 name: "initial".to_owned(),
2551 backfill_intent: None,
2552 },
2553 },
2554 })
2555 .expect("migration make creates and publishes into its authoring directory");
2556
2557 assert!(
2558 migration_directory
2559 .join("0001_initial.tbmigration.json")
2560 .is_file()
2561 );
2562 assert!(migration_directory.join("0001_initial.typeql").is_file());
2563 }
2564
2565 #[test]
2566 fn migration_make_accepts_a_confined_backfill_intent() {
2567 let directory = tempfile::tempdir().expect("workspace directory");
2568 let manifest = write_workspace_manifest(directory.path(), "typedb-3.11.5/v1");
2569 fs::write(
2570 directory.path().join("schema/fragments/model.yaml"),
2571 "format: typebridge.schema/v2\nattributes:\n display-name: { value: string }\n legacy-name: { value: string }\n person-id: { value: string }\nentities:\n person:\n owns:\n display-name: {}\n legacy-name: {}\n person-id: { key: true }\n",
2572 )
2573 .expect("backfill schema writes");
2574 let initial = Cli {
2575 manifest: manifest.clone(),
2576 command: Command::Migration {
2577 command: MigrationCommand::Make {
2578 name: "initial".to_owned(),
2579 backfill_intent: None,
2580 },
2581 },
2582 };
2583 run(&initial).expect("initial migration");
2584 fs::write(
2585 directory.path().join("migrations/v2/copy-name.backfill.yaml"),
2586 "format: typebridge.migration-backfill-intent/v1\ncopy-attribute:\n owner-kind: entity\n owner: person\n source: legacy-name\n destination: display-name\n partition-key: person-id\n batch-rows: 128\n reverse: remove-equal-copied-destination\n",
2587 )
2588 .expect("backfill intent writes");
2589
2590 run(&Cli {
2591 manifest,
2592 command: Command::Migration {
2593 command: MigrationCommand::Make {
2594 name: "copy-name".to_owned(),
2595 backfill_intent: Some(PathBuf::from("copy-name.backfill.yaml")),
2596 },
2597 },
2598 })
2599 .expect("backfill migration authors");
2600
2601 assert!(
2602 directory
2603 .path()
2604 .join("migrations/v2/0002_copy-name.tbmigration.json")
2605 .is_file()
2606 );
2607 }
2608
2609 #[test]
2610 fn unsupported_execution_profile_rejects_before_credentials_or_filesystem_mutation() {
2611 let directory = tempfile::tempdir().expect("workspace directory");
2612 let manifest = write_workspace_manifest(directory.path(), "typedb-3.11.5/v1");
2613 let workspace = load_workspace(&manifest).expect("workspace loads");
2614
2615 for action in [
2616 ConnectedAction::Apply {
2617 approvals: Vec::new(),
2618 },
2619 ConnectedAction::Verify,
2620 ConnectedAction::Adopt {
2621 archive_directory: directory.path().join("missing-archive"),
2622 name: "0000_archive_frontier".to_owned(),
2623 },
2624 ] {
2625 let error = run_connected(&workspace, "dev", action)
2626 .expect_err("every connected migration operation uses the exact profile");
2627
2628 assert!(
2629 error.contains("migration_typedb_semantic_profile_unsupported"),
2630 "{error}"
2631 );
2632 assert!(error.contains("typedb-3.11.5/v1"), "{error}");
2633 assert!(error.contains("typedb-3.12.1/v1"), "{error}");
2634 assert!(
2635 !error.contains("credential environment variable")
2636 && !error.contains("cannot connect")
2637 && !error.contains("cannot check database"),
2638 "profile gate ran after external setup: {error}"
2639 );
2640 }
2641 assert!(
2642 !directory.path().join("migrations/v2").exists(),
2643 "profile rejection must not create the migration directory"
2644 );
2645 }
2646}
2647
2648#[cfg(test)]
2649mod adoption_file_tests {
2650 use super::*;
2651 use sha2::{Digest as _, Sha256};
2652
2653 const LEGACY_SCHEMA: &str = "define\nentity person;\n";
2654
2655 fn adoption_workspace() -> (tempfile::TempDir, TypeBridgeWorkspace) {
2656 let directory = tempfile::tempdir().expect("workspace directory");
2657 fs::create_dir_all(directory.path().join("schema/fragments")).expect("schema directory");
2658 fs::write(
2659 directory.path().join("schema/schema.yaml"),
2660 "format: typebridge.schema-set/v1\nsources: [fragments/*.yaml]\n",
2661 )
2662 .expect("schema set writes");
2663 fs::write(
2664 directory.path().join("schema/fragments/model.yaml"),
2665 "format: typebridge.schema/v2\nentities: {person: {}}\n",
2666 )
2667 .expect("schema writes");
2668 let manifest = directory.path().join("typebridge.yaml");
2669 fs::write(
2670 &manifest,
2671 "format: typebridge.workspace/v1\n\
2672 schema:\n root: schema/schema.yaml\n ownership: exclusive\n managed-scope: adoption-test\n\
2673 compatibility:\n semantic-profile: typedb-3.12.1/v1\n\
2674 migrations:\n directory: migrations/v2\n app-label: smoke\n\
2675 environments:\n dev:\n database: adoption_test\n uri: never-contact.invalid:1729\n migrate: 'true'\n credential:\n username: env:TYPEBRIDGE_TEST_USERNAME\n password: env:TYPEBRIDGE_TEST_PASSWORD\n",
2676 )
2677 .expect("manifest writes");
2678 let workspace = load_workspace(&manifest).expect("workspace loads");
2679 (directory, workspace)
2680 }
2681
2682 fn write_legacy_fixture(root: &Path) -> PathBuf {
2683 let directory = root.join("migrations/legacy");
2684 fs::create_dir_all(&directory).expect("legacy directory");
2685 let name = "0001_initial";
2686 let python_source = "class Migration:\n operations = []\n";
2687 let checksum = type_bridge_migration::migration_file_checksum(python_source);
2688 let source_sha256 = format!("{:x}", Sha256::digest(python_source.as_bytes()));
2689 let schema_hash = format!("{:x}", Sha256::digest(LEGACY_SCHEMA.as_bytes()));
2690 fs::write(directory.join(format!("{name}.py")), python_source)
2691 .expect("legacy source writes");
2692 let adoption = type_bridge_migration::LegacyAdoptionMetadata::new(
2693 "legacy",
2694 name,
2695 Vec::new(),
2696 checksum,
2697 source_sha256,
2698 type_bridge_migration::LegacySchemaEffect::Snapshot,
2699 type_bridge_migration::MigrationDependencySpec {
2700 app_label: "legacy".to_owned(),
2701 migration_name: name.to_owned(),
2702 },
2703 schema_hash.clone(),
2704 )
2705 .expect("legacy adoption metadata");
2706 fs::write(
2707 directory.join(format!("{name}.adoption.json")),
2708 serde_json::to_vec_pretty(&adoption).expect("metadata encodes"),
2709 )
2710 .expect("metadata writes");
2711 let snapshot = directory.join("snapshots/v0001");
2712 fs::create_dir_all(&snapshot).expect("snapshot directory");
2713 fs::write(snapshot.join("schema.tql"), LEGACY_SCHEMA).expect("snapshot schema writes");
2714 fs::write(
2715 snapshot.join("snapshot.json"),
2716 serde_json::to_vec_pretty(&serde_json::json!({
2717 "version": "v0001",
2718 "source_migration": name,
2719 "schema_hash": schema_hash,
2720 "file_hashes": {"schema.tql": schema_hash},
2721 "type_bridge_version": "1.5.11",
2722 "type_bridge_core_version": "1.5.11"
2723 }))
2724 .expect("snapshot manifest encodes"),
2725 )
2726 .expect("snapshot manifest writes");
2727 directory
2728 }
2729
2730 #[test]
2731 fn authority_publication_is_atomic_no_replace_and_resumable() {
2732 let directory = tempfile::tempdir().expect("directory");
2733 let authority =
2734 type_bridge_schema_migration::MigrationDirectory::open_ambient(directory.path())
2735 .expect("directory authority");
2736 let name = "adopted-genesis.typeql";
2737 let path = directory.path().join("adopted-genesis.typeql");
2738 assert!(
2739 publish_authority(&authority, name, b"define\nentity person;\n")
2740 .expect("first publish")
2741 );
2742 assert!(
2743 !publish_authority(&authority, name, b"define\nentity person;\n")
2744 .expect("identical recovery")
2745 );
2746 assert!(publish_authority(&authority, name, b"define\nentity company;\n").is_err());
2747 assert_eq!(
2748 fs::read(&path).expect("authority reads"),
2749 b"define\nentity person;\n"
2750 );
2751 assert!(
2752 fs::read_dir(directory.path())
2753 .expect("directory reads")
2754 .all(|entry| !entry
2755 .expect("entry")
2756 .file_name()
2757 .to_string_lossy()
2758 .ends_with(".tmp"))
2759 );
2760 }
2761
2762 #[cfg(unix)]
2763 #[test]
2764 fn authority_publication_rejects_final_symlink() {
2765 use std::os::unix::fs::symlink;
2766
2767 let directory = tempfile::tempdir().expect("directory");
2768 let outside = directory.path().join("outside");
2769 fs::write(&outside, b"untouched").expect("outside writes");
2770 let path = directory.path().join("adopted-genesis.typeql");
2771 symlink(&outside, &path).expect("symlink");
2772 let authority =
2773 type_bridge_schema_migration::MigrationDirectory::open_ambient(directory.path())
2774 .expect("directory authority");
2775
2776 assert!(publish_authority(&authority, "adopted-genesis.typeql", b"replacement").is_err());
2777 assert_eq!(fs::read(&outside).expect("outside reads"), b"untouched");
2778 }
2779
2780 #[test]
2781 fn invalid_adoption_name_creates_no_canonical_directory() {
2782 let (directory, workspace) = adoption_workspace();
2783 let missing_archive = directory.path().join("missing-legacy");
2784 let error = run_connected(
2785 &workspace,
2786 "dev",
2787 ConnectedAction::Adopt {
2788 archive_directory: missing_archive,
2789 name: String::new(),
2790 },
2791 )
2792 .expect_err("invalid name fails before history or network access");
2793 assert!(error.contains("migration"), "{error}");
2794 assert!(
2795 !directory.path().join("migrations/v2").exists(),
2796 "bad-name validation must not create canonical filesystem state"
2797 );
2798 }
2799
2800 #[test]
2801 fn invalid_apply_approvals_fail_before_credentials_or_network() {
2802 let (directory, workspace) = adoption_workspace();
2803 fs::create_dir_all(directory.path().join("migrations/v2")).expect("canonical directory");
2804
2805 for (approval, expected) in [
2806 ("not-a-compound-id", "must be app-label/name"),
2807 ("smoke/0001_missing", "is not in the committed history"),
2808 ] {
2809 let error = run_connected(
2810 &workspace,
2811 "dev",
2812 ConnectedAction::Apply {
2813 approvals: vec![approval.to_owned()],
2814 },
2815 )
2816 .expect_err("invalid approval is rejected by local authority");
2817 assert!(error.contains(expected), "{approval}: {error}");
2818 assert!(
2819 !error.contains("credential")
2820 && !error.contains("connect")
2821 && !error.contains("database"),
2822 "approval validation ran after external setup: {error}"
2823 );
2824 }
2825 }
2826
2827 #[test]
2828 fn adoption_retry_completes_either_exact_orphan_direction() {
2829 for orphan in ["genesis", "bridge"] {
2830 let (directory, workspace) = adoption_workspace();
2831 let legacy = write_legacy_fixture(directory.path());
2832 let prepared = prepare_archive_adoption(&workspace, &legacy, "0000_archive_frontier")
2833 .expect("adoption prepares");
2834 let migration_directory = workspace
2835 .ensure_migration_directory()
2836 .expect("canonical directory");
2837 match orphan {
2838 "genesis" => {
2839 publish_authority(
2840 migration_directory.directory(),
2841 type_bridge_schema_compat::ADOPTED_GENESIS_FILE_NAME,
2842 prepared.reconstructed.schema_typeql().as_bytes(),
2843 )
2844 .expect("genesis orphan publishes");
2845 }
2846 "bridge" => {
2847 publish_authority(
2848 migration_directory.directory(),
2849 &prepared.bridge_name,
2850 &prepared.bridge_bytes,
2851 )
2852 .expect("bridge orphan publishes");
2853 }
2854 _ => unreachable!(),
2855 }
2856
2857 publish_prepared_adoption(&workspace, &migration_directory, &prepared)
2858 .expect("adoption retry completes the exact orphan");
2859 workspace
2860 .discover_migrations_in(&migration_directory)
2861 .expect("completed adoption pair discovers");
2862 assert!(
2863 migration_directory
2864 .display_path()
2865 .join(type_bridge_schema_compat::ADOPTED_GENESIS_FILE_NAME)
2866 .is_file()
2867 );
2868 assert!(
2869 migration_directory
2870 .display_path()
2871 .join(&prepared.bridge_name)
2872 .is_file()
2873 );
2874 }
2875 }
2876
2877 #[test]
2878 fn legacy_history_race_after_bridge_rolls_back_new_publication() {
2879 let (directory, workspace) = adoption_workspace();
2880 let legacy = write_legacy_fixture(directory.path());
2881 let prepared = prepare_archive_adoption(&workspace, &legacy, "0000_archive_frontier")
2882 .expect("adoption prepares");
2883 let migration_directory = workspace
2884 .ensure_migration_directory()
2885 .expect("canonical directory");
2886 let legacy_source = legacy.join("0001_initial.py");
2887
2888 let error = publish_prepared_adoption_with_after_bridge(
2889 &workspace,
2890 &migration_directory,
2891 &prepared,
2892 || {
2893 fs::write(
2894 &legacy_source,
2895 "class Migration:\n operations = ['changed']\n",
2896 )
2897 .expect("race mutation writes");
2898 },
2899 )
2900 .expect_err("legacy authority race aborts pair publication");
2901 assert!(error.contains("changed"), "{error}");
2902 assert!(
2903 !migration_directory
2904 .display_path()
2905 .join(&prepared.bridge_name)
2906 .exists(),
2907 "new bridge is rolled back"
2908 );
2909 assert!(
2910 !migration_directory
2911 .display_path()
2912 .join(type_bridge_schema_compat::ADOPTED_GENESIS_FILE_NAME)
2913 .exists(),
2914 "genesis is never published after the race"
2915 );
2916 }
2917}