secretenv 0.15.0

SecretEnv CLI — resolves aliases to secrets and runs commands with them injected
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
// Copyright (C) 2026 Mandeep Patel
// SPDX-License-Identifier: AGPL-3.0-only

//! v0.15 `secretenv registry migrate` library entry.
//!
//! This module is the load-bearing core of the migrate cycle. The CLI
//! layer (`crate::cli`) parses args, handles confirmation prompts, and
//! formats the report; everything between the source read and the
//! pointer flip lives here.
//!
//! # Three-step transaction (plus optional fourth)
//!
//! 1. **read** — `source.get(&plan.source_uri)`.
//! 2. **write** — `dest.write_secret(&plan.dest_uri, &value)`.
//! 3. **pointer flip** — re-serialize the registry doc with the alias
//!    pointing at the destination URI. This is the commit point.
//! 4. **source delete** (opt-in `--delete-source`, double-confirmed by
//!    the CLI layer even under `--yes`) — `source.delete_secret(&plan.source_uri)`.
//!
//! # Borrow-not-clone (SEC-INV-10)
//!
//! The secret value rides as `Secret<String>` end-to-end. It is read
//! once, passed by reference to `write_secret`, then dropped — which
//! zeroes the wrapped buffer per the `Secret::Drop` impl.
//!
//! # No auto-rollback by deletion (SEC-INV-09)
//!
//! A failed pointer-flip after a successful destination write does
//! NOT trigger an automatic delete on the destination. The operator
//! is told the value exists in both backends and given the manual
//! recovery commands; the destination delete is their explicit call.
//!
//! # Resolve-once invariant
//!
//! [`MigrationPlan`] is built once at command start and bound through
//! the entire flow. Subsequent phases consume `&MigrationPlan` by
//! reference; no phase re-resolves the alias or re-parses URIs.
//!
//! The canonical entry is [`migrate_with_plan`] — it takes a
//! pre-built plan and is what both the CLI and the future v0.16 MCP
//! `migrate_alias` tool will call directly. [`migrate`] is the
//! convenience wrapper that builds the plan then dispatches, used
//! only when the caller hasn't already done the plan-preview render
//! (e.g. unit tests). The CLI builds the plan once for the
//! confirmation-prompt render and reuses the same plan instance for
//! the actual migration — the `transaction_id` the operator sees in
//! the prompt is the same one that lands in the report and any
//! captured telemetry. Phase 7 audit (code-rev B1) flagged the prior
//! two-call shape as a TOCTOU + `transaction_id`-drift hazard.
//!
//! The pointer-flip phase ([`migrate_registry_flip`]) deliberately
//! re-reads the registry document from the registry backend. This is
//! NOT a violation of resolve-once — the invariant binds the *alias
//! resolution and URI parsing*, not the registry-document snapshot.
//! Reading the latest doc immediately before mutation minimizes the
//! read-modify-write window (which is still racy in v0.15 — see
//! SEC-INV-21 in [[v0.14-plus-security-invariants]] for the v0.17
//! `cas_set` evolution plan).
//!
//! # Telemetry seam
//!
//! Each discrete async phase opens a `tracing::info_span!()` so the
//! v0.17 `OTel` wiring can attach an exporter without restructuring.
//! The phase-level recorders on
//! [`secretenv_telemetry::SecretEnvSpan`] are no-ops in v0.15
//! (the structural fixture lives in v0.14); v0.17 fills them in.

use std::collections::BTreeMap;
use std::time::Instant;

use anyhow::{anyhow, bail, Context, Result};
use secretenv_core::{
    resolve_registry, AliasMap, Backend, BackendRegistry, BackendUri, Config, RegistryCache,
    RegistrySelection, Secret,
};
use secretenv_telemetry::span::{MigrateOutcome, MigratePhase, SecretEnvSpan};

/// Arguments accepted by [`migrate`]. Built by the CLI layer from
/// clap parsing.
#[derive(Debug, Clone)]
pub struct MigrateArgs {
    /// Registry alias to migrate.
    pub alias: String,
    /// Destination backend URI (instance + path).
    pub dest_uri: String,
    /// Override the source URI (used for recovery flows where the
    /// registry already points at the destination but the value is
    /// still in the old backend). When `None`, the source is the
    /// current registry pointer.
    pub source_uri: Option<String>,
    /// Registry selection — name or direct URI. Mirrors
    /// `secretenv run --registry` / `secretenv registry --registry`.
    pub registry: Option<String>,
    /// Plan-only: probe destination + source liveness, render the
    /// plan, exit without mutation.
    pub dry_run: bool,
    /// Opt-in cleanup: after successful migrate, delete source. The
    /// CLI layer also accepts a `--yes` flag for the top-level
    /// confirmation prompt; that flag is consumed by the CLI handler
    /// before [`migrate`] is called, so it does not appear here.
    pub delete_source: bool,
}

