Skip to main content

aft/hashline/transaction/
mod.rs

1//! Hashline cross-file transactions: mutation-free Phase 1, preview, and
2//! patch-ordered Phase 2 with backups, baseline recheck, durability, and MV.
3//!
4//! The line-apply engine plans PUT/CUT/REM bytes. This module owns everything
5//! that touches the filesystem or the undo journal: rollback-availability
6//! checks, ordered execution, destination-before-source MV reporting, final-byte
7//! observation, register commit gating, and `op_id` emission.
8
9use std::fs::{self, File, OpenOptions};
10use std::io::{self, Write};
11use std::path::{Path, PathBuf};
12
13use crate::backup::{new_op_id, BackupStore};
14use crate::hashline::apply::{
15    apply_section_ops, commit_registers_if_complete, FileClassification, FileResult, MutationState,
16    PlannedFile, RegisterStore, SectionPlanInput, StagedRegisters,
17};
18use crate::hashline::scan::Snapshot;
19use crate::hashline::snapshot::{
20    invalidate_removed_source, publish_edit_response_snapshot, AffectedRegion,
21    EditResponseSnapshot, SnapshotStore,
22};
23use crate::hashline::syntax::{
24    Baseline, HashlineRejection, MvOperation, Operation, ResolvedOperation,
25};
26
27/// Destination coordinates for a section that ends with MV.
28#[derive(Clone, Debug)]
29pub struct MvDestinationInput<'a> {
30    pub canonical_path: &'a Path,
31    pub requested_path: &'a str,
32    /// `None` when the destination path does not exist yet (created-file rollback).
33    pub baseline_bytes: Option<&'a [u8]>,
34}
35
36/// One patch section ready for transaction planning.
37#[derive(Clone, Debug)]
38pub struct TransactionSectionInput<'a> {
39    pub canonical_path: &'a Path,
40    pub requested_path: &'a str,
41    pub baseline: &'a Baseline,
42    pub snapshot: &'a Snapshot,
43    pub operations: &'a [Operation],
44    pub resolved: &'a [ResolvedOperation],
45    pub mv_destination: Option<MvDestinationInput<'a>>,
46}
47
48/// Ordered Phase-1 plan. No disk, backup, snapshot, or session-register mutation.
49#[derive(Clone, Debug)]
50pub struct TransactionPlan {
51    pub steps: Vec<PlannedStep>,
52    pub staged_registers: StagedRegisters,
53}
54
55/// One patch-ordered execution unit.
56#[derive(Clone, Debug)]
57pub enum PlannedStep {
58    /// In-place PUT/CUT/REM against one path.
59    Mutate(PlannedFile),
60    /// Write planned bytes at the destination, then unlink the source.
61    Mv(PlannedMv),
62}
63
64/// MV plan: destination content is the post-line-op source bytes (or the
65/// untouched baseline when the section is a pure move).
66#[derive(Clone, Debug)]
67pub struct PlannedMv {
68    pub source_canonical: PathBuf,
69    pub source_requested: String,
70    pub source_baseline_bytes: Vec<u8>,
71    pub dest_canonical: PathBuf,
72    pub dest_requested: String,
73    pub dest_existed: bool,
74    pub dest_baseline_bytes: Option<Vec<u8>>,
75    pub final_bytes: Vec<u8>,
76    pub affected: AffectedRegion,
77    pub warnings: Vec<String>,
78    pub repair_layers: Vec<&'static str>,
79}
80
81/// Role of one ordered per-file outcome row.
82#[derive(Clone, Copy, Debug, Eq, PartialEq)]
83pub enum FileRole {
84    Primary,
85    MvDestination,
86    MvSource,
87}
88
89impl FileRole {
90    pub const fn as_str(self) -> &'static str {
91        match self {
92            Self::Primary => "primary",
93            Self::MvDestination => "mv_destination",
94            Self::MvSource => "mv_source",
95        }
96    }
97}
98
99/// One file row in the mutation-result envelope.
100#[derive(Clone, Debug, Eq, PartialEq)]
101pub struct FileOutcome {
102    pub canonical_path: PathBuf,
103    pub requested_path: String,
104    pub role: FileRole,
105    pub classification: FileClassification,
106    pub mutation_state: MutationState,
107    pub final_bytes: Option<Vec<u8>>,
108    pub final_tag: Option<String>,
109    pub affected: AffectedRegion,
110    pub warnings: Vec<String>,
111    pub format_skipped_reason: Option<String>,
112    pub backup_id: Option<String>,
113    pub remove_file: bool,
114    pub tag_notice: Option<String>,
115}
116
117/// Complete Phase-2 (or preview) envelope.
118#[derive(Clone, Debug, Eq, PartialEq)]
119pub struct TransactionEnvelope {
120    pub success: bool,
121    pub complete: bool,
122    pub files: Vec<FileOutcome>,
123    /// Present exactly when the undo journal retained at least one record.
124    pub op_id: Option<String>,
125    pub stop_reason: Option<&'static str>,
126    pub registers_committed: bool,
127    pub preview: bool,
128    /// Agent-visible lead-in so hosts that strip structured fields still see counts.
129    pub summary_text: String,
130}
131
132impl TransactionEnvelope {
133    /// Convert to the apply-layer envelope shape (without `op_id` / roles).
134    pub fn to_apply_envelope(&self) -> crate::hashline::apply::ApplyResultEnvelope {
135        crate::hashline::apply::ApplyResultEnvelope {
136            success: self.success,
137            complete: self.complete,
138            files: self
139                .files
140                .iter()
141                .filter(|file| file.role != FileRole::MvSource || file.remove_file)
142                .map(|file| FileResult {
143                    canonical_path: file.canonical_path.clone(),
144                    requested_path: file.requested_path.clone(),
145                    classification: file.classification,
146                    mutation_state: file.mutation_state,
147                    final_bytes: file.final_bytes.clone(),
148                    affected: file.affected.clone(),
149                    warnings: file.warnings.clone(),
150                    remove_file: file.remove_file,
151                })
152                .collect(),
153            registers_committed: self.registers_committed,
154        }
155    }
156}
157
158/// Test and integration fault injection points for Phase 2.
159#[derive(Clone, Debug, Eq, PartialEq)]
160pub enum ExecuteFault {
161    BaselineDrift { step: usize },
162    Backup { step: usize },
163    Write { step: usize },
164    Durability { step: usize },
165    SourceUnlink { step: usize },
166    FinalTagUnavailable { step: usize },
167    ValidationFailure { step: usize },
168}
169
170/// Runtime handles required to execute (or preview) a plan.
171pub struct ExecuteContext<'a> {
172    pub session: &'a str,
173    pub backups: &'a mut BackupStore,
174    pub snapshots: &'a mut SnapshotStore,
175    pub registers: &'a mut RegisterStore,
176    /// When false, Phase 1 must already have refused content-destructive ops.
177    /// Phase 2 still consults the store policy for real journal records.
178    pub backups_enabled: bool,
179    pub fault: Option<ExecuteFault>,
180}
181
182/// Plan every section without mutating disk, backups, snapshots, or registers.
183///
184/// Rollback availability is proven here. When `backups_enabled` is false, every
185/// operation that would destroy existing bytes it cannot restore is rejected as
186/// `hashline_backup_unavailable`. New-destination MV is allowed because its
187/// created-file tombstone is a real undo identity once Phase 2 journals it.
188pub fn plan_transaction(
189    sections: &[TransactionSectionInput<'_>],
190    session_registers: &RegisterStore,
191    backups_enabled: bool,
192) -> Result<TransactionPlan, HashlineRejection> {
193    let mut staged = session_registers.stage();
194    let mut steps = Vec::with_capacity(sections.len());
195
196    for section in sections {
197        let (line_ops, line_resolved, mv_op) = split_mv(section.operations, section.resolved)?;
198        if mv_op.is_some() && section.mv_destination.is_none() {
199            return Err(HashlineRejection::parse(
200                "MV section is missing resolved destination coordinates",
201            ));
202        }
203        if mv_op.is_none() && section.mv_destination.is_some() {
204            return Err(HashlineRejection::parse(
205                "destination coordinates supplied without an MV operation",
206            ));
207        }
208
209        let planned_source = if line_ops.is_empty() {
210            // Pure MV or empty line body: carry baseline bytes forward.
211            PlannedFile {
212                canonical_path: section.canonical_path.to_path_buf(),
213                requested_path: section.requested_path.to_string(),
214                baseline_bytes: section.baseline.bytes.clone(),
215                final_bytes: section.baseline.bytes.clone(),
216                affected: AffectedRegion::default(),
217                remove_file: false,
218                warnings: Vec::new(),
219                repair_layers: Vec::new(),
220            }
221        } else {
222            // Verify then plan line ops against the section baseline. Multi-section
223            // same-path composition is owned by the apply planner; transaction
224            // sections are already one logical unit per call site.
225            let input = SectionPlanInput {
226                canonical_path: section.canonical_path,
227                requested_path: section.requested_path,
228                baseline: section.baseline,
229                snapshot: section.snapshot,
230                operations: line_ops,
231                resolved: line_resolved,
232            };
233            let plan = plan_line_section(&input, &mut staged)?;
234            plan
235        };
236
237        let step = if let Some(dest) = section.mv_destination.as_ref() {
238            let dest_existed = dest.baseline_bytes.is_some();
239            PlannedStep::Mv(PlannedMv {
240                source_canonical: planned_source.canonical_path,
241                source_requested: planned_source.requested_path,
242                source_baseline_bytes: planned_source.baseline_bytes,
243                dest_canonical: dest.canonical_path.to_path_buf(),
244                dest_requested: dest.requested_path.to_string(),
245                dest_existed,
246                dest_baseline_bytes: dest.baseline_bytes.map(|bytes| bytes.to_vec()),
247                final_bytes: planned_source.final_bytes,
248                affected: planned_source.affected,
249                warnings: planned_source.warnings,
250                repair_layers: planned_source.repair_layers,
251            })
252        } else {
253            PlannedStep::Mutate(planned_source)
254        };
255
256        assert_rollback_available(backups_enabled, &step)?;
257        steps.push(step);
258    }
259
260    if steps.is_empty() {
261        return Err(HashlineRejection::parse(
262            "transaction plan requires at least one section",
263        ));
264    }
265
266    Ok(TransactionPlan {
267        steps,
268        staged_registers: staged,
269    })
270}
271
272/// Preview reuses Phase 1 and renders the planned envelope without any mutation.
273///
274/// No `op_id`, backup, snapshot mint, undo record, or register commit.
275pub fn preview_transaction(plan: TransactionPlan) -> TransactionEnvelope {
276    let files = plan
277        .steps
278        .iter()
279        .flat_map(preview_step_files)
280        .collect::<Vec<_>>();
281    let summary_text = summary_counts(&files);
282    // Drop staged registers — preview never commits.
283    RegisterStore::discard(plan.staged_registers);
284    TransactionEnvelope {
285        success: true,
286        complete: true,
287        files,
288        op_id: None,
289        stop_reason: None,
290        registers_committed: false,
291        preview: true,
292        summary_text,
293    }
294}
295
296/// Execute a Phase-1 plan in patch order under one optional `op_id`.
297///
298/// Order per file: journal → baseline recheck → write → (optional format /
299/// validate markers) → durability barrier → final tag from post-barrier bytes.
300/// MV writes and durably commits the destination before unlinking the source.
301pub fn execute_transaction(
302    plan: TransactionPlan,
303    ctx: &mut ExecuteContext<'_>,
304) -> TransactionEnvelope {
305    let TransactionPlan {
306        steps,
307        staged_registers,
308    } = plan;
309
310    let op_id = new_op_id();
311    let mut journaled = false;
312    let mut files = Vec::new();
313    let mut stopped = false;
314    let mut stop_reason: Option<&'static str> = None;
315
316    for (step_index, step) in steps.into_iter().enumerate() {
317        if stopped {
318            files.extend(not_attempted_for_step(&step));
319            continue;
320        }
321
322        match execute_step(step_index, step, &op_id, &mut journaled, ctx) {
323            StepExec::Applied(mut outcomes) => files.append(&mut outcomes),
324            StepExec::Stopped {
325                mut outcomes,
326                reason,
327            } => {
328                files.append(&mut outcomes);
329                stopped = true;
330                stop_reason = Some(reason);
331            }
332        }
333    }
334
335    // Successful MV source-removal companion rows are not independent planned
336    // files. Failed source-unlink rows do count so a partial MV is not complete.
337    let classifications: Vec<FileClassification> = files
338        .iter()
339        .filter(|file| counts_toward_completion(file))
340        .map(|file| file.classification)
341        .collect();
342    let registers_committed =
343        commit_registers_if_complete(ctx.registers, staged_registers, &classifications);
344
345    let applied = classifications
346        .iter()
347        .filter(|classification| classification.is_applied_star())
348        .count();
349    let planned_primary = classifications.len();
350    let success = applied > 0;
351    let complete = applied == planned_primary && planned_primary > 0;
352    let summary_text = summary_counts(&files);
353
354    TransactionEnvelope {
355        success,
356        complete,
357        files,
358        op_id: journaled.then_some(op_id),
359        stop_reason,
360        registers_committed,
361        preview: false,
362        summary_text,
363    }
364}
365
366/// Convenience: plan then either preview or execute.
367pub fn run_transaction(
368    sections: &[TransactionSectionInput<'_>],
369    session_registers: &RegisterStore,
370    ctx: &mut ExecuteContext<'_>,
371    preview: bool,
372) -> Result<TransactionEnvelope, HashlineRejection> {
373    let plan = plan_transaction(sections, session_registers, ctx.backups_enabled)?;
374    if preview {
375        Ok(preview_transaction(plan))
376    } else {
377        Ok(execute_transaction(plan, ctx))
378    }
379}
380
381// ── Phase 1 helpers ──────────────────────────────────────────────────────────
382
383fn split_mv<'a>(
384    operations: &'a [Operation],
385    resolved: &'a [ResolvedOperation],
386) -> Result<
387    (
388        &'a [Operation],
389        &'a [ResolvedOperation],
390        Option<&'a MvOperation>,
391    ),
392    HashlineRejection,
393> {
394    if operations.len() != resolved.len() {
395        return Err(HashlineRejection::parse(
396            "resolved operation count does not match the parsed section",
397        ));
398    }
399    if let Some(Operation::Mv(mv)) = operations.last() {
400        let line_len = operations.len() - 1;
401        if operations[..line_len]
402            .iter()
403            .any(|operation| matches!(operation, Operation::Mv(_)))
404        {
405            return Err(HashlineRejection::parse(
406                "MV must occur once and after all line operations",
407            ));
408        }
409        return Ok((&operations[..line_len], &resolved[..line_len], Some(mv)));
410    }
411    if operations
412        .iter()
413        .any(|operation| matches!(operation, Operation::Mv(_)))
414    {
415        return Err(HashlineRejection::parse(
416            "MV must occur once and after all line operations",
417        ));
418    }
419    Ok((operations, resolved, None))
420}
421
422fn plan_line_section(
423    section: &SectionPlanInput<'_>,
424    staged: &mut StagedRegisters,
425) -> Result<PlannedFile, HashlineRejection> {
426    // Exact verification against the common baseline before any byte planning.
427    for resolved in section.resolved {
428        match crate::hashline::syntax::verify_exact(
429            section.snapshot,
430            section.baseline,
431            resolved.address,
432        ) {
433            crate::hashline::syntax::VerificationOutcome::Exact => {}
434            crate::hashline::syntax::VerificationOutcome::RecoveryRequired(_) => {
435                return Err(HashlineRejection::new(
436                    crate::hashline::syntax::HashlineRejectionCode::StaleTag,
437                    crate::hashline::syntax::RejectionStage::Recovery,
438                    "addressed content no longer matches the Phase-1 baseline",
439                ));
440            }
441            crate::hashline::syntax::VerificationOutcome::Rejected(rejection) => {
442                return Err(rejection)
443            }
444            crate::hashline::syntax::VerificationOutcome::BlockNeedsResolution { .. } => {
445                return Err(HashlineRejection::new(
446                    crate::hashline::syntax::HashlineRejectionCode::BoundaryIneligible,
447                    crate::hashline::syntax::RejectionStage::Eligibility,
448                    "block address was not expanded before transaction planning",
449                ));
450            }
451        }
452    }
453    apply_section_ops(
454        section.requested_path,
455        section.canonical_path,
456        section.baseline,
457        section.operations,
458        section.resolved,
459        staged,
460    )
461}
462
463fn assert_rollback_available(
464    backups_enabled: bool,
465    step: &PlannedStep,
466) -> Result<(), HashlineRejection> {
467    if backups_enabled {
468        return Ok(());
469    }
470    match step {
471        PlannedStep::Mutate(_) => Err(HashlineRejection::backup_unavailable(
472            "backups are disabled; refusing destructive hashline mutation without a restore record",
473        )),
474        PlannedStep::Mv(mv) if mv.dest_existed => Err(HashlineRejection::backup_unavailable(
475            "backups are disabled; refusing MV onto an existing destination without a restore record",
476        )),
477        // New-destination MV keeps a created-file tombstone as its undo identity.
478        PlannedStep::Mv(_) => Ok(()),
479    }
480}
481
482fn preview_step_files(step: &PlannedStep) -> Vec<FileOutcome> {
483    match step {
484        PlannedStep::Mutate(file) => {
485            vec![FileOutcome {
486                canonical_path: file.canonical_path.clone(),
487                requested_path: file.requested_path.clone(),
488                role: FileRole::Primary,
489                classification: FileClassification::Applied,
490                mutation_state: MutationState::Unmutated,
491                final_bytes: Some(file.final_bytes.clone()),
492                final_tag: None,
493                affected: file.affected.clone(),
494                warnings: file.warnings.clone(),
495                format_skipped_reason: None,
496                backup_id: None,
497                remove_file: file.remove_file,
498                tag_notice: Some("preview: no final tag or undo identity".into()),
499            }]
500        }
501        PlannedStep::Mv(mv) => vec![
502            FileOutcome {
503                canonical_path: mv.dest_canonical.clone(),
504                requested_path: mv.dest_requested.clone(),
505                role: FileRole::MvDestination,
506                classification: FileClassification::Applied,
507                mutation_state: MutationState::Unmutated,
508                final_bytes: Some(mv.final_bytes.clone()),
509                final_tag: None,
510                affected: mv.affected.clone(),
511                warnings: mv.warnings.clone(),
512                format_skipped_reason: None,
513                backup_id: None,
514                remove_file: false,
515                tag_notice: Some("preview: no final tag or undo identity".into()),
516            },
517            FileOutcome {
518                canonical_path: mv.source_canonical.clone(),
519                requested_path: mv.source_requested.clone(),
520                role: FileRole::MvSource,
521                classification: FileClassification::Applied,
522                mutation_state: MutationState::Unmutated,
523                final_bytes: None,
524                final_tag: None,
525                affected: AffectedRegion::default(),
526                warnings: Vec::new(),
527                format_skipped_reason: None,
528                backup_id: None,
529                remove_file: true,
530                tag_notice: Some("preview: source removal not performed".into()),
531            },
532        ],
533    }
534}
535
536// ── Phase 2 execution ────────────────────────────────────────────────────────
537
538enum StepExec {
539    Applied(Vec<FileOutcome>),
540    Stopped {
541        outcomes: Vec<FileOutcome>,
542        reason: &'static str,
543    },
544}
545
546fn execute_step(
547    step_index: usize,
548    step: PlannedStep,
549    op_id: &str,
550    journaled: &mut bool,
551    ctx: &mut ExecuteContext<'_>,
552) -> StepExec {
553    match step {
554        PlannedStep::Mutate(file) => execute_mutate(step_index, file, op_id, journaled, ctx),
555        PlannedStep::Mv(mv) => execute_mv(step_index, mv, op_id, journaled, ctx),
556    }
557}
558
559fn execute_mutate(
560    step_index: usize,
561    file: PlannedFile,
562    op_id: &str,
563    journaled: &mut bool,
564    ctx: &mut ExecuteContext<'_>,
565) -> StepExec {
566    if fault_is(ctx, ExecuteFault::Backup { step: step_index }) {
567        return StepExec::Stopped {
568            outcomes: vec![failed_outcome(
569                &file.canonical_path,
570                &file.requested_path,
571                FileRole::Primary,
572                FileClassification::FailedBackup,
573                file.remove_file,
574                file.warnings.clone(),
575            )],
576            reason: "failed_backup",
577        };
578    }
579
580    // Journal before any destructive write.
581    let backup_id = match journal_existing_or_skip(
582        ctx,
583        op_id,
584        &file.canonical_path,
585        file.remove_file,
586        "hashline: pre-mutation backup",
587    ) {
588        Ok(id) => {
589            if id.is_some() {
590                *journaled = true;
591            }
592            id
593        }
594        Err(_) => {
595            return StepExec::Stopped {
596                outcomes: vec![failed_outcome(
597                    &file.canonical_path,
598                    &file.requested_path,
599                    FileRole::Primary,
600                    FileClassification::FailedBackup,
601                    file.remove_file,
602                    file.warnings.clone(),
603                )],
604                reason: "failed_backup",
605            };
606        }
607    };
608
609    // Content-destructive ops must hold a real restore record when backups are
610    // the precondition of hashline apply. A disabled store that yields no id is
611    // treated as backup failure rather than an unrecoverable write.
612    if backup_id.is_none() && path_exists(&file.canonical_path) {
613        return StepExec::Stopped {
614            outcomes: vec![failed_outcome(
615                &file.canonical_path,
616                &file.requested_path,
617                FileRole::Primary,
618                FileClassification::FailedBackup,
619                file.remove_file,
620                file.warnings.clone(),
621            )],
622            reason: "failed_backup",
623        };
624    }
625
626    if fault_is(ctx, ExecuteFault::BaselineDrift { step: step_index })
627        || !baseline_matches(&file.canonical_path, &file.baseline_bytes)
628    {
629        return StepExec::Stopped {
630            outcomes: vec![failed_outcome(
631                &file.canonical_path,
632                &file.requested_path,
633                FileRole::Primary,
634                FileClassification::FailedBaselineDrift,
635                file.remove_file,
636                file.warnings.clone(),
637            )],
638            reason: "hashline_baseline_drift",
639        };
640    }
641
642    if fault_is(ctx, ExecuteFault::Write { step: step_index }) {
643        return StepExec::Stopped {
644            outcomes: vec![failed_outcome(
645                &file.canonical_path,
646                &file.requested_path,
647                FileRole::Primary,
648                FileClassification::FailedWrite,
649                file.remove_file,
650                file.warnings.clone(),
651            )],
652            reason: "failed_write",
653        };
654    }
655
656    if file.remove_file {
657        if let Err(error) = fs::remove_file(&file.canonical_path) {
658            if error.kind() != io::ErrorKind::NotFound {
659                return StepExec::Stopped {
660                    outcomes: vec![failed_outcome(
661                        &file.canonical_path,
662                        &file.requested_path,
663                        FileRole::Primary,
664                        FileClassification::FailedWrite,
665                        true,
666                        file.warnings.clone(),
667                    )],
668                    reason: "failed_write",
669                };
670            }
671        }
672        invalidate_removed_source(ctx.snapshots, &file.canonical_path);
673        return StepExec::Applied(vec![FileOutcome {
674            canonical_path: file.canonical_path,
675            requested_path: file.requested_path,
676            role: FileRole::Primary,
677            classification: FileClassification::Applied,
678            mutation_state: MutationState::Applied,
679            final_bytes: None,
680            final_tag: None,
681            affected: AffectedRegion::default(),
682            warnings: file.warnings,
683            format_skipped_reason: None,
684            backup_id,
685            remove_file: true,
686            tag_notice: Some("source path removed; no final tag".into()),
687        }]);
688    }
689
690    if let Err(error) = durable_write(&file.canonical_path, &file.final_bytes) {
691        let classification = if error.to_string().contains("durability") {
692            FileClassification::FailedDurability
693        } else {
694            FileClassification::FailedWrite
695        };
696        return StepExec::Stopped {
697            outcomes: vec![failed_outcome(
698                &file.canonical_path,
699                &file.requested_path,
700                FileRole::Primary,
701                classification,
702                false,
703                file.warnings.clone(),
704            )],
705            reason: classification.as_str(),
706        };
707    }
708
709    if fault_is(ctx, ExecuteFault::Durability { step: step_index }) {
710        return StepExec::Stopped {
711            outcomes: vec![failed_outcome(
712                &file.canonical_path,
713                &file.requested_path,
714                FileRole::Primary,
715                FileClassification::FailedDurability,
716                false,
717                file.warnings.clone(),
718            )],
719            reason: "failed_durability",
720        };
721    }
722
723    // Authoritative post-barrier bytes.
724    let on_disk = match fs::read(&file.canonical_path) {
725        Ok(bytes) => bytes,
726        Err(_) => {
727            return StepExec::Applied(vec![FileOutcome {
728                canonical_path: file.canonical_path,
729                requested_path: file.requested_path,
730                role: FileRole::Primary,
731                classification: FileClassification::AppliedTagUnavailable,
732                mutation_state: MutationState::Applied,
733                final_bytes: Some(file.final_bytes),
734                final_tag: None,
735                affected: file.affected,
736                warnings: file.warnings,
737                format_skipped_reason: None,
738                backup_id,
739                remove_file: false,
740                tag_notice: Some("final bytes could not be re-read for tagging".into()),
741            }]);
742        }
743    };
744
745    let mut classification = FileClassification::Applied;
746    if fault_is(ctx, ExecuteFault::ValidationFailure { step: step_index }) {
747        classification = FileClassification::AppliedWithValidationFailure;
748    }
749
750    let (final_tag, tag_notice, classification) =
751        if fault_is(ctx, ExecuteFault::FinalTagUnavailable { step: step_index }) {
752            (
753                None,
754                Some("final tag unavailable; re-read before chaining".into()),
755                FileClassification::AppliedTagUnavailable,
756            )
757        } else {
758            let published = publish_edit_response_snapshot(
759                ctx.snapshots,
760                &file.canonical_path,
761                file.requested_path.clone(),
762                &on_disk,
763                &file.affected,
764            );
765            tag_from_publish(published, classification)
766        };
767
768    StepExec::Applied(vec![FileOutcome {
769        canonical_path: file.canonical_path,
770        requested_path: file.requested_path,
771        role: FileRole::Primary,
772        classification,
773        mutation_state: classification.mutation_state(),
774        final_bytes: Some(on_disk),
775        final_tag,
776        affected: file.affected,
777        warnings: file.warnings,
778        format_skipped_reason: None,
779        backup_id,
780        remove_file: false,
781        tag_notice,
782    }])
783}
784
785fn execute_mv(
786    step_index: usize,
787    mv: PlannedMv,
788    op_id: &str,
789    journaled: &mut bool,
790    ctx: &mut ExecuteContext<'_>,
791) -> StepExec {
792    if fault_is(ctx, ExecuteFault::Backup { step: step_index }) {
793        return StepExec::Stopped {
794            outcomes: mv_failed_pair(
795                &mv,
796                FileClassification::FailedBackup,
797                FileClassification::NotAttempted,
798            ),
799            reason: "failed_backup",
800        };
801    }
802
803    // Destination journal first: existing content backup or created-file tombstone.
804    let dest_backup_id = if mv.dest_existed {
805        match ctx.backups.snapshot_with_op(
806            ctx.session,
807            &mv.dest_canonical,
808            "hashline: MV destination backup",
809            Some(op_id),
810        ) {
811            Ok(Some(id)) => {
812                *journaled = true;
813                Some(id)
814            }
815            Ok(None) => {
816                return StepExec::Stopped {
817                    outcomes: mv_failed_pair(
818                        &mv,
819                        FileClassification::FailedBackup,
820                        FileClassification::NotAttempted,
821                    ),
822                    reason: "failed_backup",
823                };
824            }
825            Err(_) => {
826                return StepExec::Stopped {
827                    outcomes: mv_failed_pair(
828                        &mv,
829                        FileClassification::FailedBackup,
830                        FileClassification::NotAttempted,
831                    ),
832                    reason: "failed_backup",
833                };
834            }
835        }
836    } else {
837        match ctx.backups.snapshot_op_tombstone(
838            ctx.session,
839            op_id,
840            &mv.dest_canonical,
841            "hashline: MV created destination",
842        ) {
843            Ok(Some(id)) => {
844                *journaled = true;
845                Some(id)
846            }
847            Ok(None) => {
848                // Tombstone unavailable (backups disabled). New-dest MV is still
849                // allowed in Phase 1; without a journal entry we refuse the write
850                // rather than advertise a missing undo identity.
851                return StepExec::Stopped {
852                    outcomes: mv_failed_pair(
853                        &mv,
854                        FileClassification::FailedBackup,
855                        FileClassification::NotAttempted,
856                    ),
857                    reason: "failed_backup",
858                };
859            }
860            Err(_) => {
861                return StepExec::Stopped {
862                    outcomes: mv_failed_pair(
863                        &mv,
864                        FileClassification::FailedBackup,
865                        FileClassification::NotAttempted,
866                    ),
867                    reason: "failed_backup",
868                };
869            }
870        }
871    };
872
873    // Source content backup so undo can restore it after unlink.
874    let source_backup_id = match ctx.backups.snapshot_with_op(
875        ctx.session,
876        &mv.source_canonical,
877        "hashline: MV source backup",
878        Some(op_id),
879    ) {
880        Ok(Some(id)) => {
881            *journaled = true;
882            Some(id)
883        }
884        Ok(None) | Err(_) => {
885            return StepExec::Stopped {
886                outcomes: mv_failed_pair(
887                    &mv,
888                    FileClassification::FailedBackup,
889                    FileClassification::NotAttempted,
890                ),
891                reason: "failed_backup",
892            };
893        }
894    };
895
896    // Baseline recheck on source (and existing destination).
897    if fault_is(ctx, ExecuteFault::BaselineDrift { step: step_index })
898        || !baseline_matches(&mv.source_canonical, &mv.source_baseline_bytes)
899        || mv
900            .dest_baseline_bytes
901            .as_ref()
902            .is_some_and(|expected| !baseline_matches(&mv.dest_canonical, expected))
903    {
904        return StepExec::Stopped {
905            outcomes: mv_failed_pair(
906                &mv,
907                FileClassification::FailedBaselineDrift,
908                FileClassification::NotAttempted,
909            ),
910            reason: "hashline_baseline_drift",
911        };
912    }
913
914    if fault_is(ctx, ExecuteFault::Write { step: step_index }) {
915        return StepExec::Stopped {
916            outcomes: mv_failed_pair(
917                &mv,
918                FileClassification::FailedWrite,
919                FileClassification::NotAttempted,
920            ),
921            reason: "failed_write",
922        };
923    }
924
925    // Destination durability precedes source unlink.
926    if let Err(error) = ensure_parent_dirs(&mv.dest_canonical)
927        .and_then(|_| durable_write(&mv.dest_canonical, &mv.final_bytes))
928    {
929        let classification = if error.to_string().contains("durability") {
930            FileClassification::FailedDurability
931        } else {
932            FileClassification::FailedWrite
933        };
934        return StepExec::Stopped {
935            outcomes: mv_failed_pair(&mv, classification, FileClassification::NotAttempted),
936            reason: classification.as_str(),
937        };
938    }
939
940    if fault_is(ctx, ExecuteFault::Durability { step: step_index }) {
941        return StepExec::Stopped {
942            outcomes: mv_failed_pair(
943                &mv,
944                FileClassification::FailedDurability,
945                FileClassification::NotAttempted,
946            ),
947            reason: "failed_durability",
948        };
949    }
950
951    let dest_on_disk = fs::read(&mv.dest_canonical).unwrap_or_else(|_| mv.final_bytes.clone());
952
953    if fault_is(ctx, ExecuteFault::SourceUnlink { step: step_index })
954        || fs::remove_file(&mv.source_canonical).is_err()
955    {
956        // Destination stands; source intact. Shared op_id remains for recovery.
957        let (final_tag, tag_notice, dest_class) =
958            observe_dest_tag(ctx, &mv, &dest_on_disk, step_index);
959        return StepExec::Stopped {
960            outcomes: vec![
961                FileOutcome {
962                    canonical_path: mv.dest_canonical,
963                    requested_path: mv.dest_requested,
964                    role: FileRole::MvDestination,
965                    classification: dest_class,
966                    mutation_state: dest_class.mutation_state(),
967                    final_bytes: Some(dest_on_disk),
968                    final_tag,
969                    affected: mv.affected,
970                    warnings: mv.warnings,
971                    format_skipped_reason: None,
972                    backup_id: dest_backup_id,
973                    remove_file: false,
974                    tag_notice,
975                },
976                FileOutcome {
977                    canonical_path: mv.source_canonical,
978                    requested_path: mv.source_requested,
979                    role: FileRole::MvSource,
980                    classification: FileClassification::FailedSourceUnlink,
981                    mutation_state: MutationState::PartialMv,
982                    final_bytes: Some(mv.source_baseline_bytes),
983                    final_tag: None,
984                    affected: AffectedRegion::default(),
985                    warnings: Vec::new(),
986                    format_skipped_reason: None,
987                    backup_id: source_backup_id,
988                    remove_file: false,
989                    tag_notice: Some(
990                        "destination written; source unlink failed — partial MV under shared op_id"
991                            .into(),
992                    ),
993                },
994            ],
995            reason: "failed_source_unlink",
996        };
997    }
998
999    invalidate_removed_source(ctx.snapshots, &mv.source_canonical);
1000
1001    let (final_tag, tag_notice, dest_class) = observe_dest_tag(ctx, &mv, &dest_on_disk, step_index);
1002
1003    StepExec::Applied(vec![
1004        FileOutcome {
1005            canonical_path: mv.dest_canonical,
1006            requested_path: mv.dest_requested,
1007            role: FileRole::MvDestination,
1008            classification: dest_class,
1009            mutation_state: dest_class.mutation_state(),
1010            final_bytes: Some(dest_on_disk),
1011            final_tag,
1012            affected: mv.affected,
1013            warnings: mv.warnings,
1014            format_skipped_reason: None,
1015            backup_id: dest_backup_id,
1016            remove_file: false,
1017            tag_notice,
1018        },
1019        FileOutcome {
1020            canonical_path: mv.source_canonical,
1021            requested_path: mv.source_requested,
1022            role: FileRole::MvSource,
1023            classification: FileClassification::Applied,
1024            mutation_state: MutationState::Applied,
1025            final_bytes: None,
1026            final_tag: None,
1027            affected: AffectedRegion::default(),
1028            warnings: Vec::new(),
1029            format_skipped_reason: None,
1030            backup_id: source_backup_id,
1031            remove_file: true,
1032            tag_notice: Some("source path removed; no final tag".into()),
1033        },
1034    ])
1035}
1036
1037fn observe_dest_tag(
1038    ctx: &mut ExecuteContext<'_>,
1039    mv: &PlannedMv,
1040    dest_on_disk: &[u8],
1041    step_index: usize,
1042) -> (Option<String>, Option<String>, FileClassification) {
1043    if fault_is(ctx, ExecuteFault::FinalTagUnavailable { step: step_index }) {
1044        return (
1045            None,
1046            Some("final tag unavailable; re-read before chaining".into()),
1047            FileClassification::AppliedTagUnavailable,
1048        );
1049    }
1050    let mut classification = FileClassification::Applied;
1051    if fault_is(ctx, ExecuteFault::ValidationFailure { step: step_index }) {
1052        classification = FileClassification::AppliedWithValidationFailure;
1053    }
1054    let published = publish_edit_response_snapshot(
1055        ctx.snapshots,
1056        &mv.dest_canonical,
1057        mv.dest_requested.clone(),
1058        dest_on_disk,
1059        &mv.affected,
1060    );
1061    tag_from_publish(published, classification)
1062}
1063
1064fn tag_from_publish(
1065    published: EditResponseSnapshot,
1066    classification: FileClassification,
1067) -> (Option<String>, Option<String>, FileClassification) {
1068    if let Some(snapshot) = published.snapshot {
1069        (Some(snapshot.tag.clone()), published.notice, classification)
1070    } else {
1071        (
1072            None,
1073            published
1074                .notice
1075                .or_else(|| Some("final tag unavailable; re-read before chaining".into())),
1076            FileClassification::AppliedTagUnavailable,
1077        )
1078    }
1079}
1080
1081fn journal_existing_or_skip(
1082    ctx: &mut ExecuteContext<'_>,
1083    op_id: &str,
1084    path: &Path,
1085    _remove_file: bool,
1086    description: &str,
1087) -> Result<Option<String>, ()> {
1088    if !path_exists(path) {
1089        // Creating a brand-new path via PUT is out of v1; treat as no-op journal.
1090        return Ok(None);
1091    }
1092    match ctx
1093        .backups
1094        .snapshot_with_op(ctx.session, path, description, Some(op_id))
1095    {
1096        Ok(id) => Ok(id),
1097        Err(_) => Err(()),
1098    }
1099}
1100
1101// ── Disk helpers ─────────────────────────────────────────────────────────────
1102
1103fn path_exists(path: &Path) -> bool {
1104    fs::symlink_metadata(path).is_ok()
1105}
1106
1107fn baseline_matches(path: &Path, expected: &[u8]) -> bool {
1108    match fs::read(path) {
1109        Ok(bytes) => bytes == expected,
1110        Err(error) if error.kind() == io::ErrorKind::NotFound => expected.is_empty(),
1111        Err(_) => false,
1112    }
1113}
1114
1115fn ensure_parent_dirs(path: &Path) -> io::Result<()> {
1116    if let Some(parent) = path.parent() {
1117        if !parent.as_os_str().is_empty() {
1118            fs::create_dir_all(parent)?;
1119        }
1120    }
1121    Ok(())
1122}
1123
1124/// Write bytes via temp + fsync + rename so a crash cannot leave a torn target.
1125fn durable_write(path: &Path, bytes: &[u8]) -> io::Result<()> {
1126    ensure_parent_dirs(path)?;
1127    let parent = path.parent().unwrap_or_else(|| Path::new("."));
1128    let file_name = path
1129        .file_name()
1130        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no file name"))?;
1131    let temp_name = {
1132        let mut name = std::ffi::OsString::from(".aft-hashline-");
1133        name.push(file_name);
1134        name.push(".tmp");
1135        name
1136    };
1137    let temp_path = parent.join(temp_name);
1138
1139    let write_result = (|| {
1140        let mut file = OpenOptions::new()
1141            .write(true)
1142            .create(true)
1143            .truncate(true)
1144            .open(&temp_path)?;
1145        file.write_all(bytes)?;
1146        file.sync_all()
1147            .map_err(|error| io::Error::new(error.kind(), format!("durability: {error}")))?;
1148        fs::rename(&temp_path, path)?;
1149        // Best-effort directory durability after the rename.
1150        if let Ok(dir) = File::open(parent) {
1151            let _ = dir.sync_all();
1152        }
1153        Ok(())
1154    })();
1155
1156    if write_result.is_err() {
1157        let _ = fs::remove_file(&temp_path);
1158    }
1159    write_result
1160}
1161
1162// ── Outcome helpers ──────────────────────────────────────────────────────────
1163
1164fn failed_outcome(
1165    path: &Path,
1166    requested: &str,
1167    role: FileRole,
1168    classification: FileClassification,
1169    remove_file: bool,
1170    warnings: Vec<String>,
1171) -> FileOutcome {
1172    FileOutcome {
1173        canonical_path: path.to_path_buf(),
1174        requested_path: requested.to_string(),
1175        role,
1176        classification,
1177        mutation_state: classification.mutation_state(),
1178        final_bytes: None,
1179        final_tag: None,
1180        affected: AffectedRegion::default(),
1181        warnings,
1182        format_skipped_reason: None,
1183        backup_id: None,
1184        remove_file,
1185        tag_notice: None,
1186    }
1187}
1188
1189fn mv_failed_pair(
1190    mv: &PlannedMv,
1191    dest_class: FileClassification,
1192    source_class: FileClassification,
1193) -> Vec<FileOutcome> {
1194    vec![
1195        failed_outcome(
1196            &mv.dest_canonical,
1197            &mv.dest_requested,
1198            FileRole::MvDestination,
1199            dest_class,
1200            false,
1201            mv.warnings.clone(),
1202        ),
1203        failed_outcome(
1204            &mv.source_canonical,
1205            &mv.source_requested,
1206            FileRole::MvSource,
1207            source_class,
1208            false,
1209            Vec::new(),
1210        ),
1211    ]
1212}
1213
1214fn not_attempted_for_step(step: &PlannedStep) -> Vec<FileOutcome> {
1215    match step {
1216        PlannedStep::Mutate(file) => vec![failed_outcome(
1217            &file.canonical_path,
1218            &file.requested_path,
1219            FileRole::Primary,
1220            FileClassification::NotAttempted,
1221            file.remove_file,
1222            Vec::new(),
1223        )],
1224        PlannedStep::Mv(mv) => mv_failed_pair(
1225            mv,
1226            FileClassification::NotAttempted,
1227            FileClassification::NotAttempted,
1228        ),
1229    }
1230}
1231
1232fn fault_is(ctx: &ExecuteContext<'_>, want: ExecuteFault) -> bool {
1233    ctx.fault.as_ref() == Some(&want)
1234}
1235
1236fn counts_toward_completion(file: &FileOutcome) -> bool {
1237    // Successful source removal is a companion row on an already-counted MV dest.
1238    !(file.role == FileRole::MvSource && file.classification.is_applied_star())
1239}
1240
1241fn summary_counts(files: &[FileOutcome]) -> String {
1242    let primary: Vec<_> = files
1243        .iter()
1244        .filter(|file| counts_toward_completion(file))
1245        .collect();
1246    let applied = primary
1247        .iter()
1248        .filter(|file| file.classification.is_applied_star())
1249        .count();
1250    let total = primary.len();
1251    format!("{applied} of {total} files applied")
1252}
1253
1254#[cfg(test)]
1255mod tests {
1256    use super::*;
1257    use crate::backup::BackupPolicy;
1258    use crate::hashline::scan::scan_bytes;
1259    use crate::hashline::snapshot::{capture_taggable_read, ReadPublication, ReadSelection};
1260    use crate::hashline::syntax::{
1261        parse_address, resolve_address, resolve_snapshot, PutOperation, PutSource, RegisterRef,
1262        ResolvedAddress,
1263    };
1264
1265    const SESSION: &str = "hashline-tx-test";
1266
1267    fn whole_snapshot(bytes: &[u8]) -> Snapshot {
1268        scan_bytes(bytes)
1269    }
1270
1271    fn put_text(address: &str, body: &[&str]) -> Operation {
1272        Operation::Put(PutOperation {
1273            address: parse_address(address).unwrap(),
1274            source: PutSource::Text(body.iter().map(|line| (*line).to_string()).collect()),
1275            line: 1,
1276        })
1277    }
1278
1279    fn resolve_one(snapshot: &Snapshot, operation: &Operation) -> ResolvedOperation {
1280        let address = match operation.address() {
1281            Some(address) => resolve_address(address, snapshot).unwrap(),
1282            None => ResolvedAddress::WholeFile,
1283        };
1284        ResolvedOperation {
1285            operation_index: 0,
1286            address,
1287        }
1288    }
1289
1290    fn write_file(path: &Path, bytes: &[u8]) {
1291        if let Some(parent) = path.parent() {
1292            fs::create_dir_all(parent).unwrap();
1293        }
1294        fs::write(path, bytes).unwrap();
1295    }
1296
1297    fn backup_store(dir: &Path) -> BackupStore {
1298        let mut store = BackupStore::new();
1299        store.set_storage_dir(dir.to_path_buf(), 72);
1300        store
1301    }
1302
1303    fn ctx<'a>(
1304        backups: &'a mut BackupStore,
1305        snapshots: &'a mut SnapshotStore,
1306        registers: &'a mut RegisterStore,
1307        backups_enabled: bool,
1308        fault: Option<ExecuteFault>,
1309    ) -> ExecuteContext<'a> {
1310        ExecuteContext {
1311            session: SESSION,
1312            backups,
1313            snapshots,
1314            registers,
1315            backups_enabled,
1316            fault,
1317        }
1318    }
1319
1320    fn section_put<'a>(
1321        path: &'a Path,
1322        requested: &'a str,
1323        baseline: &'a Baseline,
1324        snapshot: &'a Snapshot,
1325        ops: &'a [Operation],
1326        resolved: &'a [ResolvedOperation],
1327    ) -> TransactionSectionInput<'a> {
1328        TransactionSectionInput {
1329            canonical_path: path,
1330            requested_path: requested,
1331            baseline,
1332            snapshot,
1333            operations: ops,
1334            resolved,
1335            mv_destination: None,
1336        }
1337    }
1338
1339    fn put_after_reads(
1340        selections: impl IntoIterator<Item = ReadSelection>,
1341    ) -> Result<Vec<u8>, HashlineRejection> {
1342        let temp = tempfile::tempdir().unwrap();
1343        let path = temp.path().join("reread.txt");
1344        let original = b"one\ntwo\nthree\nfour\n";
1345        write_file(&path, original);
1346
1347        let mut snapshots = SnapshotStore::new();
1348        let mut tag = None;
1349        for selection in selections {
1350            let publication =
1351                capture_taggable_read(&mut snapshots, &path, "reread.txt", selection).unwrap();
1352            let ReadPublication::Tagged { snapshot, .. } = publication else {
1353                panic!("fixture read must publish a tagged snapshot");
1354            };
1355            tag.get_or_insert(snapshot.tag);
1356        }
1357
1358        let snapshot = resolve_snapshot(
1359            &mut snapshots,
1360            &path,
1361            tag.as_deref().expect("at least one read selection"),
1362        )?;
1363        let baseline = Baseline::from_bytes(original.to_vec());
1364        let operations = vec![put_text("2", &["TWO"])];
1365        let resolved = vec![resolve_one(&snapshot, &operations[0])];
1366        let sections = [section_put(
1367            &path,
1368            "reread.txt",
1369            &baseline,
1370            &snapshot,
1371            &operations,
1372            &resolved,
1373        )];
1374        let session_registers = RegisterStore::new();
1375        let plan = plan_transaction(&sections, &session_registers, true)?;
1376        let mut backups = backup_store(&temp.path().join("backups"));
1377        let mut execution_registers = RegisterStore::new();
1378        let mut execution = ctx(
1379            &mut backups,
1380            &mut snapshots,
1381            &mut execution_registers,
1382            true,
1383            None,
1384        );
1385        let envelope = execute_transaction(plan, &mut execution);
1386        assert!(envelope.success);
1387        assert!(envelope.complete);
1388        Ok(fs::read(path).unwrap())
1389    }
1390
1391    #[test]
1392    fn two_ranged_reads_of_one_version_then_put_applies() {
1393        let bytes = put_after_reads([ReadSelection::range(1, 2), ReadSelection::range(3, 4)])
1394            .expect("same-version ranged reads must resolve");
1395        assert_eq!(bytes, b"one\nTWO\nthree\nfour\n");
1396    }
1397
1398    #[test]
1399    fn ranged_then_whole_read_of_one_version_then_put_applies() {
1400        let bytes = put_after_reads([ReadSelection::range(2, 2), ReadSelection::WholeFile])
1401            .expect("same-version ranged and whole reads must resolve");
1402        assert_eq!(bytes, b"one\nTWO\nthree\nfour\n");
1403    }
1404
1405    #[test]
1406    fn second_read_without_intervening_mutation_does_not_enter_refusal_loop() {
1407        let bytes = put_after_reads([ReadSelection::range(1, 1), ReadSelection::range(2, 2)])
1408            .expect("a second read of unchanged content must leave the tag editable");
1409        assert_eq!(bytes, b"one\nTWO\nthree\nfour\n");
1410    }
1411
1412    /// A8: Phase 1 is mutation-free; Phase 2 is patch-ordered with honest envelopes.
1413    #[test]
1414    fn a8_phase1_mutation_free_and_phase2_ordered() {
1415        let temp = tempfile::tempdir().unwrap();
1416        let a = temp.path().join("a.txt");
1417        let b = temp.path().join("b.txt");
1418        write_file(&a, b"alpha\n");
1419        write_file(&b, b"beta\n");
1420        let bytes_a = fs::read(&a).unwrap();
1421        let bytes_b = fs::read(&b).unwrap();
1422        let snap_a = whole_snapshot(&bytes_a);
1423        let snap_b = whole_snapshot(&bytes_b);
1424        let base_a = Baseline::from_bytes(bytes_a.clone());
1425        let base_b = Baseline::from_bytes(bytes_b.clone());
1426        let ops_a = vec![put_text("1", &["ALPHA"])];
1427        let ops_b = vec![put_text("1", &["BETA"])];
1428        let res_a = vec![resolve_one(&snap_a, &ops_a[0])];
1429        let res_b = vec![resolve_one(&snap_b, &ops_b[0])];
1430        let sections = [
1431            section_put(&a, "a.txt", &base_a, &snap_a, &ops_a, &res_a),
1432            section_put(&b, "b.txt", &base_b, &snap_b, &ops_b, &res_b),
1433        ];
1434        let registers = RegisterStore::new();
1435        let plan = plan_transaction(&sections, &registers, true).expect("phase1");
1436        // Phase 1 left disk untouched.
1437        assert_eq!(fs::read(&a).unwrap(), b"alpha\n");
1438        assert_eq!(fs::read(&b).unwrap(), b"beta\n");
1439        assert_eq!(plan.steps.len(), 2);
1440
1441        let backup_dir = temp.path().join("backups");
1442        let mut backups = backup_store(&backup_dir);
1443        let mut snapshots = SnapshotStore::new();
1444        let mut session_regs = RegisterStore::new();
1445        let mut exec = ctx(&mut backups, &mut snapshots, &mut session_regs, true, None);
1446        let envelope = execute_transaction(plan, &mut exec);
1447        assert!(envelope.success);
1448        assert!(envelope.complete);
1449        assert!(envelope.op_id.is_some());
1450        assert_eq!(envelope.files.len(), 2);
1451        assert_eq!(envelope.files[0].requested_path, "a.txt");
1452        assert_eq!(envelope.files[1].requested_path, "b.txt");
1453        assert_eq!(
1454            envelope.files[0].classification,
1455            FileClassification::Applied
1456        );
1457        assert_eq!(envelope.files[0].mutation_state, MutationState::Applied);
1458        assert_eq!(fs::read(&a).unwrap(), b"ALPHA\n");
1459        assert_eq!(fs::read(&b).unwrap(), b"BETA\n");
1460        assert!(envelope.summary_text.contains("2 of 2 files applied"));
1461
1462        // One real aft_safety undo restores both files under the shared op_id.
1463        let op_id = envelope.op_id.clone().unwrap();
1464        let restored = backups.restore_last_operation(SESSION).unwrap();
1465        assert_eq!(restored.op_id, op_id);
1466        assert_eq!(fs::read(&a).unwrap(), b"alpha\n");
1467        assert_eq!(fs::read(&b).unwrap(), b"beta\n");
1468    }
1469
1470    /// A8: all-failed Phase 2 returns success:false with the complete envelope.
1471    #[test]
1472    fn a8_all_failed_emits_success_false_with_envelope() {
1473        let temp = tempfile::tempdir().unwrap();
1474        let a = temp.path().join("a.txt");
1475        let b = temp.path().join("b.txt");
1476        write_file(&a, b"a\n");
1477        write_file(&b, b"b\n");
1478        let bytes_a = fs::read(&a).unwrap();
1479        let bytes_b = fs::read(&b).unwrap();
1480        let snap_a = whole_snapshot(&bytes_a);
1481        let snap_b = whole_snapshot(&bytes_b);
1482        let base_a = Baseline::from_bytes(bytes_a);
1483        let base_b = Baseline::from_bytes(bytes_b);
1484        let ops_a = vec![put_text("1", &["A"])];
1485        let ops_b = vec![put_text("1", &["B"])];
1486        let res_a = vec![resolve_one(&snap_a, &ops_a[0])];
1487        let res_b = vec![resolve_one(&snap_b, &ops_b[0])];
1488        let sections = [
1489            section_put(&a, "a.txt", &base_a, &snap_a, &ops_a, &res_a),
1490            section_put(&b, "b.txt", &base_b, &snap_b, &ops_b, &res_b),
1491        ];
1492        let registers = RegisterStore::new();
1493        let plan = plan_transaction(&sections, &registers, true).unwrap();
1494
1495        let mut backups = backup_store(&temp.path().join("backups"));
1496        let mut snapshots = SnapshotStore::new();
1497        let mut session_regs = RegisterStore::new();
1498        let mut exec = ctx(
1499            &mut backups,
1500            &mut snapshots,
1501            &mut session_regs,
1502            true,
1503            Some(ExecuteFault::BaselineDrift { step: 0 }),
1504        );
1505        let envelope = execute_transaction(plan, &mut exec);
1506        assert!(!envelope.success);
1507        assert!(!envelope.complete);
1508        assert_eq!(
1509            envelope.files[0].classification,
1510            FileClassification::FailedBaselineDrift
1511        );
1512        assert_eq!(envelope.files[0].mutation_state, MutationState::Unmutated);
1513        assert_eq!(
1514            envelope.files[1].classification,
1515            FileClassification::NotAttempted
1516        );
1517        assert_eq!(envelope.files[1].mutation_state, MutationState::Unmutated);
1518        assert_eq!(envelope.stop_reason, Some("hashline_baseline_drift"));
1519        assert!(envelope.summary_text.starts_with("0 of 2 files applied"));
1520        // Journal runs before baseline recheck, so a drift stop after backup still
1521        // yields op_id. Disk bytes remain unchanged (unmutated).
1522        assert!(envelope.op_id.is_some());
1523        assert_eq!(fs::read(&a).unwrap(), b"a\n");
1524        assert_eq!(fs::read(&b).unwrap(), b"b\n");
1525    }
1526
1527    /// A8: partial failure keeps earlier applications under a shared op_id.
1528    #[test]
1529    fn a8_partial_failure_keeps_prior_under_shared_op_id() {
1530        let temp = tempfile::tempdir().unwrap();
1531        let a = temp.path().join("a.txt");
1532        let b = temp.path().join("b.txt");
1533        write_file(&a, b"a\n");
1534        write_file(&b, b"b\n");
1535        let bytes_a = fs::read(&a).unwrap();
1536        let bytes_b = fs::read(&b).unwrap();
1537        let snap_a = whole_snapshot(&bytes_a);
1538        let snap_b = whole_snapshot(&bytes_b);
1539        let base_a = Baseline::from_bytes(bytes_a);
1540        let base_b = Baseline::from_bytes(bytes_b);
1541        let ops_a = vec![put_text("1", &["A"])];
1542        let ops_b = vec![put_text("1", &["B"])];
1543        let res_a = vec![resolve_one(&snap_a, &ops_a[0])];
1544        let res_b = vec![resolve_one(&snap_b, &ops_b[0])];
1545        let sections = [
1546            section_put(&a, "a.txt", &base_a, &snap_a, &ops_a, &res_a),
1547            section_put(&b, "b.txt", &base_b, &snap_b, &ops_b, &res_b),
1548        ];
1549        let registers = RegisterStore::new();
1550        let plan = plan_transaction(&sections, &registers, true).unwrap();
1551
1552        let mut backups = backup_store(&temp.path().join("backups"));
1553        let mut snapshots = SnapshotStore::new();
1554        let mut session_regs = RegisterStore::new();
1555        let mut exec = ctx(
1556            &mut backups,
1557            &mut snapshots,
1558            &mut session_regs,
1559            true,
1560            Some(ExecuteFault::Write { step: 1 }),
1561        );
1562        let envelope = execute_transaction(plan, &mut exec);
1563        assert!(envelope.success);
1564        assert!(!envelope.complete);
1565        assert!(envelope.op_id.is_some());
1566        assert_eq!(
1567            envelope.files[0].classification,
1568            FileClassification::Applied
1569        );
1570        assert_eq!(
1571            envelope.files[1].classification,
1572            FileClassification::FailedWrite
1573        );
1574        assert_eq!(
1575            envelope.files[1].mutation_state,
1576            MutationState::UnknownPossiblyMutated
1577        );
1578        assert_eq!(fs::read(&a).unwrap(), b"A\n");
1579        assert_eq!(fs::read(&b).unwrap(), b"b\n");
1580
1581        let op_id = envelope.op_id.unwrap();
1582        let restored = backups.restore_last_operation(SESSION).unwrap();
1583        assert_eq!(restored.op_id, op_id);
1584        assert_eq!(fs::read(&a).unwrap(), b"a\n");
1585    }
1586
1587    /// A8: MV destination durability before source unlink; both destination shapes.
1588    #[test]
1589    fn a8_mv_new_and_existing_destination_with_undo() {
1590        let temp = tempfile::tempdir().unwrap();
1591        let src = temp.path().join("src.txt");
1592        let new_dest = temp.path().join("new_dest.txt");
1593        write_file(&src, b"move-me\n");
1594        let bytes = fs::read(&src).unwrap();
1595        let snap = whole_snapshot(&bytes);
1596        let base = Baseline::from_bytes(bytes.clone());
1597        let ops = vec![Operation::Mv(MvOperation {
1598            destination: "new_dest.txt".into(),
1599            line: 1,
1600        })];
1601        // MV has no address; resolved slot is WholeFile.
1602        let resolved = vec![ResolvedOperation {
1603            operation_index: 0,
1604            address: ResolvedAddress::WholeFile,
1605        }];
1606        let sections = [TransactionSectionInput {
1607            canonical_path: &src,
1608            requested_path: "src.txt",
1609            baseline: &base,
1610            snapshot: &snap,
1611            operations: &ops,
1612            resolved: &resolved,
1613            mv_destination: Some(MvDestinationInput {
1614                canonical_path: &new_dest,
1615                requested_path: "new_dest.txt",
1616                baseline_bytes: None,
1617            }),
1618        }];
1619        let registers = RegisterStore::new();
1620        let plan = plan_transaction(&sections, &registers, true).unwrap();
1621        let mut backups = backup_store(&temp.path().join("backups"));
1622        let mut snapshots = SnapshotStore::new();
1623        // Seed a source snapshot so invalidation is observable.
1624        snapshots.publish(&src, snap.clone());
1625        let mut session_regs = RegisterStore::new();
1626        let mut exec = ctx(&mut backups, &mut snapshots, &mut session_regs, true, None);
1627        let envelope = execute_transaction(plan, &mut exec);
1628        assert!(envelope.success && envelope.complete);
1629        assert!(envelope.op_id.is_some());
1630        assert_eq!(envelope.files[0].role, FileRole::MvDestination);
1631        assert_eq!(envelope.files[1].role, FileRole::MvSource);
1632        assert!(envelope.files[0].final_tag.is_some());
1633        assert!(envelope.files[1].remove_file);
1634        assert_eq!(fs::read(&new_dest).unwrap(), b"move-me\n");
1635        assert!(!src.exists());
1636        // Source snapshots cleared without eviction history.
1637        assert!(snapshots.lookup(&src, &snap.tag).is_err());
1638
1639        let op_id = envelope.op_id.unwrap();
1640        let restored = backups.restore_last_operation(SESSION).unwrap();
1641        assert_eq!(restored.op_id, op_id);
1642        assert_eq!(fs::read(&src).unwrap(), b"move-me\n");
1643        assert!(!new_dest.exists(), "created destination removed on undo");
1644
1645        // Existing destination shape.
1646        let src2 = temp.path().join("src2.txt");
1647        let dest2 = temp.path().join("dest2.txt");
1648        write_file(&src2, b"from\n");
1649        write_file(&dest2, b"old-dest\n");
1650        let bytes2 = fs::read(&src2).unwrap();
1651        let snap2 = whole_snapshot(&bytes2);
1652        let base2 = Baseline::from_bytes(bytes2);
1653        let dest_bytes = fs::read(&dest2).unwrap();
1654        let ops2 = vec![Operation::Mv(MvOperation {
1655            destination: "dest2.txt".into(),
1656            line: 1,
1657        })];
1658        let resolved2 = vec![ResolvedOperation {
1659            operation_index: 0,
1660            address: ResolvedAddress::WholeFile,
1661        }];
1662        let sections2 = [TransactionSectionInput {
1663            canonical_path: &src2,
1664            requested_path: "src2.txt",
1665            baseline: &base2,
1666            snapshot: &snap2,
1667            operations: &ops2,
1668            resolved: &resolved2,
1669            mv_destination: Some(MvDestinationInput {
1670                canonical_path: &dest2,
1671                requested_path: "dest2.txt",
1672                baseline_bytes: Some(&dest_bytes),
1673            }),
1674        }];
1675        let plan2 = plan_transaction(&sections2, &registers, true).unwrap();
1676        let mut exec2 = ctx(&mut backups, &mut snapshots, &mut session_regs, true, None);
1677        let envelope2 = execute_transaction(plan2, &mut exec2);
1678        assert!(envelope2.success);
1679        assert_eq!(fs::read(&dest2).unwrap(), b"from\n");
1680        assert!(!src2.exists());
1681        let restored2 = backups.restore_last_operation(SESSION).unwrap();
1682        assert_eq!(restored2.op_id, envelope2.op_id.unwrap());
1683        assert_eq!(fs::read(&src2).unwrap(), b"from\n");
1684        assert_eq!(fs::read(&dest2).unwrap(), b"old-dest\n");
1685    }
1686
1687    /// A8: failed source unlink leaves destination applied under shared op_id.
1688    #[test]
1689    fn a8_mv_source_unlink_failure_is_partial_mv() {
1690        let temp = tempfile::tempdir().unwrap();
1691        let src = temp.path().join("src.txt");
1692        let dest = temp.path().join("dest.txt");
1693        write_file(&src, b"body\n");
1694        let bytes = fs::read(&src).unwrap();
1695        let snap = whole_snapshot(&bytes);
1696        let base = Baseline::from_bytes(bytes);
1697        let ops = vec![Operation::Mv(MvOperation {
1698            destination: "dest.txt".into(),
1699            line: 1,
1700        })];
1701        let resolved = vec![ResolvedOperation {
1702            operation_index: 0,
1703            address: ResolvedAddress::WholeFile,
1704        }];
1705        let sections = [TransactionSectionInput {
1706            canonical_path: &src,
1707            requested_path: "src.txt",
1708            baseline: &base,
1709            snapshot: &snap,
1710            operations: &ops,
1711            resolved: &resolved,
1712            mv_destination: Some(MvDestinationInput {
1713                canonical_path: &dest,
1714                requested_path: "dest.txt",
1715                baseline_bytes: None,
1716            }),
1717        }];
1718        let registers = RegisterStore::new();
1719        let plan = plan_transaction(&sections, &registers, true).unwrap();
1720        let mut backups = backup_store(&temp.path().join("backups"));
1721        let mut snapshots = SnapshotStore::new();
1722        let mut session_regs = RegisterStore::new();
1723        let mut exec = ctx(
1724            &mut backups,
1725            &mut snapshots,
1726            &mut session_regs,
1727            true,
1728            Some(ExecuteFault::SourceUnlink { step: 0 }),
1729        );
1730        let envelope = execute_transaction(plan, &mut exec);
1731        assert!(envelope.success);
1732        assert!(!envelope.complete);
1733        assert!(envelope.op_id.is_some());
1734        assert_eq!(
1735            envelope.files[0].classification,
1736            FileClassification::Applied
1737        );
1738        assert_eq!(
1739            envelope.files[1].classification,
1740            FileClassification::FailedSourceUnlink
1741        );
1742        assert_eq!(envelope.files[1].mutation_state, MutationState::PartialMv);
1743        assert_eq!(fs::read(&dest).unwrap(), b"body\n");
1744        assert!(src.exists(), "source remains after unlink failure");
1745    }
1746
1747    /// A8: registers commit only when every planned primary file is applied*.
1748    #[test]
1749    fn a8_register_commit_only_when_all_applied() {
1750        let temp = tempfile::tempdir().unwrap();
1751        let a = temp.path().join("a.txt");
1752        write_file(&a, b"one\ntwo\n");
1753        let bytes = fs::read(&a).unwrap();
1754        let snap = whole_snapshot(&bytes);
1755        let base = Baseline::from_bytes(bytes);
1756        let ops = vec![Operation::Cut(crate::hashline::syntax::CutOperation {
1757            address: parse_address("1").unwrap(),
1758            register: Some(RegisterRef::Named("clip".into())),
1759            line: 1,
1760        })];
1761        let resolved = vec![resolve_one(&snap, &ops[0])];
1762        let sections = [section_put(&a, "a.txt", &base, &snap, &ops, &resolved)];
1763        let registers = RegisterStore::new();
1764        let plan = plan_transaction(&sections, &registers, true).unwrap();
1765
1766        let mut backups = backup_store(&temp.path().join("backups"));
1767        let mut snapshots = SnapshotStore::new();
1768        let mut session_regs = RegisterStore::new();
1769        let mut exec = ctx(&mut backups, &mut snapshots, &mut session_regs, true, None);
1770        let envelope = execute_transaction(plan, &mut exec);
1771        assert!(envelope.registers_committed);
1772        assert_eq!(
1773            session_regs.get(&RegisterRef::Named("clip".into())),
1774            Some(["one".to_string()].as_slice())
1775        );
1776
1777        // Failure path discards staged captures.
1778        write_file(&a, b"one\ntwo\n");
1779        let plan2 = plan_transaction(&sections, &RegisterStore::new(), true).unwrap();
1780        let mut session_regs2 = RegisterStore::new();
1781        let mut exec2 = ctx(
1782            &mut backups,
1783            &mut snapshots,
1784            &mut session_regs2,
1785            true,
1786            Some(ExecuteFault::Write { step: 0 }),
1787        );
1788        let envelope2 = execute_transaction(plan2, &mut exec2);
1789        assert!(!envelope2.registers_committed);
1790        assert!(session_regs2
1791            .get(&RegisterRef::Named("clip".into()))
1792            .is_none());
1793    }
1794
1795    /// A8: applied_with_validation_failure and applied_tag_unavailable.
1796    #[test]
1797    fn a8_applied_star_variants() {
1798        let temp = tempfile::tempdir().unwrap();
1799        let path = temp.path().join("v.txt");
1800        write_file(&path, b"x\n");
1801        let bytes = fs::read(&path).unwrap();
1802        let snap = whole_snapshot(&bytes);
1803        let base = Baseline::from_bytes(bytes);
1804        let ops = vec![put_text("1", &["Y"])];
1805        let resolved = vec![resolve_one(&snap, &ops[0])];
1806        let sections = [section_put(&path, "v.txt", &base, &snap, &ops, &resolved)];
1807        let registers = RegisterStore::new();
1808
1809        let mut backups = backup_store(&temp.path().join("backups"));
1810        let mut snapshots = SnapshotStore::new();
1811        let mut session_regs = RegisterStore::new();
1812        let plan = plan_transaction(&sections, &registers, true).unwrap();
1813        let mut exec = ctx(
1814            &mut backups,
1815            &mut snapshots,
1816            &mut session_regs,
1817            true,
1818            Some(ExecuteFault::ValidationFailure { step: 0 }),
1819        );
1820        let envelope = execute_transaction(plan, &mut exec);
1821        assert_eq!(
1822            envelope.files[0].classification,
1823            FileClassification::AppliedWithValidationFailure
1824        );
1825        assert_eq!(fs::read(&path).unwrap(), b"Y\n");
1826
1827        write_file(&path, b"x\n");
1828        let bytes = fs::read(&path).unwrap();
1829        let snap = whole_snapshot(&bytes);
1830        let base = Baseline::from_bytes(bytes);
1831        let sections = [section_put(&path, "v.txt", &base, &snap, &ops, &resolved)];
1832        let plan = plan_transaction(&sections, &registers, true).unwrap();
1833        let mut exec = ctx(
1834            &mut backups,
1835            &mut snapshots,
1836            &mut session_regs,
1837            true,
1838            Some(ExecuteFault::FinalTagUnavailable { step: 0 }),
1839        );
1840        let envelope = execute_transaction(plan, &mut exec);
1841        assert_eq!(
1842            envelope.files[0].classification,
1843            FileClassification::AppliedTagUnavailable
1844        );
1845        assert!(envelope.files[0].final_tag.is_none());
1846        assert!(envelope.files[0].tag_notice.is_some());
1847    }
1848
1849    /// A10: preview mutates nothing — files, snapshots, backups, registers, op_id.
1850    #[test]
1851    fn a10_preview_mutates_nothing() {
1852        let temp = tempfile::tempdir().unwrap();
1853        let path = temp.path().join("p.txt");
1854        let dest = temp.path().join("p-dest.txt");
1855        write_file(&path, b"preview\n");
1856        let bytes = fs::read(&path).unwrap();
1857        let snap = whole_snapshot(&bytes);
1858        let base = Baseline::from_bytes(bytes.clone());
1859        let ops = vec![
1860            put_text("1", &["PREVIEWED"]),
1861            Operation::Mv(MvOperation {
1862                destination: "p-dest.txt".into(),
1863                line: 2,
1864            }),
1865        ];
1866        let resolved = vec![
1867            resolve_one(&snap, &ops[0]),
1868            ResolvedOperation {
1869                operation_index: 1,
1870                address: ResolvedAddress::WholeFile,
1871            },
1872        ];
1873        let sections = [TransactionSectionInput {
1874            canonical_path: &path,
1875            requested_path: "p.txt",
1876            baseline: &base,
1877            snapshot: &snap,
1878            operations: &ops,
1879            resolved: &resolved,
1880            mv_destination: Some(MvDestinationInput {
1881                canonical_path: &dest,
1882                requested_path: "p-dest.txt",
1883                baseline_bytes: None,
1884            }),
1885        }];
1886        let mut registers = RegisterStore::new();
1887        // Seed a register so we can prove preview does not commit staged captures.
1888        {
1889            let mut staged = registers.stage();
1890            staged
1891                .capture(RegisterRef::Named("keep".into()), vec!["seed".into()])
1892                .unwrap();
1893            registers.commit(staged);
1894        }
1895        let plan = plan_transaction(&sections, &registers, true).unwrap();
1896        let before_reg = registers
1897            .get(&RegisterRef::Named("keep".into()))
1898            .map(|lines| lines.to_vec());
1899
1900        let backups = backup_store(&temp.path().join("backups"));
1901        let tracked_before = backups.tracked_files(SESSION);
1902        let mut snapshots = SnapshotStore::new();
1903        snapshots.publish(&path, snap.clone());
1904        let envelope = preview_transaction(plan);
1905
1906        assert!(envelope.preview);
1907        assert!(envelope.op_id.is_none());
1908        assert!(!envelope.registers_committed);
1909        assert_eq!(fs::read(&path).unwrap(), b"preview\n");
1910        assert!(!dest.exists());
1911        assert_eq!(backups.tracked_files(SESSION), tracked_before);
1912        assert!(snapshots.lookup(&path, &snap.tag).is_ok());
1913        assert_eq!(
1914            registers
1915                .get(&RegisterRef::Named("keep".into()))
1916                .map(|lines| lines.to_vec()),
1917            before_reg
1918        );
1919        assert!(envelope.files.iter().all(|f| f.final_tag.is_none()));
1920        assert!(envelope
1921            .files
1922            .iter()
1923            .all(|f| f.mutation_state == MutationState::Unmutated));
1924    }
1925
1926    /// A12: external writer between Phase 1 and Phase 2 write → baseline drift.
1927    #[test]
1928    fn a12_baseline_drift_stops_later_files_and_keeps_prior_op_id() {
1929        let temp = tempfile::tempdir().unwrap();
1930        let a = temp.path().join("a.txt");
1931        let b = temp.path().join("b.txt");
1932        write_file(&a, b"a0\n");
1933        write_file(&b, b"b0\n");
1934        let bytes_a = fs::read(&a).unwrap();
1935        let bytes_b = fs::read(&b).unwrap();
1936        let snap_a = whole_snapshot(&bytes_a);
1937        let snap_b = whole_snapshot(&bytes_b);
1938        let base_a = Baseline::from_bytes(bytes_a);
1939        let base_b = Baseline::from_bytes(bytes_b);
1940        let ops_a = vec![put_text("1", &["A1"])];
1941        let ops_b = vec![put_text("1", &["B1"])];
1942        let res_a = vec![resolve_one(&snap_a, &ops_a[0])];
1943        let res_b = vec![resolve_one(&snap_b, &ops_b[0])];
1944        let sections = [
1945            section_put(&a, "a.txt", &base_a, &snap_a, &ops_a, &res_a),
1946            section_put(&b, "b.txt", &base_b, &snap_b, &ops_b, &res_b),
1947        ];
1948        let registers = RegisterStore::new();
1949        let plan = plan_transaction(&sections, &registers, true).unwrap();
1950
1951        // External writer mutates b after Phase 1.
1952        write_file(&b, b"b-EXTERNAL\n");
1953
1954        let mut backups = backup_store(&temp.path().join("backups"));
1955        let mut snapshots = SnapshotStore::new();
1956        let mut session_regs = RegisterStore::new();
1957        let mut exec = ctx(&mut backups, &mut snapshots, &mut session_regs, true, None);
1958        let envelope = execute_transaction(plan, &mut exec);
1959
1960        assert!(envelope.success);
1961        assert!(!envelope.complete);
1962        assert_eq!(
1963            envelope.files[0].classification,
1964            FileClassification::Applied
1965        );
1966        assert_eq!(
1967            envelope.files[1].classification,
1968            FileClassification::FailedBaselineDrift
1969        );
1970        assert_eq!(envelope.files[1].mutation_state, MutationState::Unmutated);
1971        assert_eq!(envelope.stop_reason, Some("hashline_baseline_drift"));
1972        assert!(envelope.op_id.is_some());
1973        assert_eq!(fs::read(&a).unwrap(), b"A1\n");
1974        assert_eq!(fs::read(&b).unwrap(), b"b-EXTERNAL\n");
1975
1976        let op_id = envelope.op_id.unwrap();
1977        let restored = backups.restore_last_operation(SESSION).unwrap();
1978        assert_eq!(restored.op_id, op_id);
1979        assert_eq!(fs::read(&a).unwrap(), b"a0\n");
1980    }
1981
1982    /// A17: backups disabled refuses PUT and MV-onto-existing; new-dest MV plans.
1983    #[test]
1984    fn a17_backup_unavailable_refusals_and_new_dest_mv() {
1985        let temp = tempfile::tempdir().unwrap();
1986        let path = temp.path().join("t.txt");
1987        write_file(&path, b"t\n");
1988        let bytes = fs::read(&path).unwrap();
1989        let snap = whole_snapshot(&bytes);
1990        let base = Baseline::from_bytes(bytes);
1991        let ops = vec![put_text("1", &["T"])];
1992        let resolved = vec![resolve_one(&snap, &ops[0])];
1993        let sections = [section_put(&path, "t.txt", &base, &snap, &ops, &resolved)];
1994        let registers = RegisterStore::new();
1995        let err = plan_transaction(&sections, &registers, false).unwrap_err();
1996        assert_eq!(
1997            err.code,
1998            crate::hashline::syntax::HashlineRejectionCode::BackupUnavailable
1999        );
2000        assert_eq!(err.stage, crate::hashline::syntax::RejectionStage::Baseline);
2001        assert_eq!(fs::read(&path).unwrap(), b"t\n");
2002
2003        // MV onto existing destination refused.
2004        let src = temp.path().join("s.txt");
2005        let dest = temp.path().join("d.txt");
2006        write_file(&src, b"s\n");
2007        write_file(&dest, b"d\n");
2008        let s_bytes = fs::read(&src).unwrap();
2009        let d_bytes = fs::read(&dest).unwrap();
2010        let s_snap = whole_snapshot(&s_bytes);
2011        let s_base = Baseline::from_bytes(s_bytes);
2012        let mv_ops = vec![Operation::Mv(MvOperation {
2013            destination: "d.txt".into(),
2014            line: 1,
2015        })];
2016        let mv_resolved = vec![ResolvedOperation {
2017            operation_index: 0,
2018            address: ResolvedAddress::WholeFile,
2019        }];
2020        let mv_sections = [TransactionSectionInput {
2021            canonical_path: &src,
2022            requested_path: "s.txt",
2023            baseline: &s_base,
2024            snapshot: &s_snap,
2025            operations: &mv_ops,
2026            resolved: &mv_resolved,
2027            mv_destination: Some(MvDestinationInput {
2028                canonical_path: &dest,
2029                requested_path: "d.txt",
2030                baseline_bytes: Some(&d_bytes),
2031            }),
2032        }];
2033        let err = plan_transaction(&mv_sections, &registers, false).unwrap_err();
2034        assert_eq!(
2035            err.code,
2036            crate::hashline::syntax::HashlineRejectionCode::BackupUnavailable
2037        );
2038        assert_eq!(fs::read(&src).unwrap(), b"s\n");
2039        assert_eq!(fs::read(&dest).unwrap(), b"d\n");
2040
2041        // New-destination MV is allowed in Phase 1 even when the backups flag is
2042        // false; execution with a live BackupStore still journals a real op_id.
2043        let src2 = temp.path().join("s2.txt");
2044        let dest2 = temp.path().join("d2.txt");
2045        write_file(&src2, b"s2\n");
2046        let s2_bytes = fs::read(&src2).unwrap();
2047        let s2_snap = whole_snapshot(&s2_bytes);
2048        let s2_base = Baseline::from_bytes(s2_bytes);
2049        let mv2_ops = vec![Operation::Mv(MvOperation {
2050            destination: "d2.txt".into(),
2051            line: 1,
2052        })];
2053        let mv2_resolved = vec![ResolvedOperation {
2054            operation_index: 0,
2055            address: ResolvedAddress::WholeFile,
2056        }];
2057        let mv2_sections = [TransactionSectionInput {
2058            canonical_path: &src2,
2059            requested_path: "s2.txt",
2060            baseline: &s2_base,
2061            snapshot: &s2_snap,
2062            operations: &mv2_ops,
2063            resolved: &mv2_resolved,
2064            mv_destination: Some(MvDestinationInput {
2065                canonical_path: &dest2,
2066                requested_path: "d2.txt",
2067                baseline_bytes: None,
2068            }),
2069        }];
2070        let plan = plan_transaction(&mv2_sections, &registers, false).expect("new dest MV plans");
2071        let mut backups = backup_store(&temp.path().join("backups"));
2072        let mut snapshots = SnapshotStore::new();
2073        let mut session_regs = RegisterStore::new();
2074        // Execution uses a real (enabled) store so the created-file tombstone and
2075        // source backup produce a genuine undo identity — never a fabricated one.
2076        let mut exec = ctx(&mut backups, &mut snapshots, &mut session_regs, false, None);
2077        let envelope = execute_transaction(plan, &mut exec);
2078        assert!(envelope.success);
2079        assert!(envelope.op_id.is_some(), "real journaled op_id required");
2080        assert_eq!(fs::read(&dest2).unwrap(), b"s2\n");
2081        assert!(!src2.exists());
2082        let op_id = envelope.op_id.unwrap();
2083        let restored = backups.restore_last_operation(SESSION).unwrap();
2084        assert_eq!(restored.op_id, op_id);
2085        assert_eq!(fs::read(&src2).unwrap(), b"s2\n");
2086        assert!(!dest2.exists());
2087
2088        // Disabled BackupStore must never advertise an op_id it did not journal.
2089        let mut disabled = BackupStore::new();
2090        disabled.set_policy(BackupPolicy {
2091            enabled: false,
2092            ..BackupPolicy::default()
2093        });
2094        write_file(&src2, b"s2\n");
2095        // Reuse the prior section coordinates; Phase 1 only needs the baseline
2096        // bytes that still match the restored source contents.
2097        let plan = plan_transaction(&mv2_sections, &registers, false).unwrap();
2098        let mut snapshots = SnapshotStore::new();
2099        let mut session_regs = RegisterStore::new();
2100        let mut exec = ctx(
2101            &mut disabled,
2102            &mut snapshots,
2103            &mut session_regs,
2104            false,
2105            None,
2106        );
2107        let envelope = execute_transaction(plan, &mut exec);
2108        assert!(!envelope.success);
2109        assert!(envelope.op_id.is_none());
2110        assert_eq!(
2111            envelope.files[0].classification,
2112            FileClassification::FailedBackup
2113        );
2114    }
2115
2116    /// Journal entry created before a later failure still yields op_id.
2117    #[test]
2118    fn op_id_present_when_journal_entry_exists_before_failure() {
2119        let temp = tempfile::tempdir().unwrap();
2120        let a = temp.path().join("a.txt");
2121        write_file(&a, b"a\n");
2122        let bytes = fs::read(&a).unwrap();
2123        let snap = whole_snapshot(&bytes);
2124        let base = Baseline::from_bytes(bytes);
2125        let ops = vec![put_text("1", &["A"])];
2126        let resolved = vec![resolve_one(&snap, &ops[0])];
2127        let sections = [section_put(&a, "a.txt", &base, &snap, &ops, &resolved)];
2128        let registers = RegisterStore::new();
2129        let plan = plan_transaction(&sections, &registers, true).unwrap();
2130        let mut backups = backup_store(&temp.path().join("backups"));
2131        let mut snapshots = SnapshotStore::new();
2132        let mut session_regs = RegisterStore::new();
2133        // Drift after journal: backup succeeds, write never happens, op_id remains.
2134        // Force drift by mutating after plan; backup still runs first in execute.
2135        write_file(&a, b"changed\n");
2136        let mut exec = ctx(&mut backups, &mut snapshots, &mut session_regs, true, None);
2137        let envelope = execute_transaction(plan, &mut exec);
2138        assert!(!envelope.success);
2139        assert_eq!(
2140            envelope.files[0].classification,
2141            FileClassification::FailedBaselineDrift
2142        );
2143        // Backup is taken before baseline recheck, so op_id must be present.
2144        assert!(envelope.op_id.is_some());
2145        assert_eq!(fs::read(&a).unwrap(), b"changed\n");
2146    }
2147}