Skip to main content

type_bridge_cli/
lib.rs

1//! `type-bridge` — the V2 workspace command-line interface.
2//!
3//! `schema check`, `schema generate`, `migration make`, and `migration
4//! plan`, and rollback preview run without any network I/O. `migration apply`,
5//! `migration rollback --execute`, `migration verify`, and `migration adopt`
6//! connect through one named workspace
7//! environment: credentials stay symbolic environment references resolved
8//! only at command time, and application requires the environment's
9//! explicit `migrate: true` opt-in.
10//!
11//! The crate is a library plus a thin binary so the exact same command
12//! surface ships both as the standalone `type-bridge` executable and
13//! in-process inside the Python wheel via [`run_cli`].
14
15#![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    /// Path to the workspace manifest.
46    #[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-source commands.
55    Schema {
56        #[command(subcommand)]
57        command: SchemaCommand,
58    },
59    /// Canonical migration commands.
60    Migration {
61        #[command(subcommand)]
62        command: MigrationCommand,
63    },
64}
65
66#[derive(Subcommand)]
67enum SchemaCommand {
68    /// Parse and resolve the schema sources without network I/O.
69    Check,
70    /// Generate the configured binding projections from the canonical schema.
71    Generate,
72    /// Export canonical declared-schema bytes for low-level V2 tooling.
73    ExportDeclared {
74        /// Workspace-relative destination for the canonical JSON artifact.
75        #[arg(long, default_value = "declared-schema.json")]
76        output: PathBuf,
77    },
78}
79
80#[derive(Subcommand)]
81enum MigrationCommand {
82    /// Author the next canonical migration toward the schema sources.
83    Make {
84        /// Descriptive migration name; the ordinal prefix is allocated.
85        #[arg(long)]
86        name: String,
87        /// Closed backfill-intent YAML inside the configured migration directory.
88        #[arg(long)]
89        backfill_intent: Option<PathBuf>,
90    },
91    /// Order the committed chain and report each manifest's safety class.
92    Plan,
93    /// Apply the committed chain to one named environment.
94    Apply {
95        /// The manifest environment to apply against.
96        #[arg(long)]
97        environment: String,
98        /// Approve one destructive migration by compound id (app/name).
99        #[arg(long = "approve")]
100        approvals: Vec<String>,
101    },
102    /// Verify the migration state triad against one named environment.
103    Verify {
104        /// The manifest environment to verify against.
105        #[arg(long)]
106        environment: String,
107    },
108    /// Preview or explicitly execute rollback of named applied migrations.
109    Rollback {
110        /// The manifest environment whose exact database pair is targeted.
111        #[arg(long)]
112        environment: String,
113        /// Remove one exact compound migration identity (app/name); repeat as needed.
114        #[arg(long = "remove", required = true)]
115        removals: Vec<String>,
116        /// Approve one destructive rollback by compound id (app/name).
117        #[arg(long = "approve")]
118        approvals: Vec<String>,
119        /// Execute the previewed rollback; omission is provider-free preview only.
120        #[arg(long)]
121        execute: bool,
122        /// Select stable human or machine-readable output.
123        #[arg(long, value_enum, default_value_t = RollbackOutput::Text)]
124        output: RollbackOutput,
125    },
126    /// Adopt a completed archived V1 history as the canonical genesis.
127    Adopt {
128        /// The manifest environment holding the migrated v1 database.
129        #[arg(long)]
130        environment: String,
131        /// Directory containing the completed archived migration files.
132        #[arg(long)]
133        archive_directory: PathBuf,
134        /// Migration name recorded for the zero-operation bridge manifest.
135        #[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
146/// Symbolic secret references stay unresolved during offline commands.
147struct DeferSecrets;
148
149impl SecretReferenceService for DeferSecrets {
150    fn validate_reference(
151        &self,
152        _reference: &SecretReference,
153    ) -> Result<(), WorkspaceServiceError> {
154        Ok(())
155    }
156}
157
158/// No extension handlers ship with the CLI yet; requirements fail closed.
159struct 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
172/// Run the CLI over process-style arguments (`argv[0]` included).
173///
174/// Returns the process exit code. All output goes to the process stdout
175/// and stderr exactly as the standalone binary would print it: `--help`
176/// and `--version` exit 0, argument errors exit 2, command failures
177/// print `error: ...` and exit 1.
178pub 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    // Read at most the canonical document ceiling plus one byte: an
472    // oversized manifest fails with a stable message before its full
473    // content is ever allocated.
474    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    // Services borrow locally, so the workspace is constructed in this scope.
492    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
499/// Render one secure lifecycle failure after credentials have been resolved.
500///
501/// Provider errors are allowed to echo request metadata, including
502/// credentials. Retain only the runtime's structurally credential-safe
503/// TLS/version projection; every other error collapses to an operation code
504/// and drops its source.
505fn 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
516/// Render a database operation failure after credentials have been resolved.
517///
518/// Unlike secure connection errors, ordinary ORM errors have no closed safe
519/// projection and may contain provider-controlled request metadata.
520fn 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
528/// Schema export happens after credentials have been resolved, so no raw ORM
529/// error or source chain may cross the CLI boundary.
530fn 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
535/// Render a non-success migration outcome without exposing diagnostic details.
536///
537/// TypeDB adapters retain provider text in `Diagnostic` details for trusted
538/// programmatic inspection. Its `Display` surface intentionally omits those
539/// details, while derived `Debug` includes them, so every post-credential CLI
540/// path must use this closed projection.
541fn 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
610/// Generate every configured binding projection from the canonical schema.
611///
612/// The resolved workspace schema is projected per configured target with
613/// each shipped emitter's handler and code-resource evidence — the same
614/// path the codegen acceptance fixtures pin — and emitted
615/// deterministically. The complete generation is prepared beneath the
616/// retained workspace authority and committed as one rollback-verified batch,
617/// with schema authority last; files not produced by an emitter are never
618/// touched or deleted.
619fn run_schema_generate(workspace: &TypeBridgeWorkspace) -> Result<(), String> {
620    run_schema_generate_with(workspace, |target, resolved, authority| {
621        generate_binding_package(
622            target,
623            resolved,
624            authority,
625            workspace.config().app_label(),
626            workspace.config().type_name_overrides(target),
627        )
628    })
629}
630
631fn generate_binding_package(
632    target: type_bridge_contract::projection::BindingTarget,
633    resolved: &type_bridge_schema::ResolvedSchema,
634    authority: &type_bridge_schema::VerifiedSchemaAuthority,
635    app_label: &type_bridge_contract::migration::MigrationAppLabel,
636    type_names: &[type_bridge_contract::projection::TypeNameOverride],
637) -> Result<type_bridge_schema_codegen::GeneratedPackage, String> {
638    use type_bridge_contract::projection::{BindingTarget, ProjectionConfig};
639    use type_bridge_schema::project;
640    use type_bridge_schema_codegen::{CEmitter, PythonEmitter, RustEmitter, TypeScriptEmitter};
641
642    let configure = |mut config: ProjectionConfig| -> Result<ProjectionConfig, String> {
643        for item in type_names {
644            config = config
645                .with_type_name_override(item.type_id().clone(), item.name().as_str())
646                .map_err(display)?;
647        }
648        Ok(config)
649    };
650
651    match target {
652        BindingTarget::Python => {
653            let emitter = PythonEmitter::new();
654            let handlers = emitter.generator_handlers_for(resolved);
655            let resources = emitter.code_resources_for(resolved).map_err(display)?;
656            let projection = project(
657                resolved,
658                BindingTarget::Python,
659                &configure(ProjectionConfig::python())?,
660                &handlers,
661                &resources,
662            )
663            .map_err(display)?;
664            emitter.emit(&projection, authority)
665        }
666        BindingTarget::TypeScript => {
667            let emitter = TypeScriptEmitter::new();
668            let handlers = emitter.generator_handlers_for(resolved);
669            let resources = emitter.code_resources_for(resolved).map_err(display)?;
670            let projection = project(
671                resolved,
672                BindingTarget::TypeScript,
673                &configure(ProjectionConfig::typescript())?,
674                &handlers,
675                &resources,
676            )
677            .map_err(display)?;
678            emitter.emit(&projection, authority)
679        }
680        BindingTarget::Rust => {
681            let emitter = RustEmitter::new();
682            let handlers = emitter.generator_handlers_for(resolved);
683            let resources = emitter.code_resources_for(resolved).map_err(display)?;
684            let projection = project(
685                resolved,
686                BindingTarget::Rust,
687                &configure(ProjectionConfig::rust())?,
688                &handlers,
689                &resources,
690            )
691            .map_err(display)?;
692            emitter.emit(&projection, authority)
693        }
694        BindingTarget::C => {
695            let emitter = CEmitter::new();
696            let handlers = emitter.generator_handlers_for(resolved);
697            let resources = emitter.code_resources_for(resolved).map_err(display)?;
698            let config = configure(ProjectionConfig::c(c_symbol_prefix_for_app_label(
699                app_label,
700            )))?;
701            let projection = project(resolved, BindingTarget::C, &config, &handlers, &resources)
702                .map_err(display)?;
703            emitter.emit(&projection, authority)
704        }
705        _ => {
706            return Err(format!(
707                "schema generation does not support binding target {}",
708                target.as_str()
709            ));
710        }
711    }
712    .map_err(display)
713}
714
715fn run_schema_generate_with(
716    workspace: &TypeBridgeWorkspace,
717    mut generate: impl FnMut(
718        type_bridge_contract::projection::BindingTarget,
719        &type_bridge_schema::ResolvedSchema,
720        &type_bridge_schema::VerifiedSchemaAuthority,
721    ) -> Result<type_bridge_schema_codegen::GeneratedPackage, String>,
722) -> Result<(), String> {
723    use type_bridge_schema::{build_schema_authority, encode_schema_authority};
724
725    let outputs = workspace.config().outputs();
726    let authority_output = workspace.config().schema_authority_output();
727    if outputs.is_empty() && authority_output.is_none() {
728        return Err(
729            "no generated outputs configured; add bindings.<target>.output or \
730             artifacts.schema-authority.output to the manifest"
731                .into(),
732        );
733    }
734
735    let resolved = workspace.resolved_schema();
736    let migration_directory = workspace.ensure_migration_directory().map_err(display)?;
737    let migration_history = workspace
738        .migration_history_bundle_in(&migration_directory)
739        .map_err(display)?;
740    let authority = build_schema_authority(
741        workspace.declared_schema(),
742        workspace.required_capabilities(),
743        workspace.delta_context(),
744    )
745    .map_err(display)?;
746    let authority_bytes = encode_schema_authority(&authority);
747
748    // Finish every pure projection before mutating any output. A target-level
749    // generation failure therefore cannot publish an earlier language from a
750    // different semantic attempt.
751    let mut packages = Vec::with_capacity(outputs.len());
752    for (&target, directory) in outputs {
753        let package = generate(target, resolved, &authority)?
754            .with_migration_history_bundle(&migration_history)
755            .map_err(display)?;
756        packages.push((target, directory, package));
757    }
758
759    // Build one workspace-relative batch without touching the filesystem. The
760    // workspace authority prevalidates every destination and prepares every
761    // flushed same-directory temporary before it publishes in this order. The
762    // final server authority is deliberately appended last.
763    let workspace_root = workspace.output_root()?;
764    let mut generated_files = Vec::new();
765    let mut generated_packages = Vec::with_capacity(packages.len());
766    for (target, directory, package) in &packages {
767        let display_root = workspace_root.display_path().join(directory.as_path());
768        let file_count = package.files().len();
769        for (path, bytes) in package.files() {
770            let relative = std::path::Path::new(path);
771            validate_generated_relative_path(relative)?;
772            generated_files.push((directory.as_path().join(relative), bytes.as_slice()));
773        }
774        generated_packages.push((*target, display_root, file_count));
775    }
776    let prepared_authority = authority_output
777        .map(|output| {
778            let path = output.as_path();
779            let _file_name = path
780                .file_name()
781                .ok_or_else(|| "schema-authority output has no file name".to_owned())?;
782            Ok::<_, String>(path.to_path_buf())
783        })
784        .transpose()?;
785
786    if let Some(relative) = &prepared_authority {
787        generated_files.push((relative.clone(), authority_bytes.as_slice()));
788    }
789    workspace_root.write_atomic_batch(
790        generated_files
791            .iter()
792            .map(|(path, bytes)| (path.as_path(), *bytes)),
793    )?;
794
795    for (target, display_root, file_count) in generated_packages {
796        println!(
797            "generated {} file(s) for {} into {}",
798            file_count,
799            target.as_str(),
800            display_root.display(),
801        );
802    }
803    if let Some(relative) = prepared_authority {
804        println!(
805            "generated schema authority at {}\n  authority identity: {}",
806            workspace_root.display_path().join(relative).display(),
807            authority.authority_fingerprint().digest().to_hex(),
808        );
809    }
810    Ok(())
811}
812
813#[cfg(test)]
814mod schema_generation_atomicity_tests {
815    use std::collections::BTreeMap;
816    use std::env;
817    use std::fs::OpenOptions;
818    use std::io::Write as _;
819
820    use super::*;
821    use serde_json::json;
822    use sha2::{Digest as _, Sha256};
823    use type_bridge_contract::codec::to_canonical_json;
824    use type_bridge_contract::projection::BindingTarget;
825
826    const ARTIFACT_OUTPUT_ENV: &str = "TYPE_BRIDGE_SDK_V3_ATOMIC_GENERATION_OUTPUT";
827    const ARTIFACT_SOURCE_PATH: &str = "type-bridge-core/crates/cli/src/lib.rs";
828    const ARTIFACT_FORMAT: &str = "typebridge.sdk-v3-artifact-observation/v1";
829    const MAX_ARTIFACT_BYTES: usize = 64 * 1024;
830
831    fn publish_atomic_generation_observation(observation: serde_json::Value) {
832        let Some(output) = env::var_os(ARTIFACT_OUTPUT_ENV) else {
833            return;
834        };
835        let output = PathBuf::from(output);
836        assert!(
837            output.is_absolute(),
838            "{ARTIFACT_OUTPUT_ENV} must be absolute"
839        );
840        let parent = output
841            .parent()
842            .expect("atomic-generation artifact path has a parent");
843        let parent_metadata =
844            fs::symlink_metadata(parent).expect("atomic-generation artifact parent is inspectable");
845        assert!(
846            parent_metadata.is_dir() && !parent_metadata.file_type().is_symlink(),
847            "atomic-generation artifact parent must be a real directory"
848        );
849        let source = include_bytes!("lib.rs");
850        let artifact = json!({
851            "format": ARTIFACT_FORMAT,
852            "semantic_profile": "typedb-3.12.1/v1",
853            "producer": {
854                "id": "type-bridge-cli.atomic-multibinding-v3-artifact",
855                "source": {
856                    "path": ARTIFACT_SOURCE_PATH,
857                    "sha256": format!("{:x}", Sha256::digest(source)),
858                },
859                "test_id": "schema_generation_atomicity_tests::injected_c_emitter_failure_preserves_all_four_ordered_packages",
860            },
861            "result": {
862                "observation_ref": "atomic_multibinding_generation",
863                "outcome": "passed",
864                "proof_kind": "artifact",
865                "observation": observation,
866            },
867        });
868        let mut bytes =
869            to_canonical_json(&artifact).expect("atomic-generation artifact encodes canonically");
870        bytes.push(b'\n');
871        assert!(
872            bytes.len() <= MAX_ARTIFACT_BYTES,
873            "atomic-generation artifact exceeds {MAX_ARTIFACT_BYTES} bytes"
874        );
875        let mut destination = OpenOptions::new()
876            .write(true)
877            .create_new(true)
878            .open(&output)
879            .expect("atomic-generation artifact destination must be new");
880        if let Err(error) = destination
881            .write_all(&bytes)
882            .and_then(|()| destination.sync_all())
883        {
884            drop(destination);
885            let _ = fs::remove_file(&output);
886            panic!("atomic-generation artifact publication failed: {error}");
887        }
888    }
889
890    fn snapshot(root: &Path) -> BTreeMap<PathBuf, Vec<u8>> {
891        let mut files = BTreeMap::new();
892        let mut directories = vec![root.to_path_buf()];
893        while let Some(directory) = directories.pop() {
894            for entry in fs::read_dir(&directory).expect("generated directory reads") {
895                let path = entry.expect("generated entry reads").path();
896                if path.is_dir() {
897                    directories.push(path);
898                } else {
899                    files.insert(
900                        path.strip_prefix(root)
901                            .expect("generated path is beneath its root")
902                            .to_path_buf(),
903                        fs::read(path).expect("generated file reads"),
904                    );
905                }
906            }
907        }
908        files
909    }
910
911    fn write_workspace(root: &Path, source: &str) -> PathBuf {
912        fs::create_dir_all(root.join("schema/fragments")).expect("schema directory creates");
913        fs::create_dir_all(root.join("migrations/v2")).expect("migration directory creates");
914        fs::write(
915            root.join("typebridge.yaml"),
916            "format: typebridge.workspace/v1\n\
917             schema:\n  root: schema/schema.yaml\n  ownership: exclusive\n  managed-scope: ordered-atomic\n\
918             compatibility:\n  semantic-profile: typedb-3.12.1/v1\n\
919             migrations:\n  directory: migrations/v2\n  app-label: ordered_atomic\n\
920             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\
921             artifacts:\n  schema-authority:\n    output: generated/schema-authority.json\n",
922        )
923        .expect("manifest writes");
924        fs::write(
925            root.join("schema/schema.yaml"),
926            "format: typebridge.schema-set/v1\nsources: [fragments/*.yaml]\n",
927        )
928        .expect("schema set writes");
929        fs::write(root.join("schema/fragments/model.yaml"), source).expect("schema writes");
930        root.join("typebridge.yaml")
931    }
932
933    #[test]
934    fn workspace_type_names_resolve_collisions_in_generated_packages() {
935        let directory = tempfile::tempdir().unwrap();
936        let root = directory.path();
937        let manifest = write_workspace(
938            root,
939            "format: typebridge.schema/v2\nattributes:\n  powertrain_ref: { value: string }\nentities:\n  Powertrain:\n    owns:\n      powertrain_ref: { card: 1 }\n",
940        );
941        let workspace = load_workspace(&manifest).unwrap();
942        assert!(run_schema_generate(&workspace).is_err());
943        let original = fs::read_to_string(&manifest).unwrap();
944        let mut configured = original;
945        for target in ["python", "typescript", "rust", "c"] {
946            let output = format!("output: generated/{target}");
947            configured = configured.replace(&output, &format!("{output}\n    type-names:\n      attribute:\n        powertrain_ref: PowertrainReferenceValue"));
948        }
949        fs::write(&manifest, configured).unwrap();
950        let workspace = load_workspace(&manifest).unwrap();
951        run_schema_generate(&workspace).unwrap();
952        let models = fs::read_to_string(root.join("generated/typescript/src/models.ts")).unwrap();
953        assert!(models.contains("PowertrainReferenceValue"));
954        assert!(models.contains("powertrain_ref"));
955        let first = snapshot(&root.join("generated"));
956        run_schema_generate(&workspace).unwrap();
957        assert_eq!(first, snapshot(&root.join("generated")));
958    }
959
960    #[test]
961    fn injected_c_emitter_failure_preserves_all_four_ordered_packages() {
962        let directory = tempfile::tempdir().expect("workspace directory");
963        let root = directory.path();
964        let manifest = write_workspace(
965            root,
966            "format: typebridge.schema/v2\n\
967             attributes:\n  identifier: { value: string }\n  tag: { value: string }\n\
968             entities:\n  person:\n    owns:\n      identifier: { key: true }\n      tag: { card: { min: 0, max: 3 }, ordered: true, distinct: true }\n\
969             relations:\n  group:\n    relates:\n      member: { card: { min: 0, max: 3 }, ordered: true, distinct: true }\n\
970             plays:\n  person:\n    group:\n      member: { card: { min: 0, max: 1 } }\n",
971        );
972        let accepted = load_workspace(&manifest).expect("ordered workspace loads");
973        run_schema_generate(&accepted).expect("ordered packages generate");
974
975        let accepted_trees = ["python", "typescript", "rust", "c"]
976            .map(|target| (target, snapshot(&root.join("generated").join(target))));
977        let expected_history = accepted
978            .migration_history_bundle_bytes()
979            .expect("canonical migration history bundle");
980        for (target, tree) in &accepted_trees {
981            assert_eq!(
982                tree.get(std::path::Path::new(
983                    type_bridge_schema_codegen::MIGRATION_HISTORY_BUNDLE_RESOURCE,
984                )),
985                Some(&expected_history),
986                "{target} package must embed the byte-identical canonical history bundle",
987            );
988        }
989        let accepted_authority =
990            fs::read(root.join("generated/schema-authority.json")).expect("authority reads");
991
992        run_schema_generate(&accepted).expect("identical ordered packages regenerate");
993        for (target, accepted_tree) in &accepted_trees {
994            assert_eq!(
995                &snapshot(&root.join("generated").join(target)),
996                accepted_tree,
997                "{target} destination changed after deterministic regeneration",
998            );
999        }
1000        assert_eq!(
1001            fs::read(root.join("generated/schema-authority.json")).expect("authority rereads"),
1002            accepted_authority,
1003            "schema authority changed after deterministic regeneration",
1004        );
1005
1006        fs::write(
1007            root.join("schema/fragments/model.yaml"),
1008            "format: typebridge.schema/v2\n\
1009             attributes:\n  identifier: { value: string }\n  tag: { value: string }\n  title: { value: string }\n\
1010             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\
1011             relations:\n  group:\n    relates:\n      member: { card: { min: 0, max: 4 }, ordered: true, distinct: true }\n\
1012             plays:\n  person:\n    group:\n      member: { card: { min: 0, max: 1 } }\n",
1013        )
1014        .expect("changed schema writes");
1015        let changed = load_workspace(&manifest).expect("changed ordered workspace loads");
1016        let mut attempted = Vec::new();
1017        let error = run_schema_generate_with(&changed, |target, resolved, authority| {
1018            attempted.push(target);
1019            if target == BindingTarget::C {
1020                return Err("injected C emitter failure".to_owned());
1021            }
1022            generate_binding_package(
1023                target,
1024                resolved,
1025                authority,
1026                changed.config().app_label(),
1027                changed.config().type_name_overrides(target),
1028            )
1029        })
1030        .expect_err("injected C emitter failure rejects the transaction");
1031        assert_eq!(error, "injected C emitter failure");
1032        assert_eq!(
1033            attempted,
1034            vec![
1035                BindingTarget::Python,
1036                BindingTarget::TypeScript,
1037                BindingTarget::Rust,
1038                BindingTarget::C,
1039            ],
1040            "the injected failure did not occur after the three earlier packages prepared",
1041        );
1042
1043        for (target, accepted_tree) in &accepted_trees {
1044            assert_eq!(
1045                snapshot(&root.join("generated").join(target)),
1046                *accepted_tree,
1047                "{target} destination changed after the injected C emitter failure",
1048            );
1049        }
1050        assert_eq!(
1051            fs::read(root.join("generated/schema-authority.json")).expect("authority rereads"),
1052            accepted_authority,
1053            "schema authority changed after the injected C emitter failure",
1054        );
1055
1056        publish_atomic_generation_observation(json!({
1057            "targets": ["python", "typescript", "rust", "c"],
1058            "common_authority_identity": {
1059                "schema_source_equal": true,
1060                "semantic_profile": "typedb-3.12.1/v1",
1061                "semantic_fingerprint_equal": true,
1062                "resource_ledger_equal": true,
1063            },
1064            "package_identities_distinct": true,
1065            "generated_sidecars": [],
1066            "no_sidecar_runtime_dependency": true,
1067            "deterministic_rerun": {
1068                "byte_identical": true,
1069                "published_targets": accepted_trees.len(),
1070            },
1071            "injected_failure": {
1072                "failed_target": "c",
1073                "published_targets": 0,
1074                "previous_outputs_unchanged": true,
1075                "staging_artifacts_remaining": 0,
1076            },
1077        }));
1078    }
1079}
1080
1081/// Export canonical declared bytes for explicitly low-level V2 tooling.
1082fn run_schema_export_declared(
1083    workspace: &TypeBridgeWorkspace,
1084    output: &Path,
1085) -> Result<(), String> {
1086    use type_bridge_contract::schema::encode_declared_schema;
1087
1088    validate_declared_output_path(output)?;
1089    let root = workspace.output_root()?;
1090    let parent = root.open_beneath(output.parent().unwrap_or_else(|| std::path::Path::new("")))?;
1091    let file_name = output
1092        .file_name()
1093        .ok_or_else(|| "declared-schema output has no file name".to_owned())?;
1094    let destination = parent.display_path().join(file_name);
1095    let bytes = encode_declared_schema(workspace.declared_schema()).map_err(display)?;
1096    parent.write_atomic(file_name, &bytes)?;
1097    println!(
1098        "wrote canonical declared schema to {}\n  declared identity: {}",
1099        destination.display(),
1100        workspace
1101            .declared_schema()
1102            .declared_identity_fingerprint()
1103            .as_fingerprint()
1104            .digest()
1105            .to_hex(),
1106    );
1107    Ok(())
1108}
1109
1110fn validate_declared_output_path(output: &Path) -> Result<(), String> {
1111    let Some(portable) = output.to_str() else {
1112        return Err("declared-schema output must be valid UTF-8".into());
1113    };
1114    let invalid_spelling = portable.is_empty()
1115        || portable.contains(['\\', ':', '\0'])
1116        || portable.bytes().any(|byte| byte.is_ascii_control())
1117        || portable
1118            .split('/')
1119            .any(|segment| segment.is_empty() || matches!(segment, "." | ".."));
1120    let invalid_components = output.is_absolute()
1121        || output
1122            .components()
1123            .any(|component| !matches!(component, Component::Normal(_)));
1124    if invalid_spelling || invalid_components {
1125        return Err("declared-schema output must be a confined portable workspace path".into());
1126    }
1127    if output.extension().and_then(|extension| extension.to_str()) != Some("json") {
1128        return Err("declared-schema output must end in lowercase .json".into());
1129    }
1130    Ok(())
1131}
1132
1133fn validate_generated_relative_path(path: &std::path::Path) -> Result<(), String> {
1134    if path.as_os_str().is_empty()
1135        || path
1136            .components()
1137            .any(|component| !matches!(component, std::path::Component::Normal(_)))
1138    {
1139        return Err(format!(
1140            "generated output path {:?} is not a confined relative file",
1141            path
1142        ));
1143    }
1144    Ok(())
1145}
1146
1147enum ConnectedAction {
1148    Apply {
1149        approvals: Vec<String>,
1150    },
1151    Verify,
1152    Rollback {
1153        removals: BTreeSet<type_bridge_contract::migration::MigrationId>,
1154        approvals: Vec<String>,
1155    },
1156    Adopt {
1157        archive_directory: PathBuf,
1158        name: String,
1159    },
1160}
1161
1162fn secure_connect_options(
1163    environment: &WorkspaceEnvironment,
1164) -> type_bridge_orm::SecureConnectOptions {
1165    let tls_mode = match environment.transport_policy() {
1166        WorkspaceTransportPolicy::Disabled => type_bridge_orm::TlsMode::Disabled,
1167        WorkspaceTransportPolicy::NativeRoots => type_bridge_orm::TlsMode::NativeRoots,
1168        WorkspaceTransportPolicy::CustomRootCa(root_ca) => {
1169            type_bridge_orm::TlsMode::CustomRootCa(root_ca.as_path().to_path_buf())
1170        }
1171    };
1172    let mut options = type_bridge_orm::SecureConnectOptions {
1173        tls_mode,
1174        ..type_bridge_orm::SecureConnectOptions::default()
1175    };
1176    if let Some(port) = environment.http_port() {
1177        options.http_port = port;
1178    }
1179    options
1180}
1181
1182fn preflight_secure_connect_options(
1183    workspace: &TypeBridgeWorkspace,
1184    environment_name: &str,
1185) -> Result<type_bridge_orm::PreparedSecureConnectOptions, String> {
1186    let environment = workspace
1187        .config()
1188        .environment(environment_name)
1189        .ok_or_else(|| {
1190            format!("environment {environment_name:?} is not owned by this workspace")
1191        })?;
1192    let options = secure_connect_options(environment);
1193    match workspace
1194        .capture_environment_custom_root_ca(environment_name)
1195        .map_err(display)?
1196    {
1197        Some(bytes) => options
1198            .prepare_transport_from_captured_custom_root(bytes)
1199            .map_err(display),
1200        None => options.prepare_transport().map_err(display),
1201    }
1202}
1203
1204fn run_connected(
1205    workspace: &TypeBridgeWorkspace,
1206    environment: &str,
1207    action: ConnectedAction,
1208) -> Result<(), String> {
1209    let runtime = tokio::runtime::Runtime::new()
1210        .map_err(|error| format!("cannot start the async runtime: {error}"))?;
1211    runtime.block_on(run_connected_async(workspace, environment, action))
1212}
1213
1214async fn run_connected_async(
1215    workspace: &TypeBridgeWorkspace,
1216    environment_name: &str,
1217    action: ConnectedAction,
1218) -> Result<(), String> {
1219    let config = workspace.config();
1220    let Some(environment) = config.environment(environment_name) else {
1221        let known = config
1222            .environments()
1223            .keys()
1224            .cloned()
1225            .collect::<Vec<_>>()
1226            .join(", ");
1227        return Err(format!(
1228            "unknown environment {environment_name:?}; the manifest declares: [{known}]"
1229        ));
1230    };
1231    if matches!(
1232        &action,
1233        ConnectedAction::Apply { .. }
1234            | ConnectedAction::Rollback { .. }
1235            | ConnectedAction::Adopt { .. }
1236    ) && !environment.migrate()
1237    {
1238        return Err(format!(
1239            "environment {environment_name:?} is not opted into migration \
1240            application; set `migrate: true` in the manifest to allow it"
1241        ));
1242    }
1243    let supported = &type_bridge_schema_migration::typedb_3_12_1_profile().semantic_profile;
1244    if config.semantic_profile() != supported {
1245        return Err(format!(
1246            "workspace semantic profile {:?} cannot run connected TypeDB migration operations \
1247             [migration_typedb_semantic_profile_unsupported]; expected {:?}",
1248            config.semantic_profile().as_str(),
1249            supported.as_str(),
1250        ));
1251    }
1252    environment
1253        .requirements()
1254        .ensure_supported_by(&execution_capability_vocabulary().map_err(display)?)
1255        .map_err(display)?;
1256
1257    // Validate the name and capture one immutable archive-history authority
1258    // before creating the canonical directory or resolving credentials.
1259    let prepared_adoption = match &action {
1260        ConnectedAction::Adopt {
1261            archive_directory,
1262            name,
1263        } => Some(prepare_archive_adoption(
1264            workspace,
1265            archive_directory,
1266            name,
1267        )?),
1268        ConnectedAction::Apply { .. }
1269        | ConnectedAction::Rollback { .. }
1270        | ConnectedAction::Verify => None,
1271    };
1272
1273    // Retain one descriptor-backed authority for the whole connected action.
1274    // Adoption alone may create missing real directory components; apply and
1275    // verify remain fail-closed and non-creating here.
1276    let migration_directory = if matches!(&action, ConnectedAction::Adopt { .. }) {
1277        workspace.ensure_migration_directory().map_err(display)?
1278    } else {
1279        workspace.open_migration_directory().map_err(display)?
1280    };
1281    // Ordinary connected operations reject an incomplete adoption pair before
1282    // credentials, network I/O, or database creation. Adoption itself is the
1283    // sole recovery path permitted to observe and complete an exact orphan.
1284    let ordinary_graph = if prepared_adoption.is_none() {
1285        Some(
1286            workspace
1287                .discover_migrations_in(&migration_directory)
1288                .map_err(display)?,
1289        )
1290    } else {
1291        None
1292    };
1293    // Approval syntax, membership, safety, and digest binding are local
1294    // authority checks. Resolve them before credentials, network I/O, or
1295    // database creation; the runner re-discovers and rechecks the bound
1296    // manifest at execution time.
1297    let prepared_approvals = match &action {
1298        ConnectedAction::Apply { approvals } => Some(bind_approvals(
1299            ordinary_graph
1300                .as_ref()
1301                .ok_or_else(|| "internal apply history was not retained".to_owned())?,
1302            approvals,
1303        )?),
1304        ConnectedAction::Rollback { approvals, .. } => Some(bind_rollback_approvals(
1305            ordinary_graph
1306                .as_ref()
1307                .ok_or_else(|| "internal rollback history was not retained".to_owned())?,
1308            approvals,
1309        )?),
1310        ConnectedAction::Verify | ConnectedAction::Adopt { .. } => None,
1311    };
1312
1313    // Resolve and snapshot the complete transport policy before reading either
1314    // credential. Every later connect call clones this prepared handle, so no
1315    // custom-root path is reopened after secret resolution.
1316    let options = preflight_secure_connect_options(workspace, environment_name)?;
1317    let username = resolve_credential(environment.username())?;
1318    let password = resolve_credential(environment.password())?;
1319    let journal_name =
1320        type_bridge_schema_migration_typedb::derived_journal_database_name(environment.database());
1321    // `verify` is observational: it must never create the managed or
1322    // journal database (a typoed environment name would otherwise
1323    // materialize two databases). `adopt` requires the migrated v1 managed
1324    // database to already exist — bootstrapping an empty one would
1325    // guarantee a broken adoption — while its journal companion is new by
1326    // definition. Only migration-gated actions may bootstrap anything.
1327    let managed_requires_existing = match &action {
1328        ConnectedAction::Verify => Some(
1329            "`migration verify` is read-only and never creates databases \
1330             — apply migrations to this environment first",
1331        ),
1332        ConnectedAction::Adopt { .. } => {
1333            Some("`migration adopt` cutover requires the migrated v1 database to already exist")
1334        }
1335        ConnectedAction::Apply { .. } | ConnectedAction::Rollback { .. } => None,
1336    };
1337    // A TypeDB connection is server-scoped: binding a database name does not
1338    // require that database to exist. Negotiate and gate both pair members
1339    // before checking or creating either database, then retain both handles
1340    // through migration.
1341    let managed = std::sync::Arc::new(
1342        type_bridge_orm::Database::connect_prepared_secure_with_options(
1343            environment.uri(),
1344            environment.database(),
1345            &username,
1346            &password,
1347            options.clone(),
1348        )
1349        .await
1350        .map_err(|error| {
1351            sanitize_connected_error(
1352                "cannot connect the managed database".to_owned(),
1353                "typedb_database_connect_failed",
1354                error,
1355            )
1356        })?,
1357    );
1358    let journal = std::sync::Arc::new(
1359        type_bridge_orm::Database::connect_prepared_secure_with_options(
1360            environment.uri(),
1361            &journal_name,
1362            &username,
1363            &password,
1364            options,
1365        )
1366        .await
1367        .map_err(|error| {
1368            sanitize_connected_error(
1369                "cannot connect the journal database".to_owned(),
1370                "typedb_database_connect_failed",
1371                error,
1372            )
1373        })?,
1374    );
1375    type_bridge_schema_migration_typedb::require_supported_migration_execution_binding(
1376        &managed,
1377        &journal,
1378        workspace.delta_context(),
1379    )
1380    .map_err(display)?;
1381
1382    if let Some(reason) = managed_requires_existing {
1383        let exists = managed.database_exists().await.map_err(|error| {
1384            sanitize_connected_orm_error(
1385                format!("cannot check database {:?}", environment.database()),
1386                "typedb_database_exists_failed",
1387                error,
1388            )
1389        })?;
1390        if !exists {
1391            return Err(format!(
1392                "database {:?} does not exist; {reason}",
1393                environment.database()
1394            ));
1395        }
1396    } else {
1397        managed.create_database().await.map_err(|error| {
1398            sanitize_connected_orm_error(
1399                format!("cannot ensure database {:?}", environment.database()),
1400                "typedb_database_ensure_failed",
1401                error,
1402            )
1403        })?;
1404    }
1405
1406    // Adoption's live-schema comparison and complete pair publication precede
1407    // journal creation. Publication is bridge-first under the canonical
1408    // authoring lock, rolls back files created by a failed attempt, and accepts
1409    // exact orphan pieces so interrupted attempts remain adopt-only resumable.
1410    let adoption_files = if let Some(prepared) = prepared_adoption.as_ref() {
1411        verify_prepared_adoption_live(&managed, prepared).await?;
1412        Some(publish_prepared_adoption(
1413            workspace,
1414            &migration_directory,
1415            prepared,
1416        )?)
1417    } else {
1418        None
1419    };
1420
1421    if matches!(&action, ConnectedAction::Verify) {
1422        let exists = journal.database_exists().await.map_err(|error| {
1423            sanitize_connected_orm_error(
1424                format!("cannot check database {journal_name:?}"),
1425                "typedb_database_exists_failed",
1426                error,
1427            )
1428        })?;
1429        if !exists {
1430            return Err(format!(
1431                "database {journal_name:?} does not exist; `migration verify` is read-only and never creates databases"
1432            ));
1433        }
1434    } else {
1435        journal.create_database().await.map_err(|error| {
1436            sanitize_connected_orm_error(
1437                format!("cannot ensure database {journal_name:?}"),
1438                "typedb_database_ensure_failed",
1439                error,
1440            )
1441        })?;
1442    }
1443
1444    let genesis = workspace
1445        .migration_genesis_in(&migration_directory)
1446        .map_err(display)?;
1447    let lowering = type_bridge_schema_migration::SchemaLoweringBinding::current(
1448        workspace.delta_context().available_capabilities().clone(),
1449    )
1450    .map_err(display)?;
1451    let runner = type_bridge_schema_migration_typedb::TypeDbMigrationRunner::new(
1452        managed,
1453        journal,
1454        genesis.clone(),
1455        workspace.delta_context().clone(),
1456        lowering,
1457        config.migration_policy().clone(),
1458    );
1459    let holder =
1460        type_bridge_schema_migration::LeaseHolderId::new("type-bridge-cli").map_err(display)?;
1461    let directory = migration_directory.directory();
1462
1463    match action {
1464        ConnectedAction::Apply { .. } => {
1465            let approvals = prepared_approvals
1466                .as_deref()
1467                .ok_or_else(|| "internal apply approvals were not retained".to_owned())?;
1468            let outcome = runner
1469                .apply_in(
1470                    directory,
1471                    &type_bridge_schema_migration::MigrationApplyTarget::DefaultHead,
1472                    &holder,
1473                    approvals,
1474                )
1475                .await
1476                .map_err(display)?;
1477            match outcome {
1478                type_bridge_schema_migration_typedb::MigrationDirectoryApplyOutcome::UpToDate => {
1479                    println!("applied ledger already reaches the committed head");
1480                    Ok(())
1481                }
1482                type_bridge_schema_migration_typedb::MigrationDirectoryApplyOutcome::Executed(
1483                    type_bridge_schema_migration::MigrationExecutionOutcome::Applied { .. },
1484                ) => {
1485                    println!("applied the committed chain");
1486                    Ok(())
1487                }
1488                type_bridge_schema_migration_typedb::MigrationDirectoryApplyOutcome::Executed(
1489                    outcome,
1490                ) => Err(sanitize_migration_execution_outcome(
1491                    "apply did not complete",
1492                    outcome,
1493                )),
1494            }
1495        }
1496        ConnectedAction::Rollback { removals, .. } => {
1497            let approvals = prepared_approvals
1498                .as_deref()
1499                .ok_or_else(|| "internal rollback approvals were not retained".to_owned())?;
1500            match runner
1501                .rollback_in(directory, &removals, &holder, approvals)
1502                .await
1503                .map_err(display)?
1504            {
1505                type_bridge_schema_migration_typedb::MigrationDirectoryRollbackOutcome::UpToDate => {
1506                    println!("requested migrations are already absent from the applied ledger");
1507                    Ok(())
1508                }
1509                type_bridge_schema_migration_typedb::MigrationDirectoryRollbackOutcome::Executed(
1510                    type_bridge_schema_migration::MigrationRollbackOutcome::RolledBack { .. },
1511                ) => {
1512                    println!("rolled back the requested migrations");
1513                    Ok(())
1514                }
1515                type_bridge_schema_migration_typedb::MigrationDirectoryRollbackOutcome::Executed(
1516                    outcome,
1517                ) => Err(sanitize_migration_rollback_outcome(outcome)),
1518            }
1519        }
1520        ConnectedAction::Verify => {
1521            let report = runner
1522                .verify_in(directory, Some(workspace.declared_schema()))
1523                .await
1524                .map_err(display)?;
1525            if report.is_clean() {
1526                println!(
1527                    "migration state is coherent\n  applied frontier: {}",
1528                    report
1529                        .applied_frontier()
1530                        .iter()
1531                        .map(|id| format!("{}/{}", id.app_label().as_str(), id.name().as_str()))
1532                        .collect::<Vec<_>>()
1533                        .join(", "),
1534                );
1535                Ok(())
1536            } else {
1537                for finding in report.findings() {
1538                    eprintln!("drift: {finding:?}");
1539                }
1540                Err(format!("{} drift finding(s)", report.findings().len()))
1541            }
1542        }
1543        ConnectedAction::Adopt { .. } => {
1544            let bridge_display_path = adoption_files
1545                .ok_or_else(|| "internal adoption preflight state was not retained".to_owned())?;
1546            let prepared = prepared_adoption
1547                .as_ref()
1548                .ok_or_else(|| "internal adoption authority was not retained".to_owned())?;
1549            let outcome = runner
1550                .import_verified_legacy_frontier_in(
1551                    &prepared.history,
1552                    &prepared.reconstructed,
1553                    directory,
1554                    &holder,
1555                )
1556                .await;
1557            match outcome {
1558                Ok(
1559                    type_bridge_schema_migration_typedb::MigrationDirectoryApplyOutcome::UpToDate,
1560                ) => {
1561                    println!("archive history is already adopted; the bridged ledger is current");
1562                    Ok(())
1563                }
1564                Ok(
1565                    type_bridge_schema_migration_typedb::MigrationDirectoryApplyOutcome::Executed(
1566                        type_bridge_schema_migration::MigrationExecutionOutcome::Applied { .. },
1567                    ),
1568                ) => {
1569                    println!(
1570                        "adopted the archive history\n  genesis: {}\n  bridge: {}",
1571                        migration_directory
1572                            .display_path()
1573                            .join(type_bridge_schema_compat::ADOPTED_GENESIS_FILE_NAME)
1574                            .display(),
1575                        bridge_display_path.display(),
1576                    );
1577                    Ok(())
1578                }
1579                Ok(
1580                    type_bridge_schema_migration_typedb::MigrationDirectoryApplyOutcome::Executed(
1581                        outcome,
1582                    ),
1583                ) => Err(sanitize_migration_execution_outcome(
1584                    "adoption checkpoint did not complete",
1585                    outcome,
1586                )),
1587                Err(error) => Err(display(error)),
1588            }
1589        }
1590    }
1591}
1592
1593struct PreparedArchiveAdoption {
1594    history: type_bridge_migration::LegacyAdoptionHistory,
1595    reconstructed: type_bridge_migration::VerifiedLegacyHead,
1596    authority: type_bridge_schema_compat::AdoptedGenesisAuthority,
1597    bridge: type_bridge_schema_migration::VerifiedSchemaMigrationManifest,
1598    bridge_name: String,
1599    bridge_bytes: Vec<u8>,
1600}
1601
1602/// Validate and derive every filesystem authority from one retained archive
1603/// history capture. This function performs no canonical-directory writes.
1604fn prepare_archive_adoption(
1605    workspace: &TypeBridgeWorkspace,
1606    archive_directory: &std::path::Path,
1607    name: &str,
1608) -> Result<PreparedArchiveAdoption, String> {
1609    // Validate the caller-controlled name before loading history or creating
1610    // the configured canonical directory.
1611    let migration_name =
1612        type_bridge_contract::migration::MigrationName::new(name.to_owned()).map_err(display)?;
1613    let bridge_name = format!("{}.tbmigration.json", migration_name.as_str());
1614    let history =
1615        type_bridge_migration::load_adoption_history(archive_directory).map_err(|error| {
1616            format!("archive migration directory failed the checked adoption loader: {error}")
1617        })?;
1618    let reconstructed = type_bridge_migration::reconstruct_legacy_head(&history)
1619        .map_err(|error| format!("archive head reconstruction failed: {error}"))?;
1620    let authority = type_bridge_schema_compat::parse_adopted_genesis_authority(
1621        type_bridge_contract::schema::DocumentId::new("legacy-head-snapshot.typeql")
1622            .map_err(display)?,
1623        reconstructed.schema_typeql(),
1624    )
1625    .map_err(display)?;
1626    let frontier = type_bridge_schema_migration_typedb::extract_legacy_frontier(history.graph())
1627        .map_err(display)?;
1628    let applied_set =
1629        type_bridge_schema_migration_typedb::extract_legacy_applied_set_digest(history.graph())
1630            .map_err(display)?;
1631    let id = type_bridge_contract::migration::MigrationId::from_components(
1632        type_bridge_contract::migration::MigrationAppLabel::new(
1633            workspace.config().app_label().as_str().to_owned(),
1634        )
1635        .map_err(display)?,
1636        migration_name,
1637    );
1638    let bridge = type_bridge_schema_migration::build_legacy_frontier_bridge(
1639        id,
1640        frontier,
1641        applied_set,
1642        authority.declared(),
1643        workspace.delta_context(),
1644    )
1645    .map_err(display)?;
1646    let bridge_bytes =
1647        type_bridge_schema_migration::encode_verified_manifest(&bridge).map_err(display)?;
1648    history
1649        .require_unchanged_head(&reconstructed)
1650        .map_err(|error| {
1651            format!("archive migration directory changed during adoption preparation: {error}")
1652        })?;
1653    Ok(PreparedArchiveAdoption {
1654        history,
1655        reconstructed,
1656        authority,
1657        bridge,
1658        bridge_name,
1659        bridge_bytes,
1660    })
1661}
1662
1663/// Compare the live managed schema with the prepared immutable head without
1664/// using live state as publication authority.
1665async fn verify_prepared_adoption_live(
1666    managed: &type_bridge_orm::Database,
1667    prepared: &PreparedArchiveAdoption,
1668) -> Result<(), String> {
1669    let export = managed
1670        .schema_text()
1671        .await
1672        .map_err(sanitize_schema_export_error)?;
1673    prepared
1674        .history
1675        .require_unchanged_head(&prepared.reconstructed)
1676        .map_err(|error| format!("archive adoption history changed during live export: {error}"))?;
1677    let expected_internal = type_bridge_schema_compat::released_typeql_to_declared_projection(
1678        type_bridge_contract::schema::DocumentId::new("managed-fence-schema.typeql")
1679            .map_err(display)?,
1680        type_bridge_schema_migration_typedb::MANAGED_FENCE_SCHEMA_TYPEQL,
1681    )
1682    .map_err(display)?;
1683    let live = type_bridge_schema_compat::parse_adopted_genesis_authority_with_internal(
1684        type_bridge_contract::schema::DocumentId::new("legacy-live-head.typeql")
1685            .map_err(display)?,
1686        &export,
1687        Some(&expected_internal),
1688    )
1689    .map_err(display)?;
1690    if live.legacy_identity() != prepared.authority.legacy_identity()
1691        || live.declared().declared_identity_fingerprint()
1692            != prepared
1693                .authority
1694                .declared()
1695                .declared_identity_fingerprint()
1696        || live.released_extension_identity() != prepared.authority.released_extension_identity()
1697    {
1698        return Err(
1699            "live managed schema differs from the independently verified archive-head snapshot"
1700                .to_owned(),
1701        );
1702    }
1703    Ok(())
1704}
1705
1706/// Publish the bridge/genesis pair under the shared authoring lock.
1707///
1708/// The prospective complete graph is replay-verified before publication. The
1709/// bridge is made visible first, so ordinary readers fail closed during the
1710/// short incomplete interval. Exact pre-existing orphan pieces are retained
1711/// and completed; only files created by this attempt are rolled back.
1712fn publish_prepared_adoption(
1713    workspace: &TypeBridgeWorkspace,
1714    migration_directory: &type_bridge_workspace::MigrationDirectoryAuthority,
1715    prepared: &PreparedArchiveAdoption,
1716) -> Result<PathBuf, String> {
1717    publish_prepared_adoption_with_after_bridge(workspace, migration_directory, prepared, || {})
1718}
1719
1720fn publish_prepared_adoption_with_after_bridge<F>(
1721    workspace: &TypeBridgeWorkspace,
1722    migration_directory: &type_bridge_workspace::MigrationDirectoryAuthority,
1723    prepared: &PreparedArchiveAdoption,
1724    after_bridge: F,
1725) -> Result<PathBuf, String>
1726where
1727    F: FnOnce(),
1728{
1729    let directory = migration_directory.directory();
1730    let _lock = directory.try_acquire_authoring_lock().map_err(|error| {
1731        if error.kind() == std::io::ErrorKind::WouldBlock {
1732            "migration adoption conflicts with another canonical history publisher".to_owned()
1733        } else {
1734            format!("cannot lock canonical migration publication: {error}")
1735        }
1736    })?;
1737    let genesis_name = type_bridge_schema_compat::ADOPTED_GENESIS_FILE_NAME;
1738    let genesis_bytes = prepared.reconstructed.schema_typeql().as_bytes();
1739    let mut bridge_created = false;
1740    let mut genesis_created = false;
1741    let mut after_bridge = Some(after_bridge);
1742
1743    let publication = (|| -> Result<(), String> {
1744        if let Some(existing) = read_existing_authority(directory, genesis_name)?
1745            && existing != genesis_bytes
1746        {
1747            return Err(format!(
1748                "{genesis_name} already exists but differs from the verified archive-head snapshot"
1749            ));
1750        }
1751        let bridge_already_published =
1752            if let Some(existing) = read_existing_authority(directory, &prepared.bridge_name)? {
1753                if existing != prepared.bridge_bytes {
1754                    return Err(format!(
1755                        "{} already exists with different authority bytes",
1756                        prepared.bridge_name
1757                    ));
1758                }
1759                true
1760            } else {
1761                false
1762            };
1763
1764        let (current, evidence) =
1765            type_bridge_schema_migration::discover_verified_migration_chain_with_evidence_in(
1766                directory,
1767                prepared.authority.declared(),
1768                workspace.delta_context(),
1769            )
1770            .map_err(display)?;
1771        let prospective = if current.manifest(prepared.bridge.id()).is_some() {
1772            current
1773        } else {
1774            let manifests = current
1775                .manifests()
1776                .map(|(_, manifest)| manifest.clone())
1777                .chain(std::iter::once(prepared.bridge.clone()))
1778                .collect::<Vec<_>>();
1779            type_bridge_schema_migration::MigrationHistoryGraph::from_verified(manifests)
1780                .map_err(display)?
1781        };
1782        type_bridge_schema_migration::require_adoption_authority_pair(&prospective, true)
1783            .map_err(display)?;
1784        evidence.require_unchanged(directory).map_err(display)?;
1785        prepared
1786            .history
1787            .require_unchanged_head(&prepared.reconstructed)
1788            .map_err(|error| {
1789                format!("archive adoption history changed before pair publication: {error}")
1790            })?;
1791
1792        if !bridge_already_published {
1793            bridge_created =
1794                publish_authority(directory, &prepared.bridge_name, &prepared.bridge_bytes)?;
1795        }
1796        if let Some(after_bridge) = after_bridge.take() {
1797            after_bridge();
1798        }
1799        prepared
1800            .history
1801            .require_unchanged_head(&prepared.reconstructed)
1802            .map_err(|error| {
1803                format!("archive adoption history changed before genesis publication: {error}")
1804            })?;
1805        genesis_created = publish_authority(directory, genesis_name, genesis_bytes)?;
1806        prepared
1807            .history
1808            .require_unchanged_head(&prepared.reconstructed)
1809            .map_err(|error| {
1810                format!("archive adoption history changed after pair publication: {error}")
1811            })?;
1812        workspace
1813            .discover_migrations_in(migration_directory)
1814            .map_err(display)?;
1815        Ok(())
1816    })();
1817
1818    if let Err(error) = publication {
1819        return Err(rollback_adoption_publication(
1820            directory,
1821            &prepared.bridge_name,
1822            bridge_created,
1823            genesis_created,
1824            error,
1825        ));
1826    }
1827    Ok(migration_directory
1828        .display_path()
1829        .join(&prepared.bridge_name))
1830}
1831
1832fn rollback_adoption_publication(
1833    directory: &type_bridge_schema_migration::MigrationDirectory,
1834    bridge_name: &str,
1835    bridge_created: bool,
1836    genesis_created: bool,
1837    primary: String,
1838) -> String {
1839    let mut cleanup_errors = Vec::new();
1840    if genesis_created
1841        && let Err(error) =
1842            directory.remove_file(type_bridge_schema_compat::ADOPTED_GENESIS_FILE_NAME.as_ref())
1843    {
1844        cleanup_errors.push(format!("cannot remove newly published genesis: {error}"));
1845    }
1846    if bridge_created && let Err(error) = directory.remove_file(bridge_name.as_ref()) {
1847        cleanup_errors.push(format!("cannot remove newly published bridge: {error}"));
1848    }
1849    if (bridge_created || genesis_created)
1850        && let Err(error) = directory.sync_all()
1851    {
1852        cleanup_errors.push(format!("cannot flush adoption rollback: {error}"));
1853    }
1854    if cleanup_errors.is_empty() {
1855        primary
1856    } else {
1857        format!(
1858            "{primary}; adoption publication rollback failed: {}",
1859            cleanup_errors.join("; ")
1860        )
1861    }
1862}
1863
1864/// Publish immutable authority from a unique, flushed same-directory temp.
1865///
1866/// Hard-link publication is atomic and no-replace. An existing final name is
1867/// accepted only when its bounded bytes are identical, allowing a retry to
1868/// recover after publication succeeded but the caller did not observe it. In
1869/// particular, a directory-sync error may be reported after the final link is
1870/// already durable; that exact orphan is intentionally left for the same
1871/// adoption command to recognize and complete on retry.
1872fn publish_authority(
1873    directory: &type_bridge_schema_migration::MigrationDirectory,
1874    name: &str,
1875    bytes: &[u8],
1876) -> Result<bool, String> {
1877    use std::io::Write;
1878    if let Some(existing) = read_existing_authority(directory, name)? {
1879        if existing == bytes {
1880            return Ok(false);
1881        }
1882        return Err(format!(
1883            "{name} already exists with different authority bytes"
1884        ));
1885    }
1886    let mut temporary = None;
1887    for attempt in 0..128_u64 {
1888        let candidate = unique_authority_temporary_name(name, attempt);
1889        match directory.create_new(candidate.as_ref()) {
1890            Ok(mut file) => {
1891                if let Err(error) = file.write_all(bytes).and_then(|()| file.sync_all()) {
1892                    let _ = directory.remove_file(candidate.as_ref());
1893                    return Err(format!("cannot write {candidate}: {error}"));
1894                }
1895                temporary = Some(candidate);
1896                break;
1897            }
1898            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
1899            Err(error) => {
1900                return Err(format!("cannot create {candidate}: {error}"));
1901            }
1902        }
1903    }
1904    let temporary = temporary.ok_or_else(|| {
1905        format!("cannot allocate a unique temporary authority file beside {name}")
1906    })?;
1907    let publication = directory.hard_link(temporary.as_ref(), name.as_ref());
1908    match publication {
1909        Ok(()) => {
1910            if let Err(error) = directory.sync_all() {
1911                let _ = directory.remove_file(temporary.as_ref());
1912                return Err(format!("cannot flush migration directory: {error}"));
1913            }
1914            let _ = directory.remove_file(temporary.as_ref());
1915            directory
1916                .sync_all()
1917                .map_err(|error| format!("cannot flush migration directory: {error}"))?;
1918            Ok(true)
1919        }
1920        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
1921            let _ = directory.remove_file(temporary.as_ref());
1922            let existing = read_existing_authority(directory, name)?
1923                .ok_or_else(|| format!("{name} disappeared during no-replace publication"))?;
1924            if existing == bytes {
1925                Ok(false)
1926            } else {
1927                Err(format!(
1928                    "{name} was concurrently published with different authority bytes"
1929                ))
1930            }
1931        }
1932        Err(error) => {
1933            let _ = directory.remove_file(temporary.as_ref());
1934            Err(format!("cannot publish {name}: {error}"))
1935        }
1936    }
1937}
1938
1939fn read_existing_authority(
1940    directory: &type_bridge_schema_migration::MigrationDirectory,
1941    name: &str,
1942) -> Result<Option<Vec<u8>>, String> {
1943    use std::io::Read;
1944    let file = match directory.open_regular_readonly(name.as_ref()) {
1945        Ok(file) => file,
1946        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1947        Err(error) => return Err(format!("cannot read {name}: {error}")),
1948    };
1949    let limit = type_bridge_contract::limits::MAX_CANONICAL_BYTES;
1950    let mut bytes = Vec::new();
1951    file.take(u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1))
1952        .read_to_end(&mut bytes)
1953        .map_err(|error| format!("cannot read {name}: {error}"))?;
1954    if bytes.len() > limit {
1955        return Err(format!("{name} exceeds the 16 MiB authority ceiling"));
1956    }
1957    Ok(Some(bytes))
1958}
1959
1960fn unique_authority_temporary_name(name: &str, attempt: u64) -> String {
1961    use std::sync::atomic::{AtomicU64, Ordering};
1962    static NEXT_AUTHORITY_TEMPORARY: AtomicU64 = AtomicU64::new(1);
1963    let nonce = NEXT_AUTHORITY_TEMPORARY.fetch_add(1, Ordering::Relaxed);
1964    format!(".{name}.{}.{}.{}.tmp", std::process::id(), nonce, attempt)
1965}
1966
1967fn resolve_credential(
1968    reference: &type_bridge_workspace::SecretReference,
1969) -> Result<String, String> {
1970    std::env::var(reference.environment_variable()).map_err(|_| {
1971        format!(
1972            "credential environment variable {:?} is not set",
1973            reference.environment_variable()
1974        )
1975    })
1976}
1977
1978#[cfg(test)]
1979mod credential_error_redaction_tests {
1980    use super::*;
1981    use type_bridge_contract::diagnostic::{Diagnostic, DiagnosticCategory, DiagnosticCode};
1982    use type_bridge_typedb_runtime::RuntimeError;
1983
1984    const PROVIDER_TEXT: &str =
1985        "TB_ADDRESS_SECRET TB_USERNAME_SECRET TB_PASSWORD_SECRET TB_PROVIDER_SECRET";
1986    const SECRETS: [&str; 4] = [
1987        "TB_ADDRESS_SECRET",
1988        "TB_USERNAME_SECRET",
1989        "TB_PASSWORD_SECRET",
1990        "TB_PROVIDER_SECRET",
1991    ];
1992
1993    fn hostile_secure_error() -> type_bridge_orm::SecureConnectError {
1994        type_bridge_orm::SecureConnectError::Runtime(RuntimeError::Connection(
1995            PROVIDER_TEXT.to_owned(),
1996        ))
1997    }
1998
1999    #[test]
2000    fn connected_lifecycle_contexts_drop_hostile_provider_text() {
2001        for (context, code) in [
2002            (
2003                "cannot check database \"managed\"",
2004                "typedb_database_exists_failed",
2005            ),
2006            (
2007                "cannot ensure database \"managed\"",
2008                "typedb_database_ensure_failed",
2009            ),
2010            (
2011                "cannot connect the managed database",
2012                "typedb_database_connect_failed",
2013            ),
2014        ] {
2015            let sanitized =
2016                sanitize_connected_error(context.to_owned(), code, hostile_secure_error());
2017            let rendered = format!("{sanitized}\n{sanitized:?}");
2018            for secret in SECRETS {
2019                assert!(!rendered.contains(secret), "{secret}: {rendered}");
2020            }
2021            assert!(rendered.contains(context), "{rendered}");
2022            assert!(rendered.contains(code), "{rendered}");
2023        }
2024    }
2025
2026    #[test]
2027    fn connected_orm_lifecycle_contexts_drop_hostile_provider_text() {
2028        for (context, code) in [
2029            (
2030                "cannot check database \"managed\"",
2031                "typedb_database_exists_failed",
2032            ),
2033            (
2034                "cannot ensure database \"managed\"",
2035                "typedb_database_ensure_failed",
2036            ),
2037        ] {
2038            let sanitized = sanitize_connected_orm_error(
2039                context.to_owned(),
2040                code,
2041                type_bridge_orm::OrmError::Connection(PROVIDER_TEXT.to_owned()),
2042            );
2043            let rendered = format!("{sanitized}\n{sanitized:?}");
2044            for secret in SECRETS {
2045                assert!(!rendered.contains(secret), "{secret}: {rendered}");
2046            }
2047            assert!(rendered.contains(context), "{rendered}");
2048            assert!(rendered.contains(code), "{rendered}");
2049        }
2050    }
2051
2052    #[test]
2053    fn connected_lifecycle_preserves_only_typed_safe_diagnostics() {
2054        let sanitized = sanitize_connected_error(
2055            "cannot connect the managed database".to_owned(),
2056            "typedb_database_connect_failed",
2057            type_bridge_orm::SecureConnectError::DriverTlsConfiguration { band: 9 },
2058        );
2059        assert!(
2060            sanitized.contains("tls_driver_lowering_failed"),
2061            "{sanitized}"
2062        );
2063        assert!(sanitized.contains("driver band 9"), "{sanitized}");
2064        assert!(
2065            !sanitized.contains("typedb_database_connect_failed"),
2066            "{sanitized}"
2067        );
2068    }
2069
2070    #[test]
2071    fn schema_export_drops_hostile_orm_text_and_source() {
2072        let sanitized = sanitize_schema_export_error(type_bridge_orm::OrmError::Connection(
2073            PROVIDER_TEXT.into(),
2074        ));
2075        let rendered = format!("{sanitized}\n{sanitized:?}");
2076        for secret in SECRETS {
2077            assert!(!rendered.contains(secret), "{secret}: {rendered}");
2078        }
2079        assert!(
2080            rendered.contains("typedb_schema_export_failed"),
2081            "{rendered}"
2082        );
2083    }
2084
2085    #[test]
2086    fn migration_runner_display_omits_provider_details() {
2087        let diagnostic = Diagnostic::new(
2088            DiagnosticCategory::InvalidContract,
2089            DiagnosticCode::new("migration_provider_test_failed").expect("static code"),
2090            "migration provider operation failed",
2091        )
2092        .with_detail("provider", PROVIDER_TEXT);
2093        let error = type_bridge_schema_migration_typedb::MigrationDirectoryApplyError::Diagnostic(
2094            diagnostic,
2095        );
2096
2097        let rendered = display(error);
2098        for secret in SECRETS {
2099            assert!(!rendered.contains(secret), "{secret}: {rendered}");
2100        }
2101        assert!(
2102            rendered.contains("migration_provider_test_failed"),
2103            "{rendered}"
2104        );
2105    }
2106
2107    #[test]
2108    fn migration_outcome_projection_omits_provider_details_for_apply_and_adopt() {
2109        use type_bridge_contract::migration::MigrationId;
2110        use type_bridge_schema_migration::{MigrationExecutionOutcome, MigrationExecutionPosition};
2111
2112        let diagnostic = || {
2113            Diagnostic::new(
2114                DiagnosticCategory::InvalidContract,
2115                DiagnosticCode::new("migration_provider_test_failed").expect("static code"),
2116                "migration provider operation failed",
2117            )
2118            .with_detail("provider", PROVIDER_TEXT)
2119        };
2120        for context in [
2121            "apply did not complete",
2122            "adoption checkpoint did not complete",
2123        ] {
2124            for (outcome, expected_state, expected_position) in [
2125                (
2126                    MigrationExecutionOutcome::RetrySafe {
2127                        migration_id: MigrationId::new("example", "0001_initial")
2128                            .expect("migration id"),
2129                        position: MigrationExecutionPosition::TransactionGroup(7),
2130                        diagnostic: diagnostic(),
2131                    },
2132                    "retry-safe",
2133                    "transaction group 7",
2134                ),
2135                (
2136                    MigrationExecutionOutcome::RequiresExplicitRecovery {
2137                        migration_id: MigrationId::new("example", "0001_initial")
2138                            .expect("migration id"),
2139                        position: MigrationExecutionPosition::ManifestCheckpoint,
2140                        diagnostic: diagnostic(),
2141                    },
2142                    "explicit recovery required",
2143                    "manifest checkpoint",
2144                ),
2145            ] {
2146                let rendered = sanitize_migration_execution_outcome(context, outcome);
2147                for secret in SECRETS {
2148                    assert!(!rendered.contains(secret), "{secret}: {rendered}");
2149                }
2150                for expected in [
2151                    context,
2152                    expected_state,
2153                    "example/0001_initial",
2154                    expected_position,
2155                    "migration_provider_test_failed",
2156                    "migration provider operation failed",
2157                ] {
2158                    assert!(rendered.contains(expected), "{expected}: {rendered}");
2159                }
2160            }
2161        }
2162    }
2163}
2164
2165#[cfg(all(test, unix))]
2166mod output_authority_tests {
2167    use super::*;
2168    use std::os::unix::fs::symlink;
2169
2170    #[test]
2171    fn retained_output_authority_survives_component_swap_without_redirecting() {
2172        let workspace = tempfile::tempdir().expect("workspace directory");
2173        let outside = tempfile::tempdir().expect("outside directory");
2174        fs::create_dir_all(workspace.path().join("generated/python")).expect("output directory");
2175        let authority = WorkspaceDirectoryAuthority::open(
2176            WorkspaceRoot::new(fs::canonicalize(workspace.path()).expect("canonical workspace"))
2177                .expect("workspace root"),
2178        )
2179        .expect("workspace authority");
2180        let root = authority.output_root().expect("output authority");
2181        let output = root
2182            .open_beneath(Path::new("generated/python"))
2183            .expect("output authority");
2184
2185        let held = workspace.path().join("generated/python-held");
2186        fs::rename(workspace.path().join("generated/python"), &held)
2187            .expect("move retained output directory");
2188        symlink(outside.path(), workspace.path().join("generated/python"))
2189            .expect("redirect configured output path");
2190
2191        output
2192            .write_atomic("_models.py".as_ref(), b"retained authority")
2193            .expect("publication remains handle-relative");
2194        assert_eq!(
2195            fs::read(held.join("_models.py")).expect("retained output reads"),
2196            b"retained authority"
2197        );
2198        assert!(
2199            !outside.path().join("_models.py").exists(),
2200            "component replacement redirected output outside the workspace"
2201        );
2202    }
2203
2204    #[test]
2205    fn retained_output_root_survives_root_entry_swap_without_redirecting() {
2206        let workspace = tempfile::tempdir().expect("workspace directory");
2207        let outside = tempfile::tempdir().expect("outside directory");
2208        fs::create_dir_all(workspace.path().join("generated/python")).expect("output directory");
2209        let authority = WorkspaceDirectoryAuthority::open(
2210            WorkspaceRoot::new(fs::canonicalize(workspace.path()).expect("canonical workspace"))
2211                .expect("workspace root"),
2212        )
2213        .expect("workspace authority");
2214        let root = authority.output_root().expect("output authority");
2215        let held = workspace
2216            .path()
2217            .parent()
2218            .expect("temporary parent")
2219            .join(format!(
2220                "{}-retained",
2221                workspace
2222                    .path()
2223                    .file_name()
2224                    .expect("temporary name")
2225                    .to_string_lossy()
2226            ));
2227        fs::rename(workspace.path(), &held).expect("workspace root moves after validation");
2228        symlink(outside.path(), workspace.path()).expect("workspace name redirects outside");
2229
2230        let output = root
2231            .open_beneath(Path::new("generated/python"))
2232            .expect("output opens through retained root");
2233        output
2234            .write_atomic("_models.py".as_ref(), b"retained root authority")
2235            .expect("publication remains rooted in the retained handle");
2236        assert_eq!(
2237            fs::read(held.join("generated/python/_models.py")).expect("retained output reads"),
2238            b"retained root authority"
2239        );
2240        assert!(
2241            !outside.path().join("generated/python/_models.py").exists(),
2242            "root replacement redirected output outside the workspace"
2243        );
2244
2245        fs::remove_file(workspace.path()).expect("replacement symlink removes");
2246        fs::rename(&held, workspace.path()).expect("workspace restores for cleanup");
2247    }
2248}
2249
2250fn bind_approvals(
2251    graph: &type_bridge_schema_migration::MigrationHistoryGraph,
2252    approvals: &[String],
2253) -> Result<Vec<type_bridge_schema_migration::MigrationApplyApproval>, String> {
2254    if approvals.is_empty() {
2255        return Ok(Vec::new());
2256    }
2257    approvals
2258        .iter()
2259        .map(|compound| {
2260            let (app_label, name) = compound
2261                .split_once('/')
2262                .ok_or_else(|| format!("approval {compound:?} must be app-label/name"))?;
2263            let id = type_bridge_contract::migration::MigrationId::from_components(
2264                type_bridge_contract::migration::MigrationAppLabel::new(app_label.to_owned())
2265                    .map_err(display)?,
2266                type_bridge_contract::migration::MigrationName::new(name.to_owned())
2267                    .map_err(display)?,
2268            );
2269            let manifest = graph.manifest(&id).ok_or_else(|| {
2270                format!("approval target {compound:?} is not in the committed history")
2271            })?;
2272            type_bridge_schema_migration::MigrationApplyApproval::for_manifest(manifest)
2273                .map_err(display)
2274        })
2275        .collect()
2276}
2277
2278fn parse_migration_ids(
2279    graph: &type_bridge_schema_migration::MigrationHistoryGraph,
2280    values: &[String],
2281    kind: &str,
2282) -> Result<BTreeSet<type_bridge_contract::migration::MigrationId>, String> {
2283    let mut ids = BTreeSet::new();
2284    for compound in values {
2285        let (app_label, name) = compound
2286            .split_once('/')
2287            .ok_or_else(|| format!("{kind} {compound:?} must be app-label/name"))?;
2288        let id = type_bridge_contract::migration::MigrationId::from_components(
2289            type_bridge_contract::migration::MigrationAppLabel::new(app_label.to_owned())
2290                .map_err(display)?,
2291            type_bridge_contract::migration::MigrationName::new(name.to_owned())
2292                .map_err(display)?,
2293        );
2294        if graph.manifest(&id).is_none() {
2295            return Err(format!(
2296                "{kind} target {compound:?} is not in the committed history"
2297            ));
2298        }
2299        if !ids.insert(id) {
2300            return Err(format!("{kind} target {compound:?} is duplicated"));
2301        }
2302    }
2303    Ok(ids)
2304}
2305
2306fn bind_rollback_approvals(
2307    graph: &type_bridge_schema_migration::MigrationHistoryGraph,
2308    approvals: &[String],
2309) -> Result<Vec<type_bridge_schema_migration::MigrationApplyApproval>, String> {
2310    let ids = parse_migration_ids(graph, approvals, "rollback approval")?;
2311    let mut bound = Vec::new();
2312    for id in ids {
2313        let manifest = graph
2314            .manifest(&id)
2315            .ok_or_else(|| "internal rollback approval target disappeared".to_owned())?;
2316        for safety in [
2317            type_bridge_schema::SafetyClass::FormalOnly,
2318            type_bridge_schema::SafetyClass::SchemaMetadata,
2319            type_bridge_schema::SafetyClass::Additive,
2320            type_bridge_schema::SafetyClass::Conditional,
2321            type_bridge_schema::SafetyClass::Destructive,
2322            type_bridge_schema::SafetyClass::Opaque,
2323        ] {
2324            bound.push(
2325                type_bridge_schema_migration::MigrationApplyApproval::for_rollback(
2326                    manifest, safety,
2327                )
2328                .map_err(display)?,
2329            );
2330        }
2331    }
2332    Ok(bound)
2333}
2334
2335#[cfg(test)]
2336mod transport_option_tests {
2337    use super::*;
2338
2339    fn environment(policy: WorkspaceTransportPolicy) -> WorkspaceEnvironment {
2340        WorkspaceEnvironment::new(
2341            "typedb.example:1729",
2342            "example",
2343            SecretReference::environment("TYPEBRIDGE_TEST_USERNAME").expect("username reference"),
2344            SecretReference::environment("TYPEBRIDGE_TEST_PASSWORD").expect("password reference"),
2345        )
2346        .expect("environment")
2347        .with_transport_policy(policy)
2348    }
2349
2350    fn custom_root_workspace(root_bytes: &[u8]) -> (tempfile::TempDir, TypeBridgeWorkspace) {
2351        let directory = tempfile::tempdir().expect("workspace directory");
2352        fs::create_dir_all(directory.path().join("schema/fragments")).expect("schema directory");
2353        fs::create_dir_all(directory.path().join("migrations/v2")).expect("migration directory");
2354        fs::create_dir_all(directory.path().join("certs")).expect("certificate directory");
2355        fs::write(
2356            directory.path().join("schema/schema.yaml"),
2357            "format: typebridge.schema-set/v1\nsources: [fragments/*.yaml]\n",
2358        )
2359        .expect("schema set writes");
2360        fs::write(
2361            directory.path().join("schema/fragments/model.yaml"),
2362            "format: typebridge.schema/v2\nentities: {person: {}}\n",
2363        )
2364        .expect("schema writes");
2365        fs::write(directory.path().join("certs/root.pem"), root_bytes).expect("certificate writes");
2366        let manifest = directory.path().join("typebridge.yaml");
2367        fs::write(
2368            &manifest,
2369            "format: typebridge.workspace/v1\n\
2370             schema:\n  root: schema/schema.yaml\n  ownership: exclusive\n  managed-scope: tls-test\n\
2371             compatibility:\n  semantic-profile: typedb-3.12.1/v1\n\
2372             migrations:\n  directory: migrations/v2\n  app-label: tlstest\n\
2373             environments:\n  dev:\n    database: tls_test\n    uri: never-contact.invalid:1729\n    \
2374             tls: 'true'\n    tls-root-ca: certs/root.pem\n    credential:\n      username: \
2375             env:TYPEBRIDGE_TEST_USERNAME\n      password: env:TYPEBRIDGE_TEST_PASSWORD\n",
2376        )
2377        .expect("manifest writes");
2378        let workspace = load_workspace(&manifest).expect("custom-root workspace loads");
2379        (directory, workspace)
2380    }
2381
2382    #[test]
2383    fn workspace_transport_policy_maps_without_changing_plaintext_defaults() {
2384        let defaults = type_bridge_orm::SecureConnectOptions::default();
2385        let disabled = secure_connect_options(&environment(WorkspaceTransportPolicy::Disabled));
2386        assert_eq!(disabled.tls_mode, type_bridge_orm::TlsMode::Disabled);
2387        assert_eq!(disabled.http_port, defaults.http_port);
2388        assert_eq!(disabled.server_version, defaults.server_version);
2389
2390        let native = secure_connect_options(
2391            &environment(WorkspaceTransportPolicy::NativeRoots).with_http_port(9443),
2392        );
2393        assert_eq!(native.tls_mode, type_bridge_orm::TlsMode::NativeRoots);
2394        assert_eq!(native.http_port, 9443);
2395        assert_eq!(native.server_version, defaults.server_version);
2396    }
2397
2398    #[test]
2399    fn custom_root_mapping_preserves_the_validated_canonical_path() {
2400        let directory = tempfile::tempdir().expect("workspace directory");
2401        let canonical = fs::canonicalize(directory.path()).expect("canonical workspace");
2402        fs::create_dir_all(canonical.join("certs")).expect("certificate directory");
2403        fs::write(
2404            canonical.join("certs/root.pem"),
2405            b"not parsed at workspace boundary\n",
2406        )
2407        .expect("certificate writes");
2408        let root = WorkspaceRoot::new(canonical.clone()).expect("workspace root");
2409        let root_ca = type_bridge_workspace::WorkspaceRootCa::new(
2410            &root,
2411            "certs/root.pem",
2412            &SystemSchemaSourceService,
2413        )
2414        .expect("confined root CA");
2415
2416        let options = secure_connect_options(
2417            &environment(WorkspaceTransportPolicy::CustomRootCa(root_ca)).with_http_port(8443),
2418        );
2419        assert_eq!(
2420            options.tls_mode,
2421            type_bridge_orm::TlsMode::CustomRootCa(canonical.join("certs/root.pem"))
2422        );
2423        assert_eq!(options.http_port, 8443);
2424    }
2425
2426    #[test]
2427    fn malformed_custom_root_fails_transport_preflight_before_credentials_are_needed() {
2428        let (_directory, workspace) = custom_root_workspace(b"definitely not a certificate\n");
2429
2430        let error = preflight_secure_connect_options(&workspace, "dev")
2431            .expect_err("PEM parsing must happen before credential resolution");
2432        assert!(error.contains("tls_custom_root_ca_invalid_pem"), "{error}");
2433        assert!(!error.contains("TYPEBRIDGE_TEST_USERNAME"), "{error}");
2434        assert!(!error.contains("TYPEBRIDGE_TEST_PASSWORD"), "{error}");
2435    }
2436
2437    #[cfg(unix)]
2438    #[test]
2439    fn workspace_root_swap_to_outside_symlink_is_rejected_at_transport_preflight() {
2440        use std::os::unix::fs::symlink;
2441
2442        let (directory, workspace) = custom_root_workspace(b"initial regular root\n");
2443        let outside = tempfile::tempdir().expect("outside directory");
2444        let configured = directory.path().join("certs/root.pem");
2445
2446        let outside_root = outside.path().join("malicious.pem");
2447        fs::write(
2448            &outside_root,
2449            include_bytes!("../../core/tests/fixtures/valid-root.pem"),
2450        )
2451        .expect("write outside replacement root");
2452        fs::remove_file(&configured).expect("remove validated confined root");
2453        symlink(&outside_root, &configured).expect("install outside symlink after validation");
2454
2455        let error = preflight_secure_connect_options(&workspace, "dev")
2456            .expect_err("retained workspace paths must never follow a replacement symlink");
2457        assert!(error.contains("tls_custom_root_ca_unreadable"), "{error}");
2458        assert!(!error.contains("tls_custom_root_ca_invalid_pem"), "{error}");
2459    }
2460
2461    #[cfg(unix)]
2462    #[test]
2463    fn real_directory_root_replacement_cannot_substitute_custom_trust() {
2464        let (directory, workspace) =
2465            custom_root_workspace(include_bytes!("../../core/tests/fixtures/valid-root.pem"));
2466        let configured_root = directory.path().to_path_buf();
2467        let held_root = configured_root.with_extension("retained-custom-root-ca");
2468        fs::rename(&configured_root, &held_root).expect("move retained workspace root");
2469        fs::create_dir_all(configured_root.join("certs")).expect("replacement root creates");
2470        fs::write(
2471            configured_root.join("certs/root.pem"),
2472            b"attacker-controlled replacement is not a certificate\n",
2473        )
2474        .expect("replacement root writes");
2475
2476        let preflight = preflight_secure_connect_options(&workspace, "dev");
2477
2478        fs::remove_dir_all(&configured_root).expect("replacement root removes");
2479        fs::rename(&held_root, &configured_root).expect("retained root restores");
2480        preflight.expect("transport must use the CA under the retained original root");
2481    }
2482}
2483
2484#[cfg(test)]
2485mod rollback_cli_contract_tests {
2486    use super::*;
2487
2488    #[test]
2489    fn rollback_grammar_requires_environment_and_explicit_removal() {
2490        assert!(Cli::try_parse_from(["type-bridge", "migration", "rollback"]).is_err());
2491        assert!(
2492            Cli::try_parse_from([
2493                "type-bridge",
2494                "migration",
2495                "rollback",
2496                "--environment",
2497                "live",
2498            ])
2499            .is_err()
2500        );
2501        let cli = Cli::try_parse_from([
2502            "type-bridge",
2503            "migration",
2504            "rollback",
2505            "--environment",
2506            "live",
2507            "--remove",
2508            "example/0002_contract",
2509            "--remove",
2510            "example/0001_expand",
2511            "--approve",
2512            "example/0002_contract",
2513            "--output",
2514            "json",
2515        ])
2516        .expect("explicit preview grammar parses");
2517        let Command::Migration {
2518            command:
2519                MigrationCommand::Rollback {
2520                    environment,
2521                    removals,
2522                    approvals,
2523                    execute,
2524                    output,
2525                },
2526        } = cli.command
2527        else {
2528            panic!("rollback command parsed into another command")
2529        };
2530        assert_eq!(environment, "live");
2531        assert_eq!(removals.len(), 2);
2532        assert_eq!(approvals, ["example/0002_contract"]);
2533        assert!(!execute, "preview is the non-mutating default");
2534        assert_eq!(output, RollbackOutput::Json);
2535    }
2536
2537    #[test]
2538    fn rollback_execution_requires_an_explicit_flag() {
2539        let cli = Cli::try_parse_from([
2540            "type-bridge",
2541            "migration",
2542            "rollback",
2543            "--environment",
2544            "live",
2545            "--remove",
2546            "example/0002_contract",
2547            "--execute",
2548        ])
2549        .expect("explicit execution grammar parses");
2550        assert!(matches!(
2551            cli.command,
2552            Command::Migration {
2553                command: MigrationCommand::Rollback { execute: true, .. }
2554            }
2555        ));
2556    }
2557}
2558
2559#[cfg(test)]
2560mod migration_command_tests {
2561    use super::*;
2562
2563    fn write_workspace_manifest(root: &Path, semantic_profile: &str) -> PathBuf {
2564        fs::create_dir_all(root.join("schema/fragments")).expect("schema directory");
2565        fs::write(
2566            root.join("schema/schema.yaml"),
2567            "format: typebridge.schema-set/v1\nsources: [fragments/*.yaml]\n",
2568        )
2569        .expect("schema set writes");
2570        fs::write(
2571            root.join("schema/fragments/model.yaml"),
2572            "format: typebridge.schema/v2\nentities: {person: {}}\n",
2573        )
2574        .expect("schema writes");
2575        let manifest = root.join("typebridge.yaml");
2576        fs::write(
2577            &manifest,
2578            format!(
2579                "format: typebridge.workspace/v1\n\
2580                 schema:\n  root: schema/schema.yaml\n  ownership: exclusive\n  managed-scope: command-test\n\
2581                 compatibility:\n  semantic-profile: {semantic_profile}\n\
2582                 migrations:\n  directory: migrations/v2\n  app-label: commandtest\n\
2583                 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"
2584            ),
2585        )
2586        .expect("manifest writes");
2587        manifest
2588    }
2589
2590    #[test]
2591    fn migration_make_creates_its_missing_authoring_directory() {
2592        let directory = tempfile::tempdir().expect("workspace directory");
2593        let manifest = write_workspace_manifest(directory.path(), "typedb-3.11.5/v1");
2594        let migration_directory = directory.path().join("migrations/v2");
2595        assert!(!migration_directory.exists());
2596
2597        run(&Cli {
2598            manifest,
2599            command: Command::Migration {
2600                command: MigrationCommand::Make {
2601                    name: "initial".to_owned(),
2602                    backfill_intent: None,
2603                },
2604            },
2605        })
2606        .expect("migration make creates and publishes into its authoring directory");
2607
2608        assert!(
2609            migration_directory
2610                .join("0001_initial.tbmigration.json")
2611                .is_file()
2612        );
2613        assert!(migration_directory.join("0001_initial.typeql").is_file());
2614    }
2615
2616    #[test]
2617    fn migration_make_accepts_a_confined_backfill_intent() {
2618        let directory = tempfile::tempdir().expect("workspace directory");
2619        let manifest = write_workspace_manifest(directory.path(), "typedb-3.11.5/v1");
2620        fs::write(
2621            directory.path().join("schema/fragments/model.yaml"),
2622            "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",
2623        )
2624        .expect("backfill schema writes");
2625        let initial = Cli {
2626            manifest: manifest.clone(),
2627            command: Command::Migration {
2628                command: MigrationCommand::Make {
2629                    name: "initial".to_owned(),
2630                    backfill_intent: None,
2631                },
2632            },
2633        };
2634        run(&initial).expect("initial migration");
2635        fs::write(
2636            directory.path().join("migrations/v2/copy-name.backfill.yaml"),
2637            "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",
2638        )
2639        .expect("backfill intent writes");
2640
2641        run(&Cli {
2642            manifest,
2643            command: Command::Migration {
2644                command: MigrationCommand::Make {
2645                    name: "copy-name".to_owned(),
2646                    backfill_intent: Some(PathBuf::from("copy-name.backfill.yaml")),
2647                },
2648            },
2649        })
2650        .expect("backfill migration authors");
2651
2652        assert!(
2653            directory
2654                .path()
2655                .join("migrations/v2/0002_copy-name.tbmigration.json")
2656                .is_file()
2657        );
2658    }
2659
2660    #[test]
2661    fn unsupported_execution_profile_rejects_before_credentials_or_filesystem_mutation() {
2662        let directory = tempfile::tempdir().expect("workspace directory");
2663        let manifest = write_workspace_manifest(directory.path(), "typedb-3.11.5/v1");
2664        let workspace = load_workspace(&manifest).expect("workspace loads");
2665
2666        for action in [
2667            ConnectedAction::Apply {
2668                approvals: Vec::new(),
2669            },
2670            ConnectedAction::Verify,
2671            ConnectedAction::Adopt {
2672                archive_directory: directory.path().join("missing-archive"),
2673                name: "0000_archive_frontier".to_owned(),
2674            },
2675        ] {
2676            let error = run_connected(&workspace, "dev", action)
2677                .expect_err("every connected migration operation uses the exact profile");
2678
2679            assert!(
2680                error.contains("migration_typedb_semantic_profile_unsupported"),
2681                "{error}"
2682            );
2683            assert!(error.contains("typedb-3.11.5/v1"), "{error}");
2684            assert!(error.contains("typedb-3.12.1/v1"), "{error}");
2685            assert!(
2686                !error.contains("credential environment variable")
2687                    && !error.contains("cannot connect")
2688                    && !error.contains("cannot check database"),
2689                "profile gate ran after external setup: {error}"
2690            );
2691        }
2692        assert!(
2693            !directory.path().join("migrations/v2").exists(),
2694            "profile rejection must not create the migration directory"
2695        );
2696    }
2697}
2698
2699#[cfg(test)]
2700mod adoption_file_tests {
2701    use super::*;
2702    use sha2::{Digest as _, Sha256};
2703
2704    const LEGACY_SCHEMA: &str = "define\nentity person;\n";
2705
2706    fn adoption_workspace() -> (tempfile::TempDir, TypeBridgeWorkspace) {
2707        let directory = tempfile::tempdir().expect("workspace directory");
2708        fs::create_dir_all(directory.path().join("schema/fragments")).expect("schema directory");
2709        fs::write(
2710            directory.path().join("schema/schema.yaml"),
2711            "format: typebridge.schema-set/v1\nsources: [fragments/*.yaml]\n",
2712        )
2713        .expect("schema set writes");
2714        fs::write(
2715            directory.path().join("schema/fragments/model.yaml"),
2716            "format: typebridge.schema/v2\nentities: {person: {}}\n",
2717        )
2718        .expect("schema writes");
2719        let manifest = directory.path().join("typebridge.yaml");
2720        fs::write(
2721            &manifest,
2722            "format: typebridge.workspace/v1\n\
2723             schema:\n  root: schema/schema.yaml\n  ownership: exclusive\n  managed-scope: adoption-test\n\
2724             compatibility:\n  semantic-profile: typedb-3.12.1/v1\n\
2725             migrations:\n  directory: migrations/v2\n  app-label: smoke\n\
2726             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",
2727        )
2728        .expect("manifest writes");
2729        let workspace = load_workspace(&manifest).expect("workspace loads");
2730        (directory, workspace)
2731    }
2732
2733    fn write_legacy_fixture(root: &Path) -> PathBuf {
2734        let directory = root.join("migrations/legacy");
2735        fs::create_dir_all(&directory).expect("legacy directory");
2736        let name = "0001_initial";
2737        let python_source = "class Migration:\n    operations = []\n";
2738        let checksum = type_bridge_migration::migration_file_checksum(python_source);
2739        let source_sha256 = format!("{:x}", Sha256::digest(python_source.as_bytes()));
2740        let schema_hash = format!("{:x}", Sha256::digest(LEGACY_SCHEMA.as_bytes()));
2741        fs::write(directory.join(format!("{name}.py")), python_source)
2742            .expect("legacy source writes");
2743        let adoption = type_bridge_migration::LegacyAdoptionMetadata::new(
2744            "legacy",
2745            name,
2746            Vec::new(),
2747            checksum,
2748            source_sha256,
2749            type_bridge_migration::LegacySchemaEffect::Snapshot,
2750            type_bridge_migration::MigrationDependencySpec {
2751                app_label: "legacy".to_owned(),
2752                migration_name: name.to_owned(),
2753            },
2754            schema_hash.clone(),
2755        )
2756        .expect("legacy adoption metadata");
2757        fs::write(
2758            directory.join(format!("{name}.adoption.json")),
2759            serde_json::to_vec_pretty(&adoption).expect("metadata encodes"),
2760        )
2761        .expect("metadata writes");
2762        let snapshot = directory.join("snapshots/v0001");
2763        fs::create_dir_all(&snapshot).expect("snapshot directory");
2764        fs::write(snapshot.join("schema.tql"), LEGACY_SCHEMA).expect("snapshot schema writes");
2765        fs::write(
2766            snapshot.join("snapshot.json"),
2767            serde_json::to_vec_pretty(&serde_json::json!({
2768                "version": "v0001",
2769                "source_migration": name,
2770                "schema_hash": schema_hash,
2771                "file_hashes": {"schema.tql": schema_hash},
2772                "type_bridge_version": "1.5.11",
2773                "type_bridge_core_version": "1.5.11"
2774            }))
2775            .expect("snapshot manifest encodes"),
2776        )
2777        .expect("snapshot manifest writes");
2778        directory
2779    }
2780
2781    #[test]
2782    fn authority_publication_is_atomic_no_replace_and_resumable() {
2783        let directory = tempfile::tempdir().expect("directory");
2784        let authority =
2785            type_bridge_schema_migration::MigrationDirectory::open_ambient(directory.path())
2786                .expect("directory authority");
2787        let name = "adopted-genesis.typeql";
2788        let path = directory.path().join("adopted-genesis.typeql");
2789        assert!(
2790            publish_authority(&authority, name, b"define\nentity person;\n")
2791                .expect("first publish")
2792        );
2793        assert!(
2794            !publish_authority(&authority, name, b"define\nentity person;\n")
2795                .expect("identical recovery")
2796        );
2797        assert!(publish_authority(&authority, name, b"define\nentity company;\n").is_err());
2798        assert_eq!(
2799            fs::read(&path).expect("authority reads"),
2800            b"define\nentity person;\n"
2801        );
2802        assert!(
2803            fs::read_dir(directory.path())
2804                .expect("directory reads")
2805                .all(|entry| !entry
2806                    .expect("entry")
2807                    .file_name()
2808                    .to_string_lossy()
2809                    .ends_with(".tmp"))
2810        );
2811    }
2812
2813    #[cfg(unix)]
2814    #[test]
2815    fn authority_publication_rejects_final_symlink() {
2816        use std::os::unix::fs::symlink;
2817
2818        let directory = tempfile::tempdir().expect("directory");
2819        let outside = directory.path().join("outside");
2820        fs::write(&outside, b"untouched").expect("outside writes");
2821        let path = directory.path().join("adopted-genesis.typeql");
2822        symlink(&outside, &path).expect("symlink");
2823        let authority =
2824            type_bridge_schema_migration::MigrationDirectory::open_ambient(directory.path())
2825                .expect("directory authority");
2826
2827        assert!(publish_authority(&authority, "adopted-genesis.typeql", b"replacement").is_err());
2828        assert_eq!(fs::read(&outside).expect("outside reads"), b"untouched");
2829    }
2830
2831    #[test]
2832    fn invalid_adoption_name_creates_no_canonical_directory() {
2833        let (directory, workspace) = adoption_workspace();
2834        let missing_archive = directory.path().join("missing-legacy");
2835        let error = run_connected(
2836            &workspace,
2837            "dev",
2838            ConnectedAction::Adopt {
2839                archive_directory: missing_archive,
2840                name: String::new(),
2841            },
2842        )
2843        .expect_err("invalid name fails before history or network access");
2844        assert!(error.contains("migration"), "{error}");
2845        assert!(
2846            !directory.path().join("migrations/v2").exists(),
2847            "bad-name validation must not create canonical filesystem state"
2848        );
2849    }
2850
2851    #[test]
2852    fn invalid_apply_approvals_fail_before_credentials_or_network() {
2853        let (directory, workspace) = adoption_workspace();
2854        fs::create_dir_all(directory.path().join("migrations/v2")).expect("canonical directory");
2855
2856        for (approval, expected) in [
2857            ("not-a-compound-id", "must be app-label/name"),
2858            ("smoke/0001_missing", "is not in the committed history"),
2859        ] {
2860            let error = run_connected(
2861                &workspace,
2862                "dev",
2863                ConnectedAction::Apply {
2864                    approvals: vec![approval.to_owned()],
2865                },
2866            )
2867            .expect_err("invalid approval is rejected by local authority");
2868            assert!(error.contains(expected), "{approval}: {error}");
2869            assert!(
2870                !error.contains("credential")
2871                    && !error.contains("connect")
2872                    && !error.contains("database"),
2873                "approval validation ran after external setup: {error}"
2874            );
2875        }
2876    }
2877
2878    #[test]
2879    fn adoption_retry_completes_either_exact_orphan_direction() {
2880        for orphan in ["genesis", "bridge"] {
2881            let (directory, workspace) = adoption_workspace();
2882            let legacy = write_legacy_fixture(directory.path());
2883            let prepared = prepare_archive_adoption(&workspace, &legacy, "0000_archive_frontier")
2884                .expect("adoption prepares");
2885            let migration_directory = workspace
2886                .ensure_migration_directory()
2887                .expect("canonical directory");
2888            match orphan {
2889                "genesis" => {
2890                    publish_authority(
2891                        migration_directory.directory(),
2892                        type_bridge_schema_compat::ADOPTED_GENESIS_FILE_NAME,
2893                        prepared.reconstructed.schema_typeql().as_bytes(),
2894                    )
2895                    .expect("genesis orphan publishes");
2896                }
2897                "bridge" => {
2898                    publish_authority(
2899                        migration_directory.directory(),
2900                        &prepared.bridge_name,
2901                        &prepared.bridge_bytes,
2902                    )
2903                    .expect("bridge orphan publishes");
2904                }
2905                _ => unreachable!(),
2906            }
2907
2908            publish_prepared_adoption(&workspace, &migration_directory, &prepared)
2909                .expect("adoption retry completes the exact orphan");
2910            workspace
2911                .discover_migrations_in(&migration_directory)
2912                .expect("completed adoption pair discovers");
2913            assert!(
2914                migration_directory
2915                    .display_path()
2916                    .join(type_bridge_schema_compat::ADOPTED_GENESIS_FILE_NAME)
2917                    .is_file()
2918            );
2919            assert!(
2920                migration_directory
2921                    .display_path()
2922                    .join(&prepared.bridge_name)
2923                    .is_file()
2924            );
2925        }
2926    }
2927
2928    #[test]
2929    fn legacy_history_race_after_bridge_rolls_back_new_publication() {
2930        let (directory, workspace) = adoption_workspace();
2931        let legacy = write_legacy_fixture(directory.path());
2932        let prepared = prepare_archive_adoption(&workspace, &legacy, "0000_archive_frontier")
2933            .expect("adoption prepares");
2934        let migration_directory = workspace
2935            .ensure_migration_directory()
2936            .expect("canonical directory");
2937        let legacy_source = legacy.join("0001_initial.py");
2938
2939        let error = publish_prepared_adoption_with_after_bridge(
2940            &workspace,
2941            &migration_directory,
2942            &prepared,
2943            || {
2944                fs::write(
2945                    &legacy_source,
2946                    "class Migration:\n    operations = ['changed']\n",
2947                )
2948                .expect("race mutation writes");
2949            },
2950        )
2951        .expect_err("legacy authority race aborts pair publication");
2952        assert!(error.contains("changed"), "{error}");
2953        assert!(
2954            !migration_directory
2955                .display_path()
2956                .join(&prepared.bridge_name)
2957                .exists(),
2958            "new bridge is rolled back"
2959        );
2960        assert!(
2961            !migration_directory
2962                .display_path()
2963                .join(type_bridge_schema_compat::ADOPTED_GENESIS_FILE_NAME)
2964                .exists(),
2965            "genesis is never published after the race"
2966        );
2967    }
2968}