/// Resolved migration plan — built once at command start, bound
/// through every phase. No phase re-resolves the alias or re-parses
/// any URI.
#[derive(Debug, Clone)]
pub struct MigrationPlan {
    pub alias: String,
    pub source_uri: BackendUri,
    pub dest_uri: BackendUri,
    /// Registry source URI we'll re-write to flip the pointer.
    pub registry_source_uri: BackendUri,
    /// Stable per-invocation identifier. v0.15 uses nanoseconds since
    /// the UNIX epoch in hex form; v0.17 may upgrade to `UUIDv7`.
    pub transaction_id: String,
}

/// Recorded duration of every phase. Missing phases (e.g.
/// `source_delete_ms` when `--delete-source` was not set) are `None`.
#[derive(Debug, Default, Clone, Copy)]
#[allow(clippy::struct_field_names)]
pub struct PhaseDurations {
    pub probe_ms: u64,
    pub read_ms: u64,
    pub write_ms: u64,
    pub pointer_flip_ms: u64,
    pub source_delete_ms: Option<u64>,
}

/// Final outcome — maps 1:1 to
/// [`secretenv_telemetry::span::MigrateOutcome`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MigrateReportOutcome {
    Success,
    /// Pointer flip failed after a successful destination write.
    /// The value exists in BOTH backends; recovery is the operator's
    /// call (SEC-INV-09).
    ///
    /// `migrate_with_plan` currently surfaces this via `Err` (the
    /// downcast lookup is [`PointerFlipFailed`]) so the CLI can
    /// render the manual-recovery block to stderr without embedding
    /// URI bodies in the bubbled error message (SEC-INV-22). The
    /// variant is retained as the wire-format anchor so a future MCP
    /// boundary or a `Result<MigrateReport, MigrateReport>` API can
    /// switch to Ok-with-partial-failure without an enum-variant
    /// break. Phase 7 audit (architect M2): the
    /// `report_outcome_json_round_trip` test exercises the variant
    /// via direct construction so the JSON wire-format stays locked.
    #[allow(dead_code)] // wire-format anchor; constructed in tests + future v0.16 MCP
    #[doc(hidden)]
    PartialFailurePointerFlip,
    /// Migration succeeded but the source-delete leg (opt-in) failed.
    /// Migration is complete; cleanup is the operator's call.
    SourceDeleteFailedPostCommit,
    /// `--dry-run`; no read, write, or commit attempted.
    DryRun,
}

impl MigrateReportOutcome {
    const fn as_telemetry(self) -> MigrateOutcome {
        match self {
            Self::Success => MigrateOutcome::Ok,
            // Phase 7 audit (code-rev S8): distinct telemetry outcome
            // — migration committed but post-commit source delete
            // failed; operators querying OTel can see this without
            // scraping logs.
            Self::SourceDeleteFailedPostCommit => MigrateOutcome::OkWithCleanupFailure,
            Self::PartialFailurePointerFlip => MigrateOutcome::PartialFailure,
            Self::DryRun => MigrateOutcome::DryRun,
        }
    }
}

/// Returned by [`migrate`]. Stable surface for the CLI's text-mode
/// rendering + `--json` formatter; v0.16 MCP `migrate_alias` tool
/// re-exports this verbatim.
#[derive(Debug, Clone)]
pub struct MigrateReport {
    pub alias: String,
    pub source_backend_type: String,
    pub dest_backend_type: String,
    pub outcome: MigrateReportOutcome,
    pub phase_durations: PhaseDurations,
    pub delete_source: bool,
    /// Copy-paste cleanup command when `--delete-source` was not set
    /// (or was set but failed post-commit). `None` when the source
    /// was successfully deleted.
    pub delete_hint: Option<String>,
    pub transaction_id: String,
    /// Probe diagnostics surface raised in dry-run mode (and recorded
    /// regardless). Each entry is `(backend_instance, "ok" | "error: <msg>")`.
    pub probe_results: Vec<(String, String)>,
}

