Skip to main content

prikk_store/
rollback_draft.rs

1//! Mutating rollback draft append for the supported patch subset.
2//!
3//! This module deliberately keeps rollback publication and worktree mutation out of scope. It
4//! validates the same supported inverse plan used by rollback preview, requires an empty active
5//! WAL, marks the inverse Patch payload with `PatchPurpose::RollbackDraft`, signs it with a real
6//! role-bound Ed25519 AUTHOR signer, and appends that Patch envelope to the active WAL under the
7//! active-session lock. The existing seal path is still responsible for publishing refs later.
8
9use prikk_error::{PrikkError, Result};
10use prikk_object::{
11    CanonicalEncode, ObjectEnvelope, ObjectId, ObjectType, PatchPurpose, RefStatePayload,
12};
13
14use crate::active::prepare_empty_active_ref_for_append;
15use crate::author_signing::{AuthorSigner, author_signature};
16use crate::layout::RepositoryLayout;
17use crate::lock::ActiveLock;
18use crate::object_store::{ObjectReadSnapshot, ObjectReader};
19use crate::patch_inverse::{PatchInverseOperationSummary, prepare_patch_inverse_plan};
20use crate::refs::RefStore;
21use crate::rollback_preview::{RollbackPreviewChange, prepare_rollback_preview};
22use crate::wal::Wal;
23use crate::{
24    ActiveRefMetadata, read_active_ref_metadata, remove_active_ref_metadata,
25    validate_local_branch_ref,
26};
27
28/// Return true when a Patch envelope carries the rollback-draft payload purpose.
29pub(crate) fn is_rollback_draft_envelope(envelope: &ObjectEnvelope) -> Result<bool> {
30    if envelope.object_type != ObjectType::Patch {
31        return Ok(false);
32    }
33    Ok(
34        PatchPurpose::decode_from_patch_payload(&envelope.canonical_payload)?
35            == PatchPurpose::RollbackDraft,
36    )
37}
38
39/// Result of appending a supported inverse Patch draft to the active WAL.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct RollbackDraftReport {
42    /// Ref used as the rollback-draft target.
43    pub ref_name: String,
44    /// Published block that the inverse Patch draft targets.
45    pub target_block_id: ObjectId,
46    /// Signed inverse Patch ID appended to the active WAL.
47    pub inverse_patch_id: ObjectId,
48    /// Real AUTHOR key id recorded in the rollback draft signature.
49    pub author_key_id: String,
50    /// WAL sequence assigned to the signed inverse Patch envelope.
51    pub wal_sequence: u64,
52    /// Number of blocks inspected while deriving the inverse Patch.
53    pub block_count: usize,
54    /// Number of patch objects inspected while deriving the inverse Patch.
55    pub patch_count: usize,
56    /// Number of inverse operations appended.
57    pub inverse_operation_count: usize,
58    /// Number of file-level preview changes compared with the latest snapshot baseline.
59    pub preview_change_count: usize,
60    /// Number of files rollback would create or restore.
61    pub would_create_files: usize,
62    /// Number of files rollback would delete.
63    pub would_delete_files: usize,
64    /// Number of files rollback would replace.
65    pub would_replace_files: usize,
66    /// Operation summaries in inverse Patch application order.
67    pub operations: Vec<PatchInverseOperationSummary>,
68    /// Preview changes reported before the inverse Patch was appended.
69    pub preview_changes: Vec<RollbackPreviewChange>,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73struct RollbackTargetTip {
74    ref_state_id: ObjectId,
75    target_block_id: ObjectId,
76}
77
78/// Append a signed inverse Patch draft to an empty active WAL.
79///
80/// This function is intentionally conservative: it refuses an empty message, unpublished refs,
81/// unsupported patch operations, partial WAL tails, and non-empty active WALs. It writes no object
82/// files, publishes no refs, and makes no worktree changes.
83pub fn append_rollback_draft(
84    layout: &RepositoryLayout,
85    ref_name: &str,
86    message: &str,
87    signer: &impl AuthorSigner,
88) -> Result<RollbackDraftReport> {
89    layout.require_current_format()?;
90    crate::refs::ensure_no_incomplete_publication(layout)?;
91    let canonical_ref = validate_local_branch_ref(ref_name)?;
92    if message.trim().is_empty() {
93        return Err(PrikkError::InvalidName(
94            "rollback draft message must not be empty".to_string(),
95        ));
96    }
97
98    let planned_tip = read_target_tip(layout, &canonical_ref)?;
99    let mut inverse = prepare_patch_inverse_plan(layout, &canonical_ref)?;
100    if inverse.inverse_operation_count == 0 {
101        return Err(PrikkError::InvalidName(
102            "rollback draft has no supported inverse operations to append".to_string(),
103        ));
104    }
105    if inverse.target_block_id != planned_tip.target_block_id {
106        return Err(PrikkError::Integrity(format!(
107            "rollback inverse target {} does not match current ref target {}",
108            inverse.target_block_id, planned_tip.target_block_id
109        )));
110    }
111    let preview = prepare_rollback_preview(layout, &canonical_ref)?;
112    if preview.target_block_id != inverse.target_block_id {
113        return Err(PrikkError::Integrity(format!(
114            "rollback preview target {} does not match inverse target {}",
115            preview.target_block_id, inverse.target_block_id
116        )));
117    }
118
119    inverse.inverse_payload.purpose = PatchPurpose::RollbackDraft;
120    let canonical_payload = inverse.inverse_payload.to_canonical_bytes()?;
121    let mut envelope = ObjectEnvelope::unsigned(ObjectType::Patch, 1, canonical_payload);
122    let signature = author_signature(signer, envelope.object_id())?;
123    envelope.add_signature(signature)?;
124    let inverse_patch_id = envelope.object_id();
125
126    let wal = Wal::for_layout(layout);
127    let active_lock = ActiveLock::acquire(layout)?;
128    crate::refs::ensure_no_incomplete_publication(layout)?;
129    let replay = wal.replay()?;
130    if replay.trailing_partial_bytes != 0 {
131        return Err(PrikkError::Integrity(format!(
132            "active WAL has {} trailing partial bytes; run doctor before rollback-draft",
133            replay.trailing_partial_bytes
134        )));
135    }
136    // RFC 102 Stage 2: `replay.records.is_empty()` below would read a WAL whose only record was
137    // damaged as genuinely empty, letting rollback-draft proceed against a WAL that is not empty.
138    if replay.has_item_failure() {
139        return Err(PrikkError::Integrity(
140            "active WAL has a damaged record; run doctor before rollback-draft".to_string(),
141        ));
142    }
143    // DC-66: deliberately unchanged. Composing a correct inverse against a queue's chained,
144    // not-yet-sealed baseline is an unaddressed correctness question this increment's acceptance
145    // criteria never ask it to answer — see
146    // `rfcs/handoffs/DC-66-multi-commit-queuing/queuing-baseline-design-v1.md` §5. A user who wants a
147    // rollback draft seals the queue first.
148    if !replay.records.is_empty() {
149        return Err(PrikkError::LockConflict(
150            "rollback-draft requires an empty active WAL".to_string(),
151        ));
152    }
153    let current_tip = read_target_tip(layout, &canonical_ref)?;
154    if current_tip != planned_tip {
155        return Err(PrikkError::LockConflict(
156            "rollback-draft target ref changed during planning; retry rollback-draft".to_string(),
157        ));
158    }
159    match read_active_ref_metadata(layout)? {
160        ActiveRefMetadata::Missing => {}
161        ActiveRefMetadata::Valid(_) | ActiveRefMetadata::Invalid(_) => {
162            remove_active_ref_metadata(layout)?;
163        }
164    }
165    prepare_empty_active_ref_for_append(layout, &canonical_ref)?;
166    // DC-53 Stage 2 C1: record this signer's key material under the held `ActiveLock`, immediately
167    // before the append it gates -- not while planning, before the lock existed. Stage 1's version of
168    // this call sat before lock acquisition; Stage 2's reject-on-conflict rule turns "check the
169    // container, then append" into a check-then-act that a concurrent rollback-draft (or a
170    // rollback-draft racing a commit) could otherwise both pass before either one appends,
171    // undetected, since a rejected conflict here is unrecoverable (no prune/rewrite path exists for
172    // this container). `worktree_patch/node_authoring.rs` already does this under its own lock; this
173    // matches that shape.
174    crate::author_key_index::record_author_key_material(
175        layout,
176        signer.key_id(),
177        signer.public_key_bytes(),
178        &active_lock,
179    )?;
180    let wal_sequence = wal.append_patch(&envelope)?;
181
182    Ok(RollbackDraftReport {
183        ref_name: canonical_ref,
184        target_block_id: inverse.target_block_id,
185        inverse_patch_id,
186        author_key_id: signer.key_id().to_string(),
187        wal_sequence,
188        block_count: inverse.block_count,
189        patch_count: inverse.patch_count,
190        inverse_operation_count: inverse.inverse_operation_count,
191        preview_change_count: preview.change_count,
192        would_create_files: preview.would_create_files,
193        would_delete_files: preview.would_delete_files,
194        would_replace_files: preview.would_replace_files,
195        operations: inverse.operations,
196        preview_changes: preview.changes,
197    })
198}
199
200fn read_target_tip(layout: &RepositoryLayout, ref_name: &str) -> Result<RollbackTargetTip> {
201    let ref_store = RefStore::new(layout.clone());
202    let ref_state_id = ref_store
203        .read_current_ref_state_id(ref_name)?
204        .ok_or_else(|| PrikkError::InvalidName(format!("ref {ref_name} is not published")))?;
205    // RFC 111 §6.1: `read_target_tip` never writes an object (this module's own doc: "writes no
206    // object files"), so it takes one decoded index snapshot here instead of a fresh decode.
207    let object_store = ObjectReadSnapshot::open(layout)?;
208    let envelope = object_store
209        .read_typed(ref_state_id, ObjectType::RefState)?
210        .ok_or_else(|| {
211            PrikkError::Integrity(format!(
212                "published ref {ref_name} points to missing RefState"
213            ))
214        })?;
215    let payload =
216        RefStatePayload::decode_canonical(&envelope.canonical_payload, envelope.schema_version)?;
217    if payload.ref_name != ref_name {
218        return Err(PrikkError::Integrity(format!(
219            "published RefState name mismatch: expected {ref_name}, got {}",
220            payload.ref_name
221        )));
222    }
223    Ok(RollbackTargetTip {
224        ref_state_id,
225        target_block_id: payload.target_object_id,
226    })
227}
228
229#[cfg(test)]
230mod tests;