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