/// Build the [`MigrationPlan`] from CLI args + active registry state.
///
/// # Errors
/// - Alias not in the resolved registry cascade (and `--from` not set).
/// - Destination URI doesn't parse, or references a backend instance
///   not in `config.toml`.
/// - Source URI (whether inferred or `--from`-provided) references an
///   unconfigured backend.
pub async fn build_migration_plan(
    args: &MigrateArgs,
    config: &Config,
    backends: &BackendRegistry,
) -> Result<MigrationPlan> {
    let dest_uri = BackendUri::parse(&args.dest_uri)
        .with_context(|| format!("destination '{}' is not a valid URI", args.dest_uri))?;
    if dest_uri.is_alias() {
        bail!("destination must be a direct backend URI, not a secretenv:// alias");
    }
    if backends.get(&dest_uri.scheme).is_none() {
        bail!(
            "destination '{}' references backend instance '{}' which is not configured",
            args.dest_uri,
            dest_uri.scheme
        );
    }

    // Resolve the registry cascade to either (a) find the alias's
    // current pointer or (b) pick the primary source for the
    // pointer-flip write.
    let selection = registry_selection(args.registry.as_deref(), config)?;
    let mut cache = RegistryCache::new();
    let aliases = resolve_registry(config, &selection, backends, &mut cache).await?;

    let source_uri = if let Some(explicit) = &args.source_uri {
        BackendUri::parse(explicit)
            .with_context(|| format!("--from '{explicit}' is not a valid URI"))?
    } else {
        let (target, _src) = aliases.get(&args.alias).ok_or_else(|| {
            anyhow!(
                "alias '{}' not found in registry cascade [{}]",
                args.alias,
                format_sources(&aliases)
            )
        })?;
        target.clone()
    };
    if source_uri.is_alias() {
        bail!(
            "alias '{}' resolved to another alias ('{}') — migrate operates on backend URIs only",
            args.alias,
            source_uri.raw
        );
    }
    if backends.get(&source_uri.scheme).is_none() {
        bail!(
            "source '{}' references backend instance '{}' which is not configured",
            source_uri.raw,
            source_uri.scheme
        );
    }

    let registry_source_uri = aliases.primary_source().clone();

    Ok(MigrationPlan {
        alias: args.alias.clone(),
        source_uri,
        dest_uri,
        registry_source_uri,
        transaction_id: new_transaction_id(),
    })
}

/// Errors raised by `migrate_with_plan` that the partial-failure
/// stderr renderer needs as structured context. The CLI dispatcher
/// downcasts the returned `anyhow::Error` against this type to
/// decide whether to print the manual-recovery block to stderr
/// (vs. bubble the chain unmodified).
///
/// SEC-INV-20 / Phase 7 audit (security M2) — the recovery block
/// carries the destination URI body, which must stay on the
/// operator's terminal and not leak into a captured stderr→log
/// stream as the `anyhow::Error` Display.
#[derive(Debug)]
pub struct PointerFlipFailed {
    pub alias: String,
    pub dest_uri_raw: String,
    pub dest_delete_hint: String,
}

impl std::fmt::Display for PointerFlipFailed {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // Compact form for log capture — recovery details go through
        // the dedicated terminal-only renderer in the CLI dispatcher.
        write!(
            f,
            "pointer flip failed for alias '{}' after destination write succeeded; \
             value exists in both backends — operator action required (see stderr).",
            self.alias
        )
    }
}

impl std::error::Error for PointerFlipFailed {}

/// Convenience entry: build the [`MigrationPlan`] then dispatch to
/// [`migrate_with_plan`]. Used when the caller has no need to render
/// a confirmation preview against the resolved plan.
///
/// The CLI dispatcher does NOT use this entry — it builds the plan
/// itself for the top-level confirmation render and reuses the same
/// plan via [`migrate_with_plan`] (Phase 7 audit fix — code-rev B1
/// — eliminates the TOCTOU + `transaction_id`-drift between preview
/// and execution). v0.16 MCP `migrate_alias` and integration tests
/// are the expected consumers of this entry; `dead_code` allow
/// retained until v0.16 wires it up.
///
/// # Errors
/// See [`migrate_with_plan`].
#[allow(dead_code)]
pub async fn migrate<F>(
    args: MigrateArgs,
    config: &Config,
    backends: &BackendRegistry,
    post_commit_source_delete_consent: F,
) -> Result<MigrateReport>
where
    F: FnOnce(&MigrationPlan) -> bool,
{
    let plan = build_migration_plan(&args, config, backends).await?;
    migrate_with_plan(plan, &args, backends, post_commit_source_delete_consent).await
}

