484 lines
17 KiB
Rust
484 lines
17 KiB
Rust
//! Conflict records and the content-derived [`ConflictId`] (Chapter 6
|
|
//! §"Conflict Records").
|
|
//!
|
|
//! When an operation cannot apply cleanly, the reduction produces a
|
|
//! `Conflicted` effect referencing a [`ConflictRecord`] in the score's conflict
|
|
//! registry. Conflict records are first-class, *canonical* graph objects:
|
|
//! stable, addressable, visible to users, and resolvable by a later
|
|
//! `ResolveConflict` operation.
|
|
//!
|
|
//! Because conflict records are produced during deterministic reduction rather
|
|
//! than authored by replicas, their identifiers MUST be **content-derived**:
|
|
//! two replicas reducing the same operation set produce the same conflicts with
|
|
//! the same ids ([`derive_conflict_id`]). An ordinal or local counter in the
|
|
//! preimage is forbidden — conflicts that share kind, causing operations, and
|
|
//! affected objects are by definition the same conflict (Chapter 6 §6.4.3).
|
|
//!
|
|
//! Integrity *anomalies* ([`crate::IntegrityAnomaly`]) are deliberately a
|
|
//! separate type, not a `ConflictKind`: a conflict is an ordinary
|
|
//! canonical-state fact (two valid edits collide); an anomaly is a structural
|
|
//! failure that takes the document out of ordinary canonical operation
|
|
//! (Chapter 6 / Chapter 5 §"System-Derived Counter Collisions"; Pass 10).
|
|
|
|
use epiphany_core::{OperationId, RegionId, TransactionId, TypedObjectId};
|
|
use epiphany_determinism::{
|
|
sorted_canonical, CanonicalByteOrder, CanonicalEncode, DomainTag, Preimage,
|
|
};
|
|
|
|
use crate::encode::{push_canon, push_seq, push_str, push_tag};
|
|
use crate::support::{ConflictKindRegistryId, ResolutionRegistryId};
|
|
|
|
/// A content-derived conflict identifier: `trunc128(BLAKE3("MUSCCONF" || …))`
|
|
/// (Chapter 6 §6.4.3). 128-bit, canonical 16-byte big-endian form, ordered
|
|
/// byte-lexicographically — the order the conflict registry iterates in
|
|
/// (Appendix D §"Ordered Iteration": "Conflict records: ascending by
|
|
/// ConflictId").
|
|
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Debug)]
|
|
pub struct ConflictId(pub u128);
|
|
|
|
impl ConflictId {
|
|
/// The canonical 16-byte big-endian form.
|
|
#[inline]
|
|
pub const fn canonical_bytes(self) -> [u8; 16] {
|
|
self.0.to_be_bytes()
|
|
}
|
|
}
|
|
|
|
impl CanonicalEncode for ConflictId {
|
|
#[inline]
|
|
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
|
out.extend_from_slice(&self.canonical_bytes());
|
|
}
|
|
}
|
|
impl CanonicalByteOrder for ConflictId {}
|
|
|
|
/// A path to a specific field, naming what two concurrent operations collided
|
|
/// on in a [`ConflictKind::StructuralFieldCollision`]. Canonical text
|
|
/// (Appendix D §"Text and Unicode").
|
|
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default)]
|
|
pub struct FieldPath(pub String);
|
|
|
|
impl CanonicalEncode for FieldPath {
|
|
#[inline]
|
|
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
|
push_str(out, &self.0);
|
|
}
|
|
}
|
|
|
|
/// The kind of conflict, governing the resolution options (Chapter 6 §6.4.1).
|
|
///
|
|
/// The canonical byte form (via [`CanonicalEncode`]) includes the kind
|
|
/// discriminant and **all** payload, so distinct conflicts have distinct
|
|
/// preimages by construction (Chapter 6 §6.4.3 requirement).
|
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
pub enum ConflictKind {
|
|
/// Two concurrent operations wrote the same non-LWW field. The winner's
|
|
/// effect is materialized; the loser's is preserved here for inspection.
|
|
StructuralFieldCollision {
|
|
winner: OperationId,
|
|
loser: OperationId,
|
|
field: FieldPath,
|
|
},
|
|
/// A transaction's members were partially applicable; the whole
|
|
/// transaction was rejected.
|
|
TransactionConflict {
|
|
transaction: TransactionId,
|
|
failed_members: Vec<OperationId>,
|
|
},
|
|
/// An operation targeted a tombstoned object the kind's rules could not
|
|
/// repair.
|
|
TombstonedTarget {
|
|
target: TypedObjectId,
|
|
operation: OperationId,
|
|
},
|
|
/// Re-anchoring failed: no deterministic target could be found.
|
|
ReanchorFailure {
|
|
original_referent: TypedObjectId,
|
|
referencing_object: TypedObjectId,
|
|
},
|
|
/// A region time-model migration produced contained events whose
|
|
/// coordinate kinds are incompatible with the new model.
|
|
TimeModelMigrationFailure {
|
|
region: RegionId,
|
|
incompatible_events: Vec<TypedObjectId>,
|
|
},
|
|
/// A registered extension operation's reduction failed; opaque to the core.
|
|
ExtensionConflict {
|
|
kind_id: ConflictKindRegistryId,
|
|
details: Vec<u8>,
|
|
},
|
|
}
|
|
|
|
impl ConflictKind {
|
|
/// The discriminant byte; part of the canonical preimage.
|
|
fn discriminant(&self) -> u8 {
|
|
match self {
|
|
ConflictKind::StructuralFieldCollision { .. } => 0,
|
|
ConflictKind::TransactionConflict { .. } => 1,
|
|
ConflictKind::TombstonedTarget { .. } => 2,
|
|
ConflictKind::ReanchorFailure { .. } => 3,
|
|
ConflictKind::TimeModelMigrationFailure { .. } => 4,
|
|
ConflictKind::ExtensionConflict { .. } => 5,
|
|
}
|
|
}
|
|
|
|
/// The canonical byte form of the kind, as required by
|
|
/// [`derive_conflict_id`] (Chapter 6 §6.4.3).
|
|
pub fn canonical_bytes(&self) -> Vec<u8> {
|
|
self.to_canonical_bytes()
|
|
}
|
|
}
|
|
|
|
impl CanonicalEncode for ConflictKind {
|
|
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
|
push_tag(out, self.discriminant());
|
|
match self {
|
|
ConflictKind::StructuralFieldCollision {
|
|
winner,
|
|
loser,
|
|
field,
|
|
} => {
|
|
push_canon(out, winner);
|
|
push_canon(out, loser);
|
|
push_canon(out, field);
|
|
}
|
|
ConflictKind::TransactionConflict {
|
|
transaction,
|
|
failed_members,
|
|
} => {
|
|
push_canon(out, transaction);
|
|
// Failed members in canonical order so the preimage is stable.
|
|
push_seq(out, &sorted_canonical(failed_members.clone()));
|
|
}
|
|
ConflictKind::TombstonedTarget { target, operation } => {
|
|
push_canon(out, target);
|
|
push_canon(out, operation);
|
|
}
|
|
ConflictKind::ReanchorFailure {
|
|
original_referent,
|
|
referencing_object,
|
|
} => {
|
|
push_canon(out, original_referent);
|
|
push_canon(out, referencing_object);
|
|
}
|
|
ConflictKind::TimeModelMigrationFailure {
|
|
region,
|
|
incompatible_events,
|
|
} => {
|
|
push_canon(out, region);
|
|
push_seq(out, &sorted_canonical(incompatible_events.clone()));
|
|
}
|
|
ConflictKind::ExtensionConflict { kind_id, details } => {
|
|
push_canon(out, kind_id);
|
|
crate::encode::push_lp_bytes(out, details);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// How a conflict was resolved (Chapter 6 §6.4.1).
|
|
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
|
pub enum ResolutionAction {
|
|
/// Accept the losing operation's effect (replacing the winner).
|
|
AcceptLoser,
|
|
/// Reapply the winner explicitly (clears the conflict, no semantic change).
|
|
KeepWinner,
|
|
/// A user-authored replacement that supersedes both.
|
|
Override { override_operation: OperationId },
|
|
/// Re-anchor to a user-chosen target.
|
|
Reanchor { new_target: TypedObjectId },
|
|
/// Dismiss the conflict without changing the materialized graph: the user
|
|
/// acknowledges it and accepts the current (winner) state. Selects the
|
|
/// `Dismissed` resolution state (Pass 11, item 2.5; Chapter 6).
|
|
Dismiss,
|
|
/// Custom resolution for a registered conflict kind.
|
|
Registered(ResolutionRegistryId),
|
|
}
|
|
|
|
impl ResolutionAction {
|
|
fn discriminant(&self) -> u8 {
|
|
match self {
|
|
ResolutionAction::AcceptLoser => 0,
|
|
ResolutionAction::KeepWinner => 1,
|
|
ResolutionAction::Override { .. } => 2,
|
|
ResolutionAction::Reanchor { .. } => 3,
|
|
ResolutionAction::Dismiss => 4,
|
|
ResolutionAction::Registered(_) => 5,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl CanonicalEncode for ResolutionAction {
|
|
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
|
push_tag(out, self.discriminant());
|
|
match self {
|
|
ResolutionAction::AcceptLoser
|
|
| ResolutionAction::KeepWinner
|
|
| ResolutionAction::Dismiss => {}
|
|
ResolutionAction::Override { override_operation } => {
|
|
push_canon(out, override_operation)
|
|
}
|
|
ResolutionAction::Reanchor { new_target } => push_canon(out, new_target),
|
|
ResolutionAction::Registered(id) => push_canon(out, id),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The current resolution state of a conflict (Chapter 6 §6.4.1). Conflicts
|
|
/// begin `Unresolved`; a later `ResolveConflict` operation transitions them.
|
|
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
|
pub enum ConflictResolutionState {
|
|
Unresolved,
|
|
Resolved {
|
|
by: OperationId,
|
|
action: ResolutionAction,
|
|
},
|
|
Dismissed {
|
|
by: OperationId,
|
|
},
|
|
}
|
|
|
|
impl ConflictResolutionState {
|
|
fn discriminant(&self) -> u8 {
|
|
match self {
|
|
ConflictResolutionState::Unresolved => 0,
|
|
ConflictResolutionState::Resolved { .. } => 1,
|
|
ConflictResolutionState::Dismissed { .. } => 2,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl CanonicalEncode for ConflictResolutionState {
|
|
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
|
push_tag(out, self.discriminant());
|
|
match self {
|
|
ConflictResolutionState::Unresolved => {}
|
|
ConflictResolutionState::Resolved { by, action } => {
|
|
push_canon(out, by);
|
|
push_canon(out, action);
|
|
}
|
|
ConflictResolutionState::Dismissed { by } => push_canon(out, by),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A first-class conflict record (Chapter 6 §6.4.1).
|
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
pub struct ConflictRecord {
|
|
/// Content-derived identifier ([`derive_conflict_id`]).
|
|
pub id: ConflictId,
|
|
/// The operations that participated. At least two for a true conflict; one
|
|
/// for an operation that failed precondition checking under reduction.
|
|
pub caused_by: Vec<OperationId>,
|
|
/// The kind of conflict.
|
|
pub kind: ConflictKind,
|
|
/// Objects affected, for diagnostic and UI navigation.
|
|
pub affected_objects: Vec<TypedObjectId>,
|
|
/// Current resolution state.
|
|
pub resolution_state: ConflictResolutionState,
|
|
}
|
|
|
|
impl ConflictRecord {
|
|
/// Builds an unresolved conflict record, deriving its content id from the
|
|
/// kind, causing operations, and affected objects (Chapter 6 §6.4.3). The
|
|
/// `caused_by` and `affected_objects` vectors are stored in canonical order
|
|
/// so the record's own canonical bytes are stable regardless of how the
|
|
/// reduction assembled them.
|
|
pub fn new(
|
|
kind: ConflictKind,
|
|
caused_by: Vec<OperationId>,
|
|
affected_objects: Vec<TypedObjectId>,
|
|
) -> Self {
|
|
let id = derive_conflict_id(&kind, &caused_by, &affected_objects);
|
|
ConflictRecord {
|
|
id,
|
|
caused_by: sorted_canonical(caused_by),
|
|
kind,
|
|
affected_objects: sorted_canonical(affected_objects),
|
|
resolution_state: ConflictResolutionState::Unresolved,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl CanonicalEncode for ConflictRecord {
|
|
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
|
push_canon(out, &self.id);
|
|
push_seq(out, &self.caused_by);
|
|
push_canon(out, &self.kind);
|
|
push_seq(out, &self.affected_objects);
|
|
push_canon(out, &self.resolution_state);
|
|
}
|
|
}
|
|
|
|
/// Derives a [`ConflictId`] from a conflict's content (Chapter 6 §6.4.3).
|
|
///
|
|
/// The preimage is `"MUSCCONF" || kind.canonical_bytes() || sorted_ops ||
|
|
/// sorted_objs`, where causing operations and affected objects are each sorted
|
|
/// lexicographically by canonical bytes. No ordinal or local counter enters the
|
|
/// preimage: conflicts sharing kind, causing operations, and affected objects
|
|
/// are by definition the same conflict.
|
|
pub fn derive_conflict_id(
|
|
kind: &ConflictKind,
|
|
causing_operations: &[OperationId],
|
|
affected_objects: &[TypedObjectId],
|
|
) -> ConflictId {
|
|
let mut p = Preimage::new(DomainTag::CONFLICT);
|
|
p.push_bytes(&kind.canonical_bytes());
|
|
for op in sorted_canonical(causing_operations.to_vec()) {
|
|
p.push_bytes(&op.canonical_bytes());
|
|
}
|
|
for obj in sorted_canonical(affected_objects.to_vec()) {
|
|
p.push_bytes(&obj.canonical_bytes());
|
|
}
|
|
ConflictId(p.finish_trunc128())
|
|
}
|
|
|
|
/// The score's conflict registry (Chapter 6 §6.4.2): part of canonical
|
|
/// materialized state. Records are kept in the normative order — ascending by
|
|
/// [`ConflictId`] — so the registry's canonical bytes never depend on the order
|
|
/// in which the reduction discovered the conflicts.
|
|
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
|
pub struct ConflictRegistry {
|
|
records: Vec<ConflictRecord>,
|
|
}
|
|
|
|
impl ConflictRegistry {
|
|
/// An empty registry.
|
|
#[inline]
|
|
pub fn new() -> Self {
|
|
ConflictRegistry::default()
|
|
}
|
|
|
|
/// Inserts a record, keeping the registry ordered by `ConflictId`. If a
|
|
/// record with the same content id already exists (the same conflict
|
|
/// re-derived), the insert is idempotent — content-derived identity means
|
|
/// re-discovering a conflict is not a second conflict.
|
|
pub fn insert(&mut self, record: ConflictRecord) {
|
|
match self.records.binary_search_by(|r| r.id.cmp(&record.id)) {
|
|
Ok(_) => {} // already present; content-derived identity ⇒ idempotent
|
|
Err(pos) => self.records.insert(pos, record),
|
|
}
|
|
}
|
|
|
|
/// The records, in canonical (ascending-`ConflictId`) order.
|
|
#[inline]
|
|
pub fn records(&self) -> &[ConflictRecord] {
|
|
&self.records
|
|
}
|
|
|
|
/// A mutable handle to the record with `id`, if present.
|
|
pub fn get_mut(&mut self, id: ConflictId) -> Option<&mut ConflictRecord> {
|
|
self.records
|
|
.binary_search_by(|r| r.id.cmp(&id))
|
|
.ok()
|
|
.map(|pos| &mut self.records[pos])
|
|
}
|
|
|
|
/// Whether the registry holds any records.
|
|
#[inline]
|
|
pub fn is_empty(&self) -> bool {
|
|
self.records.is_empty()
|
|
}
|
|
}
|
|
|
|
impl CanonicalEncode for ConflictRegistry {
|
|
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
|
// Already in ConflictId order by construction.
|
|
push_seq(out, &self.records);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use epiphany_core::{EventId, ReplicaId};
|
|
|
|
fn op(r: u64, c: u64) -> OperationId {
|
|
OperationId::new(ReplicaId(r), c)
|
|
}
|
|
fn obj(n: u128) -> TypedObjectId {
|
|
TypedObjectId::Event(EventId::from_raw(n))
|
|
}
|
|
|
|
#[test]
|
|
fn resolution_action_discriminants_are_golden() {
|
|
// RATIFIED by Pass 11 (item 2.5; core_spec
|
|
// `req:semops:resolution-action-discriminants`): ResolutionAction is
|
|
// canonically encoded into operation content, so its discriminants are
|
|
// normative. Dismiss = 4 was inserted ahead of Registered (now 5); lock
|
|
// the literal values so any future reorder breaks deliberately rather
|
|
// than silently shifting the wire form.
|
|
assert_eq!(ResolutionAction::AcceptLoser.discriminant(), 0);
|
|
assert_eq!(ResolutionAction::KeepWinner.discriminant(), 1);
|
|
assert_eq!(
|
|
ResolutionAction::Override {
|
|
override_operation: op(1, 1)
|
|
}
|
|
.discriminant(),
|
|
2
|
|
);
|
|
assert_eq!(
|
|
ResolutionAction::Reanchor { new_target: obj(7) }.discriminant(),
|
|
3
|
|
);
|
|
assert_eq!(ResolutionAction::Dismiss.discriminant(), 4);
|
|
assert_eq!(
|
|
ResolutionAction::Registered(ResolutionRegistryId(0)).discriminant(),
|
|
5
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn conflict_id_is_order_independent_in_its_inputs() {
|
|
let kind = ConflictKind::TombstonedTarget {
|
|
target: obj(7),
|
|
operation: op(1, 1),
|
|
};
|
|
let a = derive_conflict_id(&kind, &[op(1, 1), op(2, 2)], &[obj(7), obj(3)]);
|
|
let b = derive_conflict_id(&kind, &[op(2, 2), op(1, 1)], &[obj(3), obj(7)]);
|
|
assert_eq!(a, b, "causing-op / affected-object order must not matter");
|
|
}
|
|
|
|
#[test]
|
|
fn distinct_kinds_yield_distinct_ids() {
|
|
let k1 = ConflictKind::TombstonedTarget {
|
|
target: obj(7),
|
|
operation: op(1, 1),
|
|
};
|
|
let k2 = ConflictKind::ReanchorFailure {
|
|
original_referent: obj(7),
|
|
referencing_object: obj(1),
|
|
};
|
|
assert_ne!(
|
|
derive_conflict_id(&k1, &[op(1, 1)], &[obj(7)]),
|
|
derive_conflict_id(&k2, &[op(1, 1)], &[obj(7)]),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn registry_is_sorted_and_idempotent_on_same_content() {
|
|
let mut reg = ConflictRegistry::new();
|
|
let mk = |t: u128| {
|
|
ConflictRecord::new(
|
|
ConflictKind::TombstonedTarget {
|
|
target: obj(t),
|
|
operation: op(1, 1),
|
|
},
|
|
vec![op(1, 1)],
|
|
vec![obj(t)],
|
|
)
|
|
};
|
|
let a = mk(5);
|
|
reg.insert(mk(9));
|
|
reg.insert(a.clone());
|
|
reg.insert(mk(1));
|
|
reg.insert(a.clone()); // re-discovering the same conflict
|
|
let ids: Vec<_> = reg.records().iter().map(|r| r.id).collect();
|
|
let mut sorted = ids.clone();
|
|
sorted.sort();
|
|
assert_eq!(ids, sorted, "registry must be ConflictId-ordered");
|
|
// a inserted twice but present once.
|
|
assert_eq!(reg.records().iter().filter(|r| r.id == a.id).count(), 1);
|
|
}
|
|
}
|