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