/// Drive the migration end-to-end against a pre-built plan. The CLI
/// layer is responsible for the top-level confirmation prompt BEFORE
/// calling this function. The `--delete-source` extra confirmation
/// fires AFTER the pointer-flip commit succeeds — per SEC-INV-08 it
/// must run even when `--yes` is set globally, and the operator must
/// have seen the commit succeed before deciding.
///
/// This is the canonical entry point. v0.16 MCP `migrate_alias` will
/// call this directly with a precomputed plan + a closure that
/// returns `false` (no destructive default) or `true` (explicit
/// MCP-side opt-in already confirmed at the MCP boundary).
///
/// The closure `post_commit_source_delete_consent` runs synchronously
/// between phase 3 and phase 4; it can read stdin, query a prompt,
/// or return a precomputed bool. The state machine is:
///
/// - `args.delete_source == false`: closure NEVER called; delete leg
///   skipped; report's `delete_hint` is populated.
/// - `args.delete_source == true` AND closure returns `true`:
///   delete-source leg runs; report's `delete_hint` is `None` on
///   success, populated on failure (with `SourceDeleteFailedPostCommit`
///   outcome).
/// - `args.delete_source == true` AND closure returns `false`:
///   delete leg skipped; report's `delete_hint` is populated;
///   outcome stays `Success` (operator declined but migration
///   committed).
///
/// # Errors
/// - Destination probe failure (returned [`Err`] only on definitive
///   capability deny — see [`Backend::probe_write`] contract).
/// - Source read failure (pre-commit; nothing to recover).
/// - Destination write failure (pre-commit; nothing to recover).
/// - Pointer-flip failure (post-commit; value lives in BOTH backends;
///   the returned [`anyhow::Error`] downcasts to [`PointerFlipFailed`]
///   so the CLI can render the manual-recovery block to stderr
///   without embedding URI bodies in the bubbled error message —
///   Phase 7 audit fix, SEC-INV-20).
///
/// Source-delete failure is non-fatal — it produces a
/// `SourceDeleteFailedPostCommit` outcome plus a populated
/// `delete_hint` in the report (not an `Err`).
pub async fn migrate_with_plan<F>(
    plan: MigrationPlan,
    args: &MigrateArgs,
    backends: &BackendRegistry,
    post_commit_source_delete_consent: F,
) -> Result<MigrateReport>
where
    F: FnOnce(&MigrationPlan) -> bool,
{
    let (mut span, _guard) = SecretEnvSpan::start("secretenv.migrate");
    span.record_command("migrate")
        .record_alias_name(&plan.alias)
        .record_migrate_transaction_id(&plan.transaction_id);

    let source = backend_for(backends, &plan.source_uri)?;
    let dest = backend_for(backends, &plan.dest_uri)?;
    span.record_migrate_source_backend_type(source.backend_type())
        .record_migrate_dest_backend_type(dest.backend_type())
        .record_migrate_delete_source(args.delete_source);

    // ----- Probe phase -----
    let (probe_ms, probe_results) = probe_phase(&plan, source, dest).await?;
    if args.dry_run {
        span.record_migrate_outcome(MigrateOutcome::DryRun);
        return Ok(MigrateReport {
            alias: plan.alias.clone(),
            source_backend_type: source.backend_type().to_owned(),
            dest_backend_type: dest.backend_type().to_owned(),
            outcome: MigrateReportOutcome::DryRun,
            phase_durations: PhaseDurations { probe_ms, ..PhaseDurations::default() },
            delete_source: args.delete_source,
            delete_hint: Some(source.delete_hint(&plan.source_uri)),
            transaction_id: plan.transaction_id,
            probe_results,
        });
    }

    // ----- Read -----
    let (value, read_ms) = match migrate_read(&plan, source).await {
        Ok(v) => v,
        Err(e) => {
            span.record_migrate_outcome(MigrateOutcome::SourceReadFailed);
            return Err(e);
        }
    };

    // ----- Write -----
    let write_ms = match migrate_write(&plan, dest, &value).await {
        Ok(ms) => ms,
        Err(e) => {
            span.record_migrate_outcome(MigrateOutcome::DestWriteFailed);
            // Pre-commit: nothing to recover. value drops here → zeroized.
            return Err(e);
        }
    };

    // Phase 7 audit (code-rev S7): drop the secret value immediately
    // after the destination write returns Ok. The registry-flip
    // round-trip can take 100s of ms (cloud backends); holding the
    // Secret<String> across that window for no reason widens the
    // SEC-INV-10 in-memory lifetime.
    drop(value);

    // ----- Pointer flip (commit point) -----
    let flip_start = Instant::now();
    let flip_result = migrate_registry_flip(&plan, backends).await;
    // Phase 7 audit (code-rev S5): capture elapsed even on Err so the
    // report carries the duration for triage.
    let flip_ms = u64::try_from(flip_start.elapsed().as_millis()).unwrap_or(u64::MAX);

    if let Err(flip_err) = flip_result {
        span.record_migrate_phase(MigratePhase::PointerFlip)
            .record_migrate_outcome(MigrateOutcome::PartialFailure);
        // Phase 7 audit (security M2): bubble a structured
        // PointerFlipFailed error WITHOUT embedding dest_uri.raw or
        // delete_hint into its Display. The CLI dispatcher
        // downcasts and renders the manual-recovery block to stderr
        // (terminal-only per SEC-INV-22).
        return Err(flip_err.context(PointerFlipFailed {
            alias: plan.alias.clone(),
            dest_uri_raw: plan.dest_uri.raw.clone(),
            dest_delete_hint: dest.delete_hint(&plan.dest_uri),
        }));
    }

    // ----- Optional source-delete -----
    // Per SEC-INV-08: fire consent closure EVEN when --yes is set
    // globally, AND only AFTER the commit (steps 1-3) succeeded.
    let mut source_delete_ms = None;
    let mut outcome = MigrateReportOutcome::Success;
    let mut delete_hint = Some(source.delete_hint(&plan.source_uri));
    if args.delete_source && post_commit_source_delete_consent(&plan) {
        match migrate_source_delete(&plan, source).await {
            Ok(ms) => {
                source_delete_ms = Some(ms);
                delete_hint = None;
            }
            Err(_e) => {
                // Phase 7 audit (code-rev S8): distinct telemetry
                // outcome so OTel queries can see "migrated but
                // source cleanup failed" without scraping logs.
                outcome = MigrateReportOutcome::SourceDeleteFailedPostCommit;
            }
        }
    }

    span.record_migrate_outcome(outcome.as_telemetry());

    Ok(MigrateReport {
        alias: plan.alias.clone(),
        source_backend_type: source.backend_type().to_owned(),
        dest_backend_type: dest.backend_type().to_owned(),
        outcome,
        phase_durations: PhaseDurations {
            probe_ms,
            read_ms,
            write_ms,
            pointer_flip_ms: flip_ms,
            source_delete_ms,
        },
        delete_source: args.delete_source,
        delete_hint,
        transaction_id: plan.transaction_id,
        probe_results,
    })
}

