Skip to main content

prikk_store/
merge_execute.rs

1//! Merge execution (DC-74/DC-75): seals the other side's patches verbatim onto the target ref when
2//! the two sides are proven confluent from a common baseline.
3//!
4//! **A merge authors nothing** — the adopted patches are the exact objects already sealed on the
5//! source ref, same canonical bytes, same `ObjectId`, same author signature; this module never
6//! decodes, re-derives, or re-signs a patch. Only the new `Block`, `RefState`, and `RefUpdate` are
7//! signed here, with the maintainer key, exactly as an ordinary `seal` signs them.
8//!
9//! Two-parent `BlockKind::Merge` blocks (DC-75): `parent_block_ids` names both `into_ref`'s prior
10//! tip (recorded again as `mainline_parent_id`) and `from_ref`'s adopted tip, sorted per the format's
11//! uniqueness invariant. `merge_baseline_block_id` records the baseline confluence was proven
12//! against — a claim `verify` independently re-derives and cross-checks rather than trusts. State
13//! derivation and replay follow the mainline parent only; the secondary parent's own chain is
14//! verified independently by the ordinary full-object-store scan. DC-74 sealed merges as
15//! indistinguishable `BlockKind::Normal` blocks; this is what discharges its release condition.
16
17use prikk_error::{PrikkError, Result};
18use prikk_object::{
19    BlockKind, BlockPayload, CanonicalEncode, ObjectEnvelope, ObjectId, ObjectType, RefKind,
20    RefStatePayload, RefUpdatePayload,
21};
22
23use crate::merge_evidence::{
24    MergeEvidenceTarget, candidate_patch_ids, prepare_merge_evidence,
25    verify_candidate_blocks_trusted,
26};
27use crate::received::validate_received_ref;
28use crate::{
29    MaintainerSigner, ObjectReader, ObjectWriteSession, ObjectWriter, RefPublication, RefStore,
30    RepositoryLayout, derive_next_state_root, maintainer_signature, validate_local_branch_ref,
31    verify_signer_trusted,
32};
33
34/// Result of a completed merge execution.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct MergeExecutionReport {
37    /// Ref advanced by the merge (the "into" side).
38    pub into_ref: String,
39    /// Ref merged in (the "from" side).
40    pub from_ref: String,
41    /// Baseline block confluence was proven against.
42    pub baseline_block_id: ObjectId,
43    /// `into_ref`'s target block immediately before the merge (the new block's mainline parent).
44    pub parent_block_id: ObjectId,
45    /// `from_ref`'s target block the adopted patches were collected up to.
46    pub adopted_target_block_id: ObjectId,
47    /// Patch IDs adopted verbatim, in sealed order.
48    pub adopted_patch_ids: Vec<ObjectId>,
49    /// New block ID.
50    pub block_id: ObjectId,
51    /// New RefState ID.
52    pub ref_state_id: ObjectId,
53}
54
55/// Execute a merge: seal `from_ref`'s patches since `baseline_block_id` verbatim onto `into_ref`.
56///
57/// Refuses cleanly with no object, WAL, or ref write of any kind unless the two sides are proven
58/// confluent from the given baseline — conflict detection is `patch_algebra`'s existing evidence
59/// machinery (the same analysis `merge-evidence`/`merge-plan` already report), reused rather than
60/// duplicated.
61pub fn execute_merge(
62    layout: &RepositoryLayout,
63    baseline_block_id: ObjectId,
64    into_ref: &str,
65    from_ref: &str,
66    signer: &impl MaintainerSigner,
67) -> Result<MergeExecutionReport> {
68    layout.require_current_format()?;
69    let into_ref = validate_local_branch_ref(into_ref)?;
70    // DC-85: `from_ref` may be a local branch or a received ref (`remotes/<name>`) — never widen
71    // `validate_local_branch_ref` itself to accept `remotes/`, since it also gates `into_ref` here
72    // and `branch create --from` elsewhere; a merge source that happens to be received gets its own
73    // resolution path instead (§3A.3). `into_ref` is never eligible: `RefStore::publish` only ever
74    // writes `refs/by-id/`, so the side being advanced must remain a genuine local branch.
75    let from_is_received = from_ref.starts_with("remotes/");
76    // Carry the local arm's own canonical string forward for the comparison below, rather than
77    // re-deriving `from_ref` from the raw argument a second time — today `validate_local_branch_ref`
78    // happens to return its input unchanged, so the two forms agree, but that is not guaranteed to
79    // stay true (`refs.rs`'s own comment on `validate_local_tag_ref` already records NFR-SEC-03's
80    // case-collision rule as unmet and tracked for later); comparing a validated name against an
81    // unvalidated one would silently stop catching `heads/Main` vs `heads/main` the day it lands.
82    let (from_target, from_ref) = if from_is_received {
83        validate_received_ref(from_ref)?;
84        (
85            MergeEvidenceTarget::ReceivedRef(from_ref.to_string()),
86            from_ref.to_string(),
87        )
88    } else {
89        let canonical = validate_local_branch_ref(from_ref)?;
90        (MergeEvidenceTarget::Ref(canonical.clone()), canonical)
91    };
92    if into_ref == from_ref {
93        return Err(PrikkError::InvalidName(
94            "merge into_ref and from_ref must differ".to_string(),
95        ));
96    }
97    // Read-only evidence gathering, requiring no signing credential at all — confluence is
98    // determinable exactly like `merge-plan`'s, before any question of who may seal is asked.
99    let evidence = prepare_merge_evidence(
100        layout,
101        baseline_block_id,
102        MergeEvidenceTarget::Ref(into_ref.clone()),
103        from_target,
104    )?;
105    if !evidence.is_confluent() {
106        return Err(PrikkError::Integrity(format!(
107            "merge refused: {from_ref} is not confluent with {into_ref} from baseline \
108             {baseline_block_id} (outcome: {}{})",
109            evidence.outcome,
110            evidence
111                .reason
112                .map(|reason| format!(", reason: {reason}"))
113                .unwrap_or_default(),
114        )));
115    }
116
117    // Only proceeding to seal needs a trusted signer.
118    let policy = verify_signer_trusted(layout, signer)?;
119
120    let mut object_store = ObjectWriteSession::open(layout)?;
121
122    // DC-85 §3A.1's mandatory criterion: a received ref's blocks never passed a trust check on the
123    // way in (`import_bundle` performs none, deliberately). Checked here, before any write, reusing
124    // the signer's already-loaded policy rather than a second load.
125    if from_is_received {
126        verify_candidate_blocks_trusted(
127            &object_store,
128            &policy,
129            baseline_block_id,
130            evidence.right_selector.target_block_id,
131        )?;
132    }
133
134    let adopted_patch_ids = candidate_patch_ids(
135        &object_store,
136        baseline_block_id,
137        evidence.right_selector.target_block_id,
138    )?;
139    if adopted_patch_ids.is_empty() {
140        return Err(PrikkError::Integrity(format!(
141            "{from_ref} has no patches to adopt since baseline {baseline_block_id}"
142        )));
143    }
144
145    let ref_store = RefStore::new(layout.clone());
146    let into_ref_state_id = ref_store
147        .read_current_ref_state_id(&into_ref)?
148        .ok_or_else(|| PrikkError::Integrity(format!("ref {into_ref} is not published")))?;
149    let into_ref_state_envelope = object_store
150        .read_typed(into_ref_state_id, ObjectType::RefState)?
151        .ok_or_else(|| {
152            PrikkError::Integrity(format!("ref {into_ref} points to missing RefState"))
153        })?;
154    let into_ref_state = RefStatePayload::decode_canonical(
155        &into_ref_state_envelope.canonical_payload,
156        into_ref_state_envelope.schema_version,
157    )?;
158    let parent_block_id = into_ref_state.target_object_id;
159    if parent_block_id != evidence.left_selector.target_block_id {
160        return Err(PrikkError::Integrity(format!(
161            "ref {into_ref} advanced during merge evidence gathering; retry"
162        )));
163    }
164
165    let state_merkle_root =
166        derive_next_state_root(&object_store, Some(parent_block_id), &adopted_patch_ids)?;
167    let adopted_target_block_id = evidence.right_selector.target_block_id;
168    let mut parent_block_ids = vec![parent_block_id, adopted_target_block_id];
169    parent_block_ids.sort();
170    let block_payload = BlockPayload {
171        parent_block_ids,
172        kind: BlockKind::Merge,
173        patch_ids: adopted_patch_ids.clone(),
174        state_merkle_root,
175        snapshot_blob_ref: None,
176        mainline_parent_id: Some(parent_block_id),
177        merge_baseline_block_id: Some(baseline_block_id),
178    };
179    let block_envelope = signed_envelope(
180        ObjectType::Block,
181        2,
182        block_payload.to_canonical_bytes()?,
183        signer,
184    )?;
185    let block_id = object_store.write_object(&block_envelope)?;
186
187    let update_seq = into_ref_state.update_seq + 1;
188    let ref_state_payload = RefStatePayload {
189        ref_name: into_ref.clone(),
190        kind: RefKind::Branch,
191        target_object_id: block_id,
192        update_seq,
193        previous_ref_state_id: Some(into_ref_state_id),
194        required_attestation_ids: Vec::new(),
195        closed: false,
196    };
197    let ref_state_envelope = signed_envelope(
198        ObjectType::RefState,
199        1,
200        ref_state_payload.to_canonical_bytes()?,
201        signer,
202    )?;
203    let ref_state_id = ref_state_envelope.object_id();
204
205    let ref_update_payload = RefUpdatePayload {
206        ref_name: into_ref.clone(),
207        old_ref_state_id: Some(into_ref_state_id),
208        new_ref_state_id: ref_state_id,
209        new_target_object_id: block_id,
210        update_seq,
211        created_at: 0,
212        author_key_id: signer.key_id().to_string(),
213    };
214    let ref_update_envelope = signed_envelope(
215        ObjectType::RefUpdate,
216        1,
217        ref_update_payload.to_canonical_bytes()?,
218        signer,
219    )?;
220
221    let publication = RefPublication {
222        ref_name: into_ref.clone(),
223        expected_previous_ref_state_id: Some(into_ref_state_id),
224        ref_state: ref_state_envelope,
225        ref_update: ref_update_envelope,
226    };
227    let published_ref_state_id =
228        ref_store.publish_with_object_store(&mut object_store, &publication)?;
229
230    Ok(MergeExecutionReport {
231        into_ref,
232        from_ref,
233        baseline_block_id,
234        parent_block_id,
235        adopted_target_block_id,
236        adopted_patch_ids,
237        block_id,
238        ref_state_id: published_ref_state_id,
239    })
240}
241
242fn signed_envelope(
243    object_type: ObjectType,
244    schema_version: u32,
245    canonical_payload: Vec<u8>,
246    signer: &impl MaintainerSigner,
247) -> Result<ObjectEnvelope> {
248    let mut envelope = ObjectEnvelope::unsigned(object_type, schema_version, canonical_payload);
249    let object_id = envelope.object_id();
250    envelope.add_signature(maintainer_signature(signer, object_type, object_id)?)?;
251    Ok(envelope)
252}
253
254#[cfg(test)]
255mod tests;