/// Source liveness + destination write-capability probe phase. Driven
/// by [`Backend::probe_write`] (default no-op; 4 backends override
/// per Phase 3).
///
/// Returns `(total_duration_ms, per-backend probe results)` where
/// each result is `(instance_name, "ok" | "error: <msg>")`. Errors
/// inside individual probes are propagated as overall failure ONLY
/// when the destination probe definitively says "deny" — see
/// `Backend::probe_write` contract.
async fn probe_phase(
    plan: &MigrationPlan,
    source: &dyn Backend,
    dest: &dyn Backend,
) -> Result<(u64, Vec<(String, String)>)> {
    let span = tracing::info_span!("secretenv.migrate.probe", alias = %plan.alias);
    let _enter = span.enter();
    let start = Instant::now();
    let mut results = Vec::with_capacity(2);

    // Source: a cheap-but-honest liveness signal is `check()`. We
    // don't probe-read the source value (that would materialize the
    // secret; SEC-INV-01). Phase 7 audit (code-rev B4): normalize to
    // the documented `"ok" | "error: <msg>"` shape rather than the
    // raw `{source_status:?}` Debug dump which leaked identity,
    // profile, region, and cli_version into the dry-run terminal +
    // JSON output.
    let source_status = source.check().await;
    let source_label = match source_status {
        secretenv_core::BackendStatus::Ok { .. } => "ok".to_owned(),
        secretenv_core::BackendStatus::NotAuthenticated { .. } => {
            "error: not authenticated".to_owned()
        }
        secretenv_core::BackendStatus::CliMissing { .. } => "error: cli missing".to_owned(),
        secretenv_core::BackendStatus::Error { .. } => "error: backend reported error".to_owned(),
    };
    results.push((source.instance_name().to_owned(), source_label));

    // Destination: the actual write-permission probe. Phase 7 audit
    // (architect M1): also report whether the backend has a real
    // probe vs. relies on the default `Ok(())` no-op, so the dry-run
    // can label "probed-and-ok" vs "no probe available".
    match dest.probe_write(&plan.dest_uri).await {
        Ok(()) => {
            let dest_label = if dest.has_probe_write() {
                "ok (probed)".to_owned()
            } else {
                "ok (no probe available for this backend)".to_owned()
            };
            results.push((dest.instance_name().to_owned(), dest_label));
        }
        Err(e) => {
            results.push((dest.instance_name().to_owned(), format!("error: {e}")));
            // Definitive deny — fail the migrate here, BEFORE any read.
            // SEC-INV-09: do not auto-rollback (nothing was written yet).
            return Err(e.context(format!(
                "destination probe rejected migrate {alias}: {dest_instance} cannot write at {dest_uri}",
                alias = plan.alias,
                dest_instance = dest.instance_name(),
                dest_uri = plan.dest_uri.raw,
            )));
        }
    }
    let dur = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
    Ok((dur, results))
}

/// Phase 1 — read the source value. Discrete async function wrapped
/// in its own `tracing::info_span!()` so the v0.17 `OTel` exporter can
/// attach without restructuring.
///
/// Phase 7 audit (code-rev S4): single explicit `info_span!` rather
/// than combining `#[tracing::instrument]` (which opens a span on
/// call) with a manual `info_span!` in the body (which would open a
/// duplicate nested child). The `OTel` contract uses the
/// `secretenv.migrate.*` names exactly.
async fn migrate_read(plan: &MigrationPlan, source: &dyn Backend) -> Result<(Secret<String>, u64)> {
    let span = tracing::info_span!("secretenv.migrate.read", alias = %plan.alias);
    let _enter = span.enter();
    let start = Instant::now();
    let value = source
        .get(&plan.source_uri)
        .await
        .with_context(|| format!("reading source value for alias '{}'", plan.alias))?;
    let dur = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
    Ok((value, dur))
}

/// Phase 2 — write to destination. Borrows the value as
/// `&Secret<String>` (SEC-INV-10).
async fn migrate_write(
    plan: &MigrationPlan,
    dest: &dyn Backend,
    value: &Secret<String>,
) -> Result<u64> {
    let span = tracing::info_span!("secretenv.migrate.write", alias = %plan.alias);
    let _enter = span.enter();
    let start = Instant::now();
    dest.write_secret(&plan.dest_uri, value)
        .await
        .with_context(|| format!("writing destination value for alias '{}'", plan.alias))?;
    let dur = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
    Ok(dur)
}

/// Phase 3 — pointer flip (commit point). Re-reads the registry doc,
/// inserts the new pointer, serializes, and writes it back. Mirrors
/// `registry_set` in `cli.rs` for the registry-write side.
///
/// Phase 7 audit (architect M4 / code-rev S6): the vestigial `args`
/// parameter was dropped — the elapsed measurement now lives in the
/// caller so it's captured even on `Err` (code-rev S5).
async fn migrate_registry_flip(plan: &MigrationPlan, backends: &BackendRegistry) -> Result<()> {
    let span = tracing::info_span!("secretenv.migrate.pointer_flip", alias = %plan.alias);
    let _enter = span.enter();

    let backend = backend_for(backends, &plan.registry_source_uri)?;
    let current = backend.list(&plan.registry_source_uri).await.with_context(|| {
        format!("reading registry document at '{}'", plan.registry_source_uri.raw)
    })?;
    let mut map: BTreeMap<String, String> = current.into_iter().collect();
    map.insert(plan.alias.clone(), plan.dest_uri.raw.clone());
    let serialized = secretenv_core::serialize_registry_doc(backend.registry_format(), &map)?;
    backend.set(&plan.registry_source_uri, &serialized).await.with_context(|| {
        format!("writing updated registry document to '{}'", plan.registry_source_uri.raw)
    })?;

    Ok(())
}

/// Phase 4 (opt-in) — delete source after a successful commit.
async fn migrate_source_delete(plan: &MigrationPlan, source: &dyn Backend) -> Result<u64> {
    let span = tracing::info_span!("secretenv.migrate.source_delete", alias = %plan.alias);
    let _enter = span.enter();
    let start = Instant::now();
    source
        .delete_secret(&plan.source_uri)
        .await
        .with_context(|| format!("deleting source value for alias '{}'", plan.alias))?;
    let dur = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
    Ok(dur)
}

// --- helpers ---------------------------------------------------------

fn backend_for<'a>(backends: &'a BackendRegistry, uri: &BackendUri) -> Result<&'a dyn Backend> {
    backends
        .get(&uri.scheme)
        .ok_or_else(|| anyhow!("no backend instance '{}' is configured", uri.scheme))
}

fn registry_selection(registry: Option<&str>, config: &Config) -> Result<RegistrySelection> {
    if let Some(value) = registry {
        if value.starts_with("secretenv://") || value.contains("://") {
            return BackendUri::parse(value)
                .map(RegistrySelection::Uri)
                .with_context(|| format!("--registry '{value}' is not a valid URI"));
        }
        return Ok(RegistrySelection::Name(value.to_owned()));
    }
    if let Ok(env) = std::env::var("SECRETENV_REGISTRY") {
        if !env.is_empty() {
            if env.contains("://") {
                return BackendUri::parse(&env)
                    .map(RegistrySelection::Uri)
                    .with_context(|| format!("SECRETENV_REGISTRY '{env}' is not a valid URI"));
            }
            return Ok(RegistrySelection::Name(env));
        }
    }
    if config.registries.contains_key("default") {
        Ok(RegistrySelection::Name("default".to_owned()))
    } else {
        bail!(
            "no registry selected: pass --registry, set SECRETENV_REGISTRY, \
             or define [registries.default] in config.toml"
        )
    }
}

fn format_sources(aliases: &AliasMap) -> String {
    aliases.sources().map(|u| u.raw.as_str()).collect::<Vec<_>>().join(", ")
}

/// Stable per-invocation identifier — nanoseconds since the UNIX
/// epoch encoded as lowercase hex. Cheap and dependency-free; v0.17
/// may upgrade to `UUIDv7` once we have a use case for sortable IDs
/// that survive process boundaries.
fn new_transaction_id() -> String {
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map_or(0, |d| d.as_nanos());
    format!("{nanos:032x}")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn transaction_id_is_32_hex_chars() {
        let id = new_transaction_id();
        assert_eq!(id.len(), 32);
        assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
    }

    #[test]
    fn report_outcome_maps_to_telemetry() {
        assert_eq!(MigrateReportOutcome::Success.as_telemetry(), MigrateOutcome::Ok);
        assert_eq!(
            MigrateReportOutcome::PartialFailurePointerFlip.as_telemetry(),
            MigrateOutcome::PartialFailure
        );
        // Phase 7 audit (code-rev S8): distinct telemetry outcome,
        // not collapsed to `Ok`.
        assert_eq!(
            MigrateReportOutcome::SourceDeleteFailedPostCommit.as_telemetry(),
            MigrateOutcome::OkWithCleanupFailure
        );
        assert_eq!(MigrateReportOutcome::DryRun.as_telemetry(), MigrateOutcome::DryRun);
    }

    #[test]
    fn pointer_flip_failed_display_omits_uri_body() {
        // Phase 7 audit (security M2): the bubbled error's Display
        // must NOT carry the dest URI body or delete_hint — only the
        // alias. The CLI dispatcher downcasts and renders the
        // manual-recovery block to stderr (terminal-only per
        // SEC-INV-22).
        let e = PointerFlipFailed {
            alias: "stripe-key".to_owned(),
            dest_uri_raw: "vault-prod://secret/payments/stripe_key".to_owned(),
            dest_delete_hint: "VAULT_ADDR=… vault kv delete …".to_owned(),
        };
        let rendered = format!("{e}");
        assert!(rendered.contains("stripe-key"), "{rendered}");
        assert!(!rendered.contains("vault-prod://"), "leaked URI: {rendered}");
        assert!(!rendered.contains("vault kv delete"), "leaked hint: {rendered}");
